# FlowDrop Workflow Specification 1.0-draft The rules a FlowDrop workflow obeys — how a workflow is written, stored, validated and executed — stated independently of any one implementation. It is a target: where an implementation disagrees with a rule, the rule is what is intended. It carries no implementation status, and confers no certification. Source: https://flowdrop.io/spec. Licensed CC BY 4.0. This file is generated: 399 rules, in the order they were issued. Identifiers are permanent and are the correct way to cite a rule. --- # Conventions Vocabulary and references shared by every rule, stated once here so no rule has to repeat them. ## Requirement levels **must**: an absolute requirement. An implementation that does not do this does not conform. **must not**: an absolute prohibition. **should**: a strong recommendation. There may be valid reasons to do otherwise; understand them before choosing. **may**: genuinely optional. An implementation that does this and one that does not are both conforming, so a caller cannot rely on it. These words carry this meaning only in a rule's normative sentence. In narrative prose they are ordinary English. ## Refusals Where a rule says a request is **refused with `400`**, `409`, `422` and so on, the number is an HTTP status code with the meaning given in **RFC 9110, HTTP Semantics**. A refusal never partially applies: if a request is refused, nothing it asked for has taken effect. ## Validation results A validation result's errors carry **no defined order**. Two implementations refusing the same workflow may report the same errors in different sequences, and one implementation may change its own order without notice, so a consumer must not depend on it. Where a rule needs an error to be identifiable, it says so by naming the code and the locator, never the position. ## Authoring surfaces Some rules address what an authoring surface does. They constrain **the data such a surface may produce** — what it may record, and what it must not — and never how it presents controls. Widget shape, layout, enablement and the order things appear in are an implementation's own affair, and a rule that could only be satisfied by building a particular control is a defect in the rule. ## Payloads Request and response bodies are JSON, as defined in **RFC 8259**. ## Counting text Where a rule bounds the length of a string, it counts **Unicode code points** unless it says otherwise. Not bytes, which would make a limit depend on the alphabet the text is written in; and not grapheme clusters, which are closer to what a person calls a character but much harder for two implementations to agree on. ## Identifiers Rules are cited by identifier (`STORE-2`, `SCH-41`), grouped by family prefix. An identifier is permanent: never renumbered, never reused, still citable after the rule is withdrawn. ## Rulings A change to what implementations must do is recorded as a **ruling**: what was decided, and why. Rules affected by a ruling reference it. A ruling is history and is never rewritten; where it turns out to be wrong, a later ruling supersedes it and says so. ## Profiles Not every rule binds every kind of implementation. A rule declares the profiles it applies to: - **runtime**: executes workflows. - **storage-api**: stores workflows and serves them over the API. - **editor-client**: authors workflows against the API. ## Levels - **core**: the set an implementation is expected to meet before calling itself a FlowDrop implementation. - **extended**: beyond core; commonly expected, not assumed. - **optional**: genuinely optional capability. --- # GR-STORE — STORE (Part I) ## STORE-1 — A request body is bounded before anything is parsed *GR-STORE (Part I) · level: core · profiles: storage-api · added in 1.0* The cheapest refusals come first. A body that is too large, too deeply nested, or not JSON at all is turned away before any workflow-level meaning is read out of it. ### The rule > **Normative.** This is the rule. > > 1. A request body must be JSON whose top level is an object or an array. > > 2. A body that is empty, is not well-formed JSON, decodes to a scalar (`null` included), exceeds 8 MiB of octets, or nests 64 levels or deeper is refused with 400, and nothing is stored. > > 3. An implementation must accept a document nested 63 levels deep. ## What it means This is the storage door's own copy of the gate API-1 describes: the same checks, with the numbers fixed. A body over 8 MiB, nested 64 levels or deeper, malformed, or not an object or array at its top level never reaches workflow-level meaning. The depth bound is easy to get off by one. The bound is a maximum *depth*, so a document nested 64 levels deep is already one level past what is accepted; the deepest document an implementation must accept nests 63 levels. The other clause that bites is the one API-1 states in general and this rule makes concrete: a door whose body is optional is not a door the gate skips. An absent body is fine, but a body that is present and malformed is refused like any other — the gate does not degrade "nothing to check" into "nothing checked". ## Example A door whose body is optional still runs whatever is actually sent through the same gate as any other door. ```http title="A scalar, not a shape the gate can work with" verdict="400 refused" POST /api/flowdrop/workflows/{workflow}/playground/sessions 5 ``` ```http title="Well-formed on the outside, broken underneath" verdict="400 refused" POST /api/flowdrop/workflows/{workflow}/playground/sessions {"name": "unterminated ``` Sending nothing at all to this same door is still accepted — the gate's bounds apply to what is sent, not to whether something was sent. ### Related rules - Names: API-1 - Referenced by: API-1 ## STORE-2 — A workflow must be named *GR-STORE (Part I) · level: core · profiles: storage-api · added in 1.0* Every workflow carries a name, on creation and on every update. The name is what a person uses to find it again, so the system refuses to store one without it. ### The rule > **Normative.** This is the rule. > > 1. A workflow's `name` is required when it is created and when it is updated, and must be a non-empty string. > > 2. An implementation must accept a name of at least 255 Unicode code points, and may accept longer. > > 3. A request that omits the name, sends an empty one, or exceeds the implementation's limit is refused with 400, and nothing is stored. ## What it means The name is the only field a workflow cannot be stored without. Everything else either defaults or is optional: STORE-4 lets `nodes`, `edges` and `metadata` default to empty on create, and leaves them untouched on a partial update. The name is the exception; it is required on update too, so there is no name-omitted `PUT`. The refusal happens at the boundary, before the workflow is written, so a rejected update leaves the stored workflow exactly as it was. ## Example ```http title="Creating a workflow with no name" verdict="400 refused" POST /api/flowdrop/workflows { "nodes": [], "edges": [] } ``` ```http title="A name that is a single zero: a string, so a name" verdict="201 stored" POST /api/flowdrop/workflows { "name": "0" } ``` ## Why Three details here were once specified differently, each because a host language's idiom was mistaken for a decision. **A name of `"0"` is a name.** Nothing about a workflow makes a single zero unsuitable, so the rule tests for a non-empty string and nothing else. **Length counts code points, not bytes.** Counting bytes makes the limit depend on which alphabet the name is written in. Saying "characters" would only move the ambiguity. **255 is a floor, not a ceiling.** The number was borrowed from a database convention no implementation here runs. A floor gives the guarantee that matters (a name within 255 code points is accepted everywhere) without freezing one system's limit into every other. Recorded under OPEN-19, which covers seven such corrections. ### Why Recorded under OPEN-19. ### References **Normative** — incorporated into this rule: - The Unicode Standard, Definition of code point (https://www.unicode.org/versions/latest/) — The unit this rule counts. **Further reading:** - Unicode UAX, Unicode Text Segmentation (https://www.unicode.org/reports/tr29/) — Why the limit counts code points and not grapheme clusters, which are closer to what a person calls a character and much harder for two implementations to agree on. ### Related rules - Referenced by: STORE-4 ## STORE-3 — A client-supplied id never overwrites an existing workflow *GR-STORE (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A create request may supply the workflow's `id`. > > 2. If a workflow with that id already exists, the request is refused with 409 and the stored workflow is left exactly as it was. > > 3. An implementation must never silently turn a create into an update. ## What it means A caller may name the id it wants a new workflow to have, rather than being handed one. That is a convenience with one sharp edge: an id is also how an existing workflow is addressed, so a create request naming an id already in use is ambiguous between "make this" and "replace that". The rule resolves the ambiguity in one direction only — it is never an update. The incumbent workflow is left exactly as it was, and the request that collided is refused. ## Example The same id, sent as a create twice. ```http title="A new id" verdict="201 stored" POST /api/flowdrop/workflows {"id": "taken", "name": "The incumbent"} ``` ```http title="The same id, claimed again" verdict="409 refused" POST /api/flowdrop/workflows {"id": "taken", "name": "The impostor"} ``` The second request never reaches the workflow named `taken`; the incumbent keeps its own name. ### Related rules - Referenced by: STORE-9 ## STORE-4 — Absent collections default to empty, and an update touches only what it sends *GR-STORE (Part I) · level: core · profiles: storage-api · added in 1.0* An update is partial. What a caller does not send, it does not change, which is what lets an editor save one part of a workflow without holding the whole of it. ### The rule > **Normative.** This is the rule. > > 1. On create, `nodes`, `edges` and `metadata` default to the empty list when absent, and a workflow that declares no `interface` declares no ports. > > 2. On update, a key the request omits is left as stored; `name` is the exception, required on update as on create. > > 3. An `interface` present on update rewrites both port lists from it even when only one side is supplied, so a missing or empty `inputs` or `outputs` clears that side. > > 4. The `metadata` published on read need not be identical to the `metadata` as stored: an implementation may fold envelope fields such as `format` and `schemaVersion` back into it on read. ## What it means An update is partial by default: a key the request does not send is left as stored, which is what lets a caller save one part of a workflow without holding the whole of it in hand. `interface` is the one key where "sent" does not mean "sent in full" — sending it at all rewrites both the inputs and the outputs it describes, even if only one of them is included. A side left out of a present `interface`, or sent as an empty list, is cleared, not left alone. Absent and empty are the same signal there that they are not everywhere else in this rule: absent preserves, present (even partially or emptily) replaces. The `metadata` a caller reads back is not necessarily the `metadata` it would get by reading the stored document directly: an implementation may fold housekeeping fields such as a format marker or a schema version into what it publishes on read, without those fields having been part of what was stored. ## Example Three requests to the same door, none of them touching `interface` the same way. ```http title="A create with no nodes, edges or metadata" verdict="201 stored" POST /api/flowdrop/workflows {"id": "bare_wf", "name": "Bare"} ``` ```http title="An update naming only the name and the nodes" verdict="200 preserved" PUT /api/flowdrop/workflows/{workflow} {"name": "Partial renamed", "nodes": [ … ]} ``` ```http title="An update whose interface is present but empty" verdict="200 cleared" PUT /api/flowdrop/workflows/{workflow} {"name": "Clear", "interface": []} ``` The first leaves `nodes`, `edges` and `metadata` all as empty lists — nothing was sent for them, so there is nothing to default from but empty. The second leaves the edges and metadata a prior request had set untouched, because this request never mentions them. The third is the one that reads like the second but is not: `interface` was sent, so both its sides are rewritten, and an empty list on either side clears it rather than leaving it as it was. ### Related rules - Names: STORE-2, STORE-14 - Referenced by: STORE-14 ## STORE-5 — A workflow refused by validation is never partially stored *GR-STORE (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A workflow that fails validation on create or on update is refused with 422 carrying `success: false`, a human-readable `error`, and `details`, a list of `{code, message, locator}` entries, one per error, each `locator` naming the position in the submitted workflow the error is about. > > 2. Only errors refuse and only errors are reported: a workflow carrying warnings alone is stored, and its warnings appear nowhere in the response. > > 3. Create and update refuse identically, and a refused update leaves the stored workflow untouched. ## What it means A workflow either passes validation or it is refused whole; there is no version of "stored, but with problems noted". That symmetry runs both ways. On the refusing side, every error is reported — none is dropped for being one of several — and each carries a `locator` naming where in the submitted workflow it applies, since a client acting on the refusal needs to point at the offending part without guessing from the message alone. On the accepting side, a warning is not a smaller version of an error: it never blocks the save and it never appears in the response, so a caller cannot detect from the write alone that anything was noted. Create and update are refused by exactly the same check, and a refused update leaves the stored workflow exactly as it was — the same guarantee STORE-3 gives a refused create. ## Example Two requests to the same door, one over the line and one short of it. ```http title="A node whose declared executor cannot be resolved" verdict="422 refused" POST /api/flowdrop/workflows {"id": "ghost_wf", "name": "Ghost WF", "nodes": [ … ]} ``` ```http title="A node carrying a config key its type does not recognise" verdict="201 stored" POST /api/flowdrop/workflows {"id": "warn_wf", "name": "Warn WF", "nodes": [ … ]} ``` The first comes back as `{"success": false, "error": "Workflow validation failed", "details": [{"code": "R1_PLUGIN_MISSING", "message": "…", "locator": "…"}]}` — one entry per error, the whole shape a client can rely on regardless of which check fired (R1.a). Nothing from the request was stored. The second is accepted outright: an unrecognised config key is a warning, not an error, so it neither blocks the save nor shows up anywhere in the response. ### Related rules - Names: STORE-9, API-8 - Referenced by: STORE-9, STORE-10, API-8 ## STORE-7 — Stored node metadata carries the type anchor and nothing else *GR-STORE (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Storage constrains a node's `data.metadata` to a single key, `node_type_id`. > > 2. A node's `data.config` is unconstrained at storage: its shape is the node type's business and is judged by validation, not by the storage layer. ### Related rules - Names: STORE-13 - Referenced by: STORE-13 ## STORE-9 — Import gates run in a fixed order and roll back what they generated *GR-STORE (Part I) · level: extended · profiles: storage-api · added in 1.0* Importing a bundle can create node types before it knows whether the workflow is acceptable. The fixed order, and the rollback, are what keep a refused import from leaving debris behind. ### The rule > **Normative.** This is the rule. > > 1. A bundle import applies its gates in this order: envelope format, publisher trust, capability manifest, node-type generation, workflow validation, flow-id shape, id collision. > > 2. Any refusal after node-type generation rolls the generated node types back, so a refused import leaves nothing behind. > > 3. An unsupported envelope format is refused with 422. > > 4. An untrusted publisher is refused with 403, unless the caller both confirms the publisher and is permitted to do so. > > 5. A processor the installation does not have is refused with 422, reported ahead of validation so that a missing processor is actionable rather than surfacing as a structural error. > > 6. A workflow that fails validation is refused with 422 and `details`. > > 7. An id that already exists is refused with 409, never overwriting. > > 8. A non-empty flow id must match `^[a-z0-9_]+$` and be at most 64 characters; an empty flow id is accepted and one is minted. > > 9. Every refusal names its reason: it carries at least one `details` entry identifying the cause, and a flow-id refusal locates itself at `flow.id`. > > 10. Exposure entries are normalised before validation for every bundle, a trusted one included: an entry keeps only its `name`, `node_id` and `port`, and only where those are scalar, cast to string; an entry that is not an object, or is wholly malformed, becomes empty but keeps its index, so validation reports it against its own position instead of shifting every later one. > > 11. What is stored is the entry as submitted, so an author's additional entry metadata survives the import. ## What it means Generating node types happens before the workflow is known to be acceptable, because a bundle can only be validated once the processors it describes exist to validate against. That ordering choice is what forces the rest of the rule: anything the import creates ahead of validation — a node type, most of all — has to be rolled back the moment a later gate refuses, or a refused import would leave behind exactly the debris a caller has no way to see or undo. The exposure-entry coercion runs even for a bundle whose publisher is trusted. Trust decides whether the bundle may be imported at all, not whether the entries inside it are safe to hand to the workflow validator: a malformed entry is normalised to an empty one that keeps its original position, so a validation error still names the entry it belongs to instead of shifting onto whichever entry happened to land at that index next. What is stored, though, is the entry as the bundle submitted it — the coercion protects the validator's reads, not what ends up on record. A missing processor is reported ahead of workflow validation for the same reason node-type generation runs early: without the processor, the workflow would fail validation with a wall of structural errors that all trace back to one absent capability. Naming the capability first is what makes the refusal actionable. ## Example ```http title="A bundle from an untrusted publisher" verdict="403 refused" POST /api/flowdrop/workflows/import {"format": "flowdrop.bundle/v1", "payload": {"flow": {"id": "imported_flow", "label": "Imported Flow", "nodes": [ … ], "edges": []}}} ``` ```http title="A bundle needing a processor the installation lacks" verdict="422 refused" POST /api/flowdrop/workflows/import {"format": "flowdrop.bundle/v1", "payload": {"publisher": "acme", "flow": {"id": "imported_flow", "label": "Imported Flow", "nodes": [{"id": "g.1", "data": {"metadata": {"node_type_id": "ghost", "executor_plugin": "ghost_module:ghost"}}}], "edges": []}}} ``` ```http title="A bundle whose flow id is already taken" verdict="409 refused" POST /api/flowdrop/workflows/import {"format": "flowdrop.bundle/v1", "payload": {"publisher": "acme", "flow": {"id": "imported_flow", "label": "Imported Flow", "nodes": [ … ], "edges": []}}} ``` The 409 never overwrites: the workflow already on record under that id is untouched, and nothing from the rejected bundle — including any node type it would have generated — survives the refusal. ### Why Recorded under OPEN-19. ### Related rules - Names: STORE-3, STORE-5 - Referenced by: STORE-5 ## STORE-10 — Read-path repairs must never run on the save path *GR-STORE (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* Loading a workflow to run it may quietly repair it. Doing the same on the way in would hide exactly the conditions validation exists to report. ### The rule > **Normative.** This is the rule. > > 1. When a workflow is loaded to be compiled or executed, an implementation may normalise it: dropping an edge whose source or target does not resolve, minting an id for an edge that has none, filling a node's configuration so that a stored value wins over a node-type default and defaults fill only absent keys, and keying nodes and edges by id so that the last occurrence of a duplicated id wins. > > 2. None of that may happen on the save, validate or mutate path, which must see the workflow exactly as submitted or as stored. ## What it means Reading a workflow to run it is allowed to be more forgiving than reading it to save or validate it, and the rule is about keeping those two readings from sharing code. A dangling edge, a duplicated id, a node missing an edge id — compiling or executing can quietly repair all three, because a run has to proceed on the best available graph. Validation exists specifically to report those same conditions, so a repair that runs before validation sees them hides exactly what the caller needed to be told. The save, validate and mutate paths must see the workflow exactly as it was submitted or as it is stored — dangling edge, duplicate id, missing id and all. Two of the normalisations have a direction worth naming, because the wrong one is the more intuitive-sounding choice. Config filling only fills the keys an author left absent: the value the author actually saved wins over whatever default the node type now declares, never the reverse — a stale node-type default is not allowed to override real input. And when a node or edge id repeats, the last occurrence read wins, silently dropping every earlier copy under that id; a validator reading the same raw list, unrepaired, sees both copies and can report the collision the repair would have erased. ## Example A node's stored config and its node type's default disagree on one key and the default carries a second key the author never set. ```json title="The node's stored config" {"operation": "multiply"} ``` ```json title="The node type's default config" {"operation": "add", "precision": 2} ``` ```json title="What the read path resolves it to" verdict="stored wins" {"operation": "multiply", "precision": 2} ``` A workflow whose node list carries the id `a` twice reads, on the read path, as one node — the second occurrence's data, the first discarded without a trace: ```json title="Two nodes carrying the same id" verdict="last wins" [{"id": "a", "data": {"label": "first"}}, {"id": "b", …}, {"id": "a", "data": {"label": "third"}}] ``` The read path resolves this to two nodes, `a` and `b`, with `a` labelled `"third"`. Validation, reading the same three-node list unrepaired, is where a duplicate id is reported at all. ### Related rules - Names: STORE-5 ## STORE-11 — Status vocabularies are closed sets of strings *GR-STORE (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Job, pipeline, session and message status are each a closed vocabulary. > > 2. A status is a string, and one spelling serves everywhere: what is persisted is what every JSON payload carries and what every event carries. > > 3. An implementation must not persist one spelling and publish another. ### Related rules - Names: STORE-12 - Referenced by: STORE-12, STORE-13, INT-22 ## STORE-12 — A finished turn leaves the session completed, not idle *GR-STORE (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. `idle` means a session was created and has never executed. > > 2. A session whose turn has finished is released as `completed`. > > 3. The two are distinct states, and an implementation must not use `idle` to mean that a turn has finished. ### Related rules - Names: STORE-11 - Referenced by: STORE-11 ## STORE-13 — One name per concept for a node's type *GR-STORE (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* A node has a type it is an instance of and a type it is drawn as. They are different things, and a payload that spells both the same way cannot be read without knowing who wrote it. ### The rule > **Normative.** This is the rule. > > 1. A node type's identifier is `node_type_id` wherever it appears in a payload: job metadata, node metadata, a serialised node execution result, a node-status broadcast, a persisted session message. > > 2. A node's visual type is `visual_type` in a per-node snapshot payload. > > 3. The bare key `node_type` must not be written anywhere. > > 4. A node's own identifier is `node_id` and is unaffected. ## What it means A node has two identities that a payload can be forgiven for confusing: the type it is an *instance* of, and the type it is *drawn as* in an editor. The first is `node_type_id`; the second is `visual_type`. A bare `node_type` key could carry either meaning, and a reader would have no way to tell which without already knowing which producer wrote it. The rule gives each concept its own name and forbids the shared one everywhere: job metadata, node metadata, a serialised node execution result, a node-status broadcast, a persisted session message, and a per-node snapshot all name the concept they mean, not the spelling that happens to be shortest. A node's own identifier, `node_id`, is a third thing again and unaffected by any of this: it names *which* node, not *what kind*. ## Example A per-node snapshot spells the node's drawn type `visual_type`; a node execution result, carried on the same kind of event, spells the same node's type-it-is-an-instance-of `node_type_id` — two different concepts, on two different payloads, never the bare key. ```json title="A node snapshot's visual type" {"nodeStates": {"chat.1": {"metadata": {"visual_type": "terminal", "label": "Chat"}}}} ``` ```json title="A node execution result, on the same node" {"nodeId": "chat.1", "output": { … }, "node_type_id": "chat_output"} ``` ### Related rules - Names: STORE-7, STORE-11 - Referenced by: STORE-7 ## STORE-14 — Every surface that returns a workflow returns one object *GR-STORE (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* List, create, read and update all publish the same workflow object. A client learns one shape, not four. ### The rule > **Normative.** This is the rule. > > 1. Every surface that returns a workflow publishes the same keys in the same order: `id`, `name`, `description`, `nodes`, `edges`, `metadata`, `created`, `changed`, `uid`. > > 2. `nodes` is enriched with each node's node-type metadata, and `metadata` is the value as published on read. > > 3. The spelling is uniformly lower-case, `created`, `changed` and `uid` included. > > 4. A tenth key, `interface`, is appended immediately after `metadata` when the workflow declares at least one input or output port, and is omitted entirely (never emitted as an empty object or an empty list) when it declares none. > > 5. One derivation serves every surface, including any surface that embeds a workflow outside the API. ## What it means A client that has decoded a workflow from one surface can decode it from any other without a special case: same nine keys, same order, same casing. That includes the list surface, where each row is a full workflow object rather than a thinner projection — a caller does not have to fetch a workflow a second time to learn a field the list already carried. `interface`, the tenth key, is conditional on the workflow itself, not on which surface answered. A workflow with no declared input or output is one object short everywhere, not just on the surfaces that happen to check; a workflow with at least one declared port carries `interface` everywhere, never as an empty placeholder. Absent and empty mean different things elsewhere in this specification, and this is the case where the difference is load-bearing: an author who removes every declared port should see the key disappear, not turn into `{}`. ## Example Unfiltered, `interface` does not appear at all. ```http title="A workflow with no declared ports" verdict="200 read" GET /api/flowdrop/workflows/{workflow} {"id": "wf_no_ports", "name": "No ports", "description": "", "nodes": [ … ], "edges": [ … ], "metadata": { … }, "created": …, "changed": …, "uid": … } ``` Declare one input, and the same nine keys gain a tenth, in the same position, on create as much as on read. ```http title="Creating a workflow that declares one input" verdict="201 stored" POST /api/flowdrop/workflows {"id": "wf_shape_iface", "name": "Shape with interface", "nodes": [ … ], "interface": {"inputs": [{"id": "numbers", "bindings": [{"nodeId": "calc1", "portId": "values"}]}]}} ``` ```http title="The same workflow, read back" verdict="200 read" GET /api/flowdrop/workflows/{workflow} {"id": "wf_shape_iface", "name": "Shape with interface", "description": "", "nodes": [ … ], "edges": [ … ], "metadata": { … }, "created": …, "changed": …, "uid": …, "interface": {"inputs": [ … ]}} ``` The list surface answers the same shape, row by row: a caller scanning `GET /api/flowdrop/workflows` sees `interface` on exactly the rows that declare a port, never on the rows that do not. ### Related rules - Names: STORE-4, MAN-20 - Referenced by: STORE-4, STORE-6, STORE-15 ## STORE-6 — Storing a workflow drops the editor's scratch state *GR-STORE (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* A canvas carries state that means something while someone is looking at it and nothing afterwards. Storing the workflow is where that state is dropped, which is why a client reading back what it just wrote does not get it back. ### The rule > **Normative.** This is the rule. > > 1. Storing a workflow discards the transient state an editor keeps on a node while it is being edited: `selected`, `dragging`, `deletable`, and `nodeId` both on the node and inside the node's `data`. > > 2. It also reduces a node's `data.metadata` to the node type the node anchors to; the rest of the metadata is not stored, because it is restored from the node type on read (STORE-14). > > 3. A node's `measured` and `position`, and its `data.config`, `data.label` and `data.extensions`, survive the write unchanged. > > 4. A node whose node-type anchor does not resolve is exempt: it keeps its metadata and its `type` verbatim, since nothing could restore them on read. > > 5. Discarding is idempotent, so storing an already-stored workflow drops nothing further. ## What it means An editor keeps state that only means something while a person is looking at it — which node is selected, which is mid-drag, whether it can currently be deleted. Storing the workflow is where that state is thrown away, which is why reading back what was just written does not return it: it was never kept in the first place. The same write also trims a node's own metadata down to the one thing storage needs to find the node's type again. Everything else in that metadata is not lost so much as not this layer's problem: it is rebuilt from the type on read (STORE-14). That rebuilding is what makes the trim safe — and it is also what makes the one exception necessary. A node whose declared type cannot be resolved has nothing to rebuild from, so trimming its metadata would destroy information with no way back. That node keeps its metadata, and its own `type`, exactly as sent. Doing this twice changes nothing the second time: a workflow that has already been through this write has nothing left in it for the write to find. ## Example Two nodes sent in the same shape, one with a type the store can resolve and one without. ```http title="A node whose type resolves, carrying the editor's own state alongside it" verdict="201 stored" POST /api/flowdrop/workflows {"id": "wf", "name": "WF", "nodes": [{"id": "calculator.1", "type": "universalNode", "position": {"x": 900, "y": 200}, "data": {"label": "E2E Calculator", "config": {"operation": "add"}, "metadata": {"id": "calculator", "name": "Derived Name"}, "nodeId": "calculator.1"}, "deletable": true, "measured": {"width": 290, "height": 647}, "selected": true, "dragging": false}]} ``` ```http title="A node with nothing to resolve its type from" verdict="201 stored" POST /api/flowdrop/workflows {"id": "wf2", "name": "WF2", "nodes": [{"id": "mystery.1", "type": "universalNode", "data": {"metadata": {"name": "Hand written", "inputs": []}, "nodeId": "mystery.1"}, "selected": true}]} ``` The first node keeps its `position`, `measured` and `data.config` unchanged, loses `selected`, `dragging`, `deletable` and `data.nodeId`, and has its `data.metadata` reduced to `{"node_type_id": "calculator"}`. The second loses the same editor-only fields, but its metadata and its `type` survive untouched — there is no type to rebuild them from later, so nothing is thrown away that could not be recovered. ### Related rules - Names: STORE-14 ## STORE-8 — A published contract version is three numbers *GR-STORE (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* The version is read by consumers that never read the contract itself, a cache validator among them, so it has to be a value that always exists and always compares. ### The rule > **Normative.** This is the rule. > > 1. A workflow's published contract version is three dot-separated non-negative integers. > > 2. A workflow whose contract has never been built carries `0.0.0`, the version from which the first build's bump is taken (MAN-18). > > 3. The version is never empty and never absent, so a consumer always has a usable value, including one using it as a cache validator (META-6). ### Related rules - Names: MAN-18, META-4, META-6 ## STORE-15 — Searching the workflow list matches a literal substring of the name *GR-STORE (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* The term is text to find, not a pattern to interpret, so a name containing a percent sign is found by searching for a percent sign. ### The rule > **Normative.** This is the rule. > > 1. A list request may carry a search term, which filters the list to workflows whose name contains it, compared without regard to case and matched anywhere in the name. > > 2. The term is matched literally and carries no pattern syntax: `%` and `_` match themselves and nothing else. > > 3. The same filter applies to the count, so the reported total describes the filtered set rather than the collection (API-6). > > 4. A term that matches nothing answers an empty page with a coherent pagination block, not a refusal and not a not-found. > > 5. A term of `0` is an ordinary search term. > > 6. A search parameter that is not a single scalar value is refused with `400`. ## What it means A search term is text to find, never a pattern to interpret. That matters because the family of characters a pattern language treats specially — `%` and `_`, in the pattern syntax this rule explicitly rules out — are ordinary characters in a workflow name, and a caller who names a workflow `100% coverage` has to be able to find it by searching for the percent sign literally, not by learning to escape it first. A term that matches nothing is not a refusal and not a not-found: it is an ordinary page, empty, with a pagination block that still reports correctly on zero rows. And the filter and the count agree with each other — the reported total describes the filtered set the caller is looking at, not the whole collection behind it (API-6). ## Example ```http title="A search matching two workflows by a case-different substring" verdict="200 paginated" GET /api/flowdrop/workflows?search=INVOICE {"success": true, "data": [ … ], "pagination": {"total": 2, "limit": 50, "offset": 0, "has_more": false}} ``` ```http title="A percent sign, matched literally rather than read as a wildcard" verdict="200 paginated" GET /api/flowdrop/workflows?search=%25 {"success": true, "data": [ … ], "pagination": {"total": 1, "limit": 50, "offset": 0, "has_more": false}} ``` ```http title="A term no name contains" verdict="200 paginated" GET /api/flowdrop/workflows?search=nothing+here {"success": true, "data": [], "pagination": {"total": 0, "limit": 50, "offset": 0, "has_more": false}} ``` A wildcard reading of `_nvoice` would match a name containing `Invoice`; a literal one, which is what this rule requires, matches nothing — the same empty, coherent answer as any other term nothing contains. ### Why Recorded under OPEN-19. ### Related rules - Names: API-6, STORE-14 --- # GR-API — API (Part I) ## API-1 — Every JSON door applies the same body gate, and reports its refusals *GR-API (Part I) · level: core · profiles: storage-api · added in 1.0* A caller should not have to learn which endpoint bounds its input. Every door that takes a JSON body applies the same limits and gives the same answer when they are exceeded. ### The rule > **Normative.** This is the rule. > > 1. Every door that accepts a JSON request body applies the same body gate: the same size, depth and top-level shape limits, refused the same way. > > 2. A refusal from that gate reaches the caller as the 400 it is; an implementation must not report it as a server error. > > 3. Where a body is optional, its absence is mapped to an empty object ahead of the gate and everything else goes through the gate; optional never means unvalidated. ## What it means A door is any route that takes a JSON request body. The gate is the check every such door runs before it reads a single field: is the body present, is it within the size bound, does it nest no deeper than the depth bound, is it JSON at all, and is its top level a shape the door can work with. The rule does not fix the bounds; it fixes that there is one gate, that every door runs the same one, and that an implementation cannot make an exception for one route because that route "never gets big bodies". Two consequences carry most of the weight. **A gate refusal is a 400, never a 500.** The gate refuses because of what the caller sent, so the answer is the caller's to act on. An implementation that lets the refusal fall through a general failure handler and surface as a server error has told the caller the wrong thing, and made the two indistinguishable. API-7 says why that matters in general; this rule is the specific case where the door already knows the answer. **Optional is a statement about presence, not about checking.** Where a door's body is optional, an absent body is read as `{}` before the gate, and the gate then runs on that. A body that is present goes through the gate whether or not the door needed it. So a malformed body on an optional-body door is refused, not quietly treated as absent. ## Example The same three bodies, sent to any JSON door, get the same three answers. The gate answers before any workflow-level meaning is read, so the door's own rules never see the first two. ```http title="A body that is not JSON" verdict="400 refused" POST /api/flowdrop/workflows {"name": ``` ```http title="Well-formed JSON whose top level is a scalar, not a shape a door can read fields from" verdict="400 refused" POST /api/flowdrop/workflows "just a string" ``` ```http title="An object: through the gate, and on to the door's own rules" verdict="201 stored" POST /api/flowdrop/workflows {"name": "WF"} ``` Whatever the door refuses from here on (STORE-2, say) is a workflow-level refusal, and the gate has nothing further to say. ### Related rules - Names: STORE-1, API-7 - Referenced by: STORE-1, INT-16 ## API-2 — The turn door and the launch door judge inputs identically *GR-API (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A session turn's `inputs` are checked exactly as a launch's are: an undeclared key is refused, a declared required input is enforced, values are checked against the declared schema, and resolution into the run's initial data is strict. > > 2. The same body earns the same verdict at both doors. > > 3. A turn must not accept, by merging raw caller input into the run's initial data, anything the launch door would refuse. > > 4. A turn whose inputs are refused is refused with 400. ## What it means A workflow can be run through two doors: launched directly, or driven a turn at a time inside a session. The author decided what the workflow accepts once, by declaring its inputs (MAN-1). This rule says that decision means one thing regardless of which door a caller comes through. The four checks the launch door runs (MAN-13, MAN-15) are the four checks the turn door runs, on the same `inputs` object, with the same answer. The clause about merging is the one that bites. A turn carries its `inputs` alongside the message, and the shortest implementation copies them straight into the run's initial data. That is a second, unguarded door: a caller who knows a workflow's internal node-keyed shape (`chat_input.1`, say) could seed state the author never published, at the turn door, when the launch door would have refused the identical body. Not a declared input name is not a declared input name, whichever door it arrives at. "Identically" is a property of the whole surface, not of the cases someone happened to test at both doors. Two checkers maintained side by side drift exactly where one door has a case the other has not seen; the observable requirement is that no such case exists. ## Example The workflow declares one input, `greeting`. The same three `inputs` objects, sent as a turn, get the verdicts the launch door gives them. ```http title="An input the workflow never declared" verdict="400 refused" POST /api/flowdrop/session/{session}/turn {"content": "hi", "inputs": {"not_a_declared_input": "x"}} ``` ```http title="The internal node-keyed shape: not a declared name, so unknown" verdict="400 refused" POST /api/flowdrop/session/{session}/turn {"content": "hi", "inputs": {"chat_input.1": {"message": "seeded"}}} ``` ```http title="A declared input" verdict="202 accepted" POST /api/flowdrop/session/{session}/turn {"content": "hi", "inputs": {"greeting": "hello"}} ``` The first refusal names the inputs the workflow does accept, as MAN-13 requires of the launch door. A turn with no `inputs` at all is valid: absent is not the same as undeclared. ### Related rules - Names: MAN-13, MAN-15 ## API-3 — A session with no workflow is a conflict, not a server error *GR-API (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A request against a session that has no associated workflow is refused with 409 on every door that serves that session; it is a problem with the session's state, the same refusal family as a turn refused because one is already running. > > 2. The response carries a generic message; the detail that identifies the session goes to the log and never into the response body. ## What it means A session that has lost its workflow reference has not been sent anything wrong — there is nothing to correct in the request. The problem is the session's own state, so the refusal is the same family as a turn refused because another one is already running: a 409, not a 400 and not a 500. This holds on every door that serves that session. The response carries a fixed, generic message. The detail that would identify which session failed goes to the log and never into the body a caller sees — so a refusal that would otherwise leak a session's existence to someone who should not learn it stays silent about which one. ## Example A turn sent to a session whose workflow reference has been cleared is refused before anything about the turn itself is read. ```http title="A turn against a session with no workflow" verdict="409 refused" POST /api/flowdrop/session/{session}/turn {"content": "hi"} ``` Whatever door reaches that same session gives the same verdict and the same generic body. ### Related rules - Names: API-7, API-8 - Referenced by: API-7, API-8 ## API-4 — Node configuration could be checked ahead of save *GR-API (Part I) · level: optional · profiles: storage-api · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. A node's configuration could be checked ahead of save, by submitting the configuration on its own to a per-node-type validation endpoint. > > 2. That surface is withdrawn: judging a configuration without the node's edges reports problems the save path accepts, and save-time validation is the one surface that issues a verdict on a node's configuration. *This rule is deprecated. It is kept so it stays citable.* ## API-5 — The paginated envelope is a third envelope with four fixed keys *GR-API (Part I) · level: core · profiles: storage-api · added in 1.0* Two envelopes carry a result and a refusal. A paginated result is a third, and its pagination block belongs to the envelope rather than to the rows it sits beside. ### The rule > **Normative.** This is the rule. > > 1. Alongside `{success, data}` and `{success, error}`, a paginated response is `{success, data, pagination}`, where `pagination` carries exactly `total`, `limit`, `offset` and `has_more`, in that order. > > 2. `has_more` is page arithmetic, `(offset + limit) < total`, and not a second query. > > 3. It is spelled `has_more` in every paginated response, whatever the spelling convention of that endpoint's rows: it belongs to the shared envelope, and an implementation must not rename it to match the rows beside it. > > 4. A door must not build a pagination block of its own. ## What it means A result and a refusal are the two envelopes every door already answers in. A paginated result is a third, and it is a shape of its own, not the result envelope with a block appended by whichever door happens to return one: the same four keys, in the same order, on every paginated door. `has_more` is arithmetic the door itself can do — whether the offset and limit it just reported would still leave rows unseen — not a second query run to find out. And it keeps its snake_case spelling regardless of the casing convention the rows beside it use: the block belongs to the shared envelope, not to the endpoint it happens to sit in, so a door must not rename it to match its own rows, and must not build a pagination block of its own. ## Example Two workflows, unfiltered: three keys, and the pagination block reports the whole set (rows elided). ```http title="A list door with two rows" verdict="200 paginated" GET /api/flowdrop/workflows {"success": true, "data": [ … ], "pagination": {"total": 2, "limit": 50, "offset": 0, "has_more": false}} ``` The same four keys, in the same order, sit beside rows spelled the other way. ```http title="A list door whose rows are spelled the other way" verdict="200 paginated" GET /api/flowdrop/workflows/{workflow}/playground/sessions {"success": true, "data": [ … ], "pagination": {"total": 1, "limit": 50, "offset": 0, "has_more": false}} ``` `has_more` is spelled the same both times: once beside rows spelled the same way as the pagination block, once beside rows that are not. ### Related rules - Names: API-6 - Referenced by: API-6 ## API-6 — Paging is clamped silently, and the clamped values are what is reported *GR-API (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A paginated door caps `limit` at 100 and floors `offset` at 0. > > 2. Out-of-range paging is corrected, never refused: there is no 400 for it. > > 3. The corrected values are what the pagination block reports, so a caller that asked for 1000 and was served 100 is told 100 and its `has_more` arithmetic holds. > > 4. `total` is counted before pagination and under every filter the result carries, so a filtered page's total describes the filtered set and not the whole collection. ## What it means Out-of-range paging is corrected, not refused — there is no 400 for asking for too much or for a negative offset. But the correction is not silent in the sense of invisible: the values reported back are the ones actually used, so a caller who asked for far more rows than the cap allows is told the capped number, and its own arithmetic on `has_more` still holds. `total` is counted before pagination is applied, and under whatever filter the request carries. A filtered page's total describes the filtered set, not the whole collection — a search that matches nothing is an empty page with a `total` of zero, not the size of the unfiltered table. ## Example Asking for far more than the cap still gets a coherent, corrected answer. ```http title="A limit far above the cap" verdict="200 clamped" GET /api/flowdrop/workflows?limit=1000 {"pagination": {"total": 3, "limit": 100, "offset": 0, "has_more": false}} ``` A filter that matches nothing still gets a well-formed, empty page. ```http title="A search term matching no row" verdict="200 counted" GET /api/flowdrop/workflows?search=nothing+here {"pagination": {"total": 0, "limit": 50, "offset": 0, "has_more": false}} ``` `total` is the filtered count in both cases, never the size of the whole collection behind it. ### Related rules - Names: API-5 - Referenced by: API-5, STORE-15 ## API-7 — A generic failure is a last resort, never a design *GR-API (Part I) · level: extended · profiles: storage-api · added in 1.0* A catch-all failure answer is a reporting device. Where it stands in for a refusal the door could have named, it renders "this endpoint has never worked" indistinguishable from "the server hiccupped". ### The rule > **Normative.** This is the rule. > > 1. A failure an implementation cannot attribute to a specific cause is answered with a fixed generic message; the underlying failure's own message is logged and never reaches the response body. > > 2. A failure an implementation can classify must be answered as that classification (a client error as a client error, a refusal by the name the door has for it), and a generic server failure must never stand in for a refusal the door is able to name. ## What it means A generic failure message exists for the case an implementation genuinely cannot name: something broke, and nothing about the failure tells the door which refusal it should have been. That message is a last resort, not a convenience. Anything a door can attribute — a record that does not exist, a caller without the permission it needed, a body that failed the shared gate — must be answered as that specific thing, never folded into the same catch-all just because the code path happens to have one. The two failures read identically to a caller who only sees a status and a message that says nothing: "this has never worked" and "something broke just now" become indistinguishable. The underlying cause still belongs in the log; only the caller-facing body is generic, and only when nothing more specific was knowable. ## Example The same route answers a missing record and a denied one differently — neither collapses into the other's message. ```http title="A pipeline id nothing matches" verdict="404 refused" GET /api/flowdrop/pipeline/{pipeline}/logs {"success": false, "error": "Pipeline with ID 99999 does not exist."} ``` ```http title="The same route, denied to a caller with no permission on it" verdict="403 refused" GET /api/flowdrop/pipeline/{pipeline}/logs {"success": false, "error": "Access denied"} ``` Nothing this door cannot attribute to one of these is answered any other way: it gets the one fixed message, and the detail stays in the log. ### Related rules - Names: API-3, API-8 - Referenced by: API-1, API-3, API-8 ## API-8 — Every refusal carries a stable machine-readable code *GR-API (Part I) · level: core · profiles: storage-api · added in 1.0* A client has to be able to tell one refusal from another without reading English. Codes are the contract; the message is for a person. ### The rule > **Normative.** This is the rule. > > 1. Every refusal an API door emits carries a stable, machine-readable `error_code` alongside the human-readable `error` string. > > 2. Message text is never contract: a client must not classify a refusal by matching its message, and an implementation must not treat wording as load-bearing. > > 3. A code has one published definition that a client and a test can both name by it, and once published a code's meaning never changes and the code is never reused. ## What it means The human-readable message on a refusal is for a person; it is never what a client is allowed to match against. A client that classifies a refusal by matching a substring of the message is reading a field that can be reworded at will, and a rewording that drops the substring it depended on reclassifies the refusal without anything failing anywhere. The `error_code` exists so a client never has to do that. A code, once published, keeps its meaning forever and is never reused for something else — the same status code can and does cover more than one reason, and the code is what tells those reasons apart. ## Example Two refusals on sibling doors share a status and nothing else. ```http title="Resuming a pipeline with no pause to resolve" verdict="409 NO_ACTIVE_PAUSE" POST /flowdrop/api/pipelines/{pipeline}/resume {} ``` ```http title="Cancelling a pipeline that already has a signal pending" verdict="409 INWARD_SIGNAL_ALREADY_PENDING" POST /flowdrop/api/pipelines/{pipeline}/cancel {} ``` Both are 409s; the code is what a client reads to tell one from the other. ### Why Recorded under OPEN-18. ### Related rules - Names: STORE-5, API-3, API-7 - Referenced by: STORE-5, API-3, API-7, INT-16 --- # GR-VAL — VAL (Part I) ## R5.a — A workflow is bounded to 500 nodes *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A workflow must not contain more than 500 nodes. > > 2. A workflow that does is refused with the error code `R5_TOO_MANY_NODES`, and the save does not take effect. ## R5.b — A workflow is bounded to 1000 edges *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A workflow must not contain more than 1000 edges. > > 2. A workflow that does is refused with the error code `R5_TOO_MANY_EDGES`, and the save does not take effect. ## R5.c — Every node carries an id *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* A node without an id cannot be referred to by an edge, an exposure entry or a runtime result, so a workflow containing one is refused rather than stored. ### The rule > **Normative.** This is the rule. > > 1. Every node in a workflow must carry a non-empty `id`. > > 2. A node whose `id` is absent or empty is refused, and because the node cannot be named the error identifies it by its position in the workflow's node list. ## R1.a — A node's executor must exist *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node whose node type resolves to an executor the implementation does not provide is refused with the error code `R1_PLUGIN_MISSING`. > > 2. The verdict is reached from the executor's declaration alone; the executor is never constructed in order to decide it. ### Related rules - Names: R9 - Referenced by: R1.b, R1.c, R9 ## R1.b — A node with no resolvable node type is not judged by R1 *GR-VAL (Part I) · level: extended · profiles: storage-api · added in 1.0* Nodes that carry no node type anchor at all (notes and other non-executable decoration) are legitimate, and must not be refused for having no executor. ### The rule > **Normative.** This is the rule. > > 1. R1 does not apply to a node that carries no node type anchor, or whose anchor does not resolve to a node type; such a node is never refused for a missing executor. > > 2. A node anchored to an unknown node type is refused by R9 instead, and a node with no anchor at all is accepted. ### Related rules - Names: R1.a, R9 - Referenced by: R9 ## R1.c — A missing executor is reported once per node *GR-VAL (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node whose executor is missing yields exactly one R1 error, however many ports the node exposes. > > 2. The defect is a property of the node, and is never re-reported per port. ### Related rules - Names: R1.a ## R6.b — Required config keys must be present *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* The list of required keys is the one the node type's derived config schema publishes, not the node type's raw required flags; a parameter that can be fed by an edge is deliberately not required in config. ### The rule > **Normative.** This is the rule. > > 1. Every key the node's derived config schema marks required must be present in the node's `config`. > > 2. A key present with the value `null` counts as present. > > 3. Each missing key yields one `R6_CONFIG_REQUIRED` at `node.{id}.config.{key}` (where `{id}` is the node's id and `{key}` the missing key), and the save does not take effect. > > 4. A parameter the node type also marks connectable does not appear on the derived required list, so its absence from `config` is accepted at save even when no edge supplies it; the omission is decided from the node type's declared flags and never from the workflow's actual edges. ### Related rules - Names: R6.i - Referenced by: R6.i, R6.a ## R6.c — An unknown config key warns but does not block *GR-VAL (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A key in a node's `config` that the node's derived config schema does not declare does not refuse the save. > > 2. It yields a warning at `node.{id}.config.{key}` saying the value is ignored at execution, and the workflow is stored. ## R6.d — A config value must match its declared type *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* R6.d through R6.h are one check with several arms. They share a code and a locator and stop at the first violation, so a reader of the result sees at most one error per config key. ### The rule > **Normative.** This is the rule. > > 1. A config value whose type does not match the `type` its schema declares is refused with `R6_CONFIG_INVALID` at `node.{id}.config.{key}`, where `{id}` is the node's id and `{key}` the config key. > > 2. A schema that declares no `type`, or one that declares a type name the implementation does not recognise, imposes no type constraint and the value passes. > > 3. R6.d to R6.h all raise this same code at this same locator and are evaluated in one fixed order (type, then `enum`, then `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`) stopping at the first violation, so one config key never yields more than one error. > > 4. The arms are distinguished by the error's message, not by its code. ### Related rules - Names: R6.e, R6.f, R6.g, R6.h - Referenced by: R6.e, R6.f, R6.g, R6.h, R11 ## R6.e — A config value must be one of its enumerated values *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a config value's schema declares an `enum`, the value must be one of the listed values. > > 2. Comparison is exact and applies no type coercion, so the number `1` does not satisfy an `enum` listing the string `"1"`. > > 3. A value outside the list is refused with `R6_CONFIG_INVALID` at `node.{id}.config.{key}`. ### Related rules - Names: R6.d - Referenced by: R6.d ## R6.f — Numeric bounds apply to numbers only *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A config value below its schema's `minimum` or above its `maximum` is refused with `R6_CONFIG_INVALID` at `node.{id}.config.{key}`. > > 2. Bounds are evaluated only for a value that is a JSON number; a string is never bounds-checked, even when it spells a number. ### Related rules - Names: R6.d - Referenced by: R6.d ## R6.g — Length bounds count characters, never bytes *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* A limit measured in bytes would depend on the alphabet the author writes in, so the same word would fit one implementation and not another. ### The rule > **Normative.** This is the rule. > > 1. A string config value shorter than its schema's `minLength` or longer than its `maxLength` is refused with `R6_CONFIG_INVALID` at `node.{id}.config.{key}`. > > 2. Both bounds count Unicode code points, never bytes. ### Related rules - Names: R6.d - Referenced by: R6.d ## R6.h — A pattern mismatch is refused, a broken pattern is not *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* A pattern the implementation cannot compile is a defect in the node type, not in the workflow, and the author who cannot fix it must not be blocked by it. ### The rule > **Normative.** This is the rule. > > 1. A string config value that does not match its schema's `pattern` is refused with `R6_CONFIG_INVALID` at `node.{id}.config.{key}`. > > 2. Where the pattern itself is not a valid expression, the value is not refused: the workflow is accepted and no error is charged to the author. ### Related rules - Names: R6.d - Referenced by: R6.d ## R6.i — A null config value means unset *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A config value of `null` means the key is unset. > > 2. It satisfies a required key, and no schema constraint is evaluated against it, so a `null` never fails type, `enum`, bounds or `pattern`. ### Related rules - Names: R6.b - Referenced by: R6.b ## R6.j — No node type, no schema-driven config verdict *GR-VAL (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. When a node's node type does not resolve there is no derived config schema to judge its `config` against, so the schema-driven checks of R6 (required keys, type, `enum`, bounds, `pattern`, and the unknown-key warning) are not applied to that node, and produce neither error nor warning for it. > > 2. The unresolvable node type is reported by R9. ### Related rules - Names: R9 - Referenced by: R6.a ## R6.l — A malformed secret reference warns the author *GR-VAL (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* Writing `{{ secrets.NAME }}` instead of `${{ secrets.NAME }}` produces a literal string at execution rather than a secret, silently and with no error. The warning exists to catch the missing `$` while the author is still looking. ### The rule > **Normative.** This is the rule. > > 1. A string in a node's `config` that contains `{{ secrets.` without the leading `$` yields a warning at `node.{id}.config.{key}` telling the author to write `${{ secrets.NAME }}`. > > 2. Config is walked recursively, so a reference nested inside structured config is covered. > > 3. A correctly written `${{ secrets.NAME }}` produces no warning, and the save is never blocked either way. ## R6.k — There is no separate per-executor config verdict *GR-VAL (Part I) · level: extended · profiles: storage-api · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. A per-executor configuration-validation step was once part of the node executor contract. > > 2. It decided nothing, and it is withdrawn: an implementation must not gate a save on one. > > 3. Save-time verdicts on a node's configuration come from R6 and R11 alone, and a value that is only wrong at execution fails inside the node when it runs. *This rule is deprecated. It is kept so it stays citable.* ### Related rules - Names: R11 ## R8.a — An edge's source node must exist *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge whose `source` does not name a node in the workflow is refused with `R8_EDGE_SOURCE_MISSING` at `edge.{index}`, where `{index}` is the edge's position in the workflow's edge list. > > 2. An empty `source` counts as missing. ### Related rules - Names: R8.b - Referenced by: R8.b, R8.c, R7.e/f, W-T, R12 ## R8.b — An edge's target node must exist *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge whose `target` does not name a node in the workflow is refused with `R8_EDGE_TARGET_MISSING` at `edge.{index}`, where `{index}` is the edge's position in the workflow's edge list. > > 2. An empty `target` counts as missing. ### Related rules - Names: R8.a - Referenced by: R8.a, R8.c, R7.e/f, W-T, R12 ## R8.c — Two dangling endpoints are two errors *GR-VAL (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge whose `source` and `target` are both missing yields two errors, one for each endpoint, both at the same `edge.{index}` locator. > > 2. The endpoints are judged independently, so neither failure suppresses the other. ### Related rules - Names: R8.a, R8.b ## R7.a — An edge may not target a hidden input port *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > An edge whose target port is declared by the target node's node type but is not exposed on that node is refused with `R7_EDGE_TARGET_NOT_EXPOSED` at `edge.{index}`, where `{index}` is the edge's position in the workflow's edge list. ### Related rules - Names: R7.c - Referenced by: R7.c, R7.d, R7.e/f ## R7.b — An edge may not leave a hidden output port *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > An edge whose source port is declared by the source node's node type but is not exposed on that node is refused with `R7_EDGE_SOURCE_NOT_EXPOSED` at `edge.{index}`. ### Related rules - Names: R7.c - Referenced by: R7.c, R7.d, R7.e/f ## R7.c — A node instance decides which of its ports are exposed *GR-VAL (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* One chain answers "is this port exposed?" everywhere it is asked: for edges and for the workflow's exposure map alike. ### The rule > **Normative.** This is the rule. > > 1. A port's effective exposure on a node is the instance's own `config.ports[].exposed` value where the instance sets one, and otherwise the port's `exposedByDefault` in the node type's metadata. > > 2. Exposing a port on the instance therefore makes an edge to or from it legal that would otherwise be refused. ### Related rules - Names: R7.a, R7.b, R10 - Referenced by: R7.a, R7.b, R10 ## R7.d — A port the node type does not declare is outside exposure checking *GR-VAL (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > A port that appears on an edge but is not declared in the node type's metadata (a dynamic or author-defined port) is out of scope for R7, and an edge over it is not refused for being unexposed. ### Related rules - Names: R7.a, R7.b, R10 - Referenced by: R10 ## R7.e/f — Exposure checking is skipped per endpoint, not per edge *GR-VAL (Part I) · level: extended · profiles: storage-api · added in 1.0* Skipping the whole edge would let one broken end hide a real fault at the other. Each end is judged on its own, so an edge can carry a dangling-source error and a hidden-target error at once. ### The rule > **Normative.** This is the rule. > > 1. Where an edge endpoint cannot be judged for exposure (its handle cannot be parsed, the node it names is empty or absent, or that node's node type does not resolve), R7 is skipped for that endpoint only. > > 2. The edge's other endpoint is still checked. > > 3. A node whose node type is unknown is reported by R9, a node whose node type resolves but whose executor is missing by R1, and a node with no node type anchor by neither; in all three cases R7 itself stays silent. ### Related rules - Names: R7.a, R7.b, R8.a, R8.b, R9 ## R4.a — An exposed workflow port must be named in the permitted alphabet *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* An exposure name is a caller-facing parameter name. Restricting it to a small alphabet keeps it usable as a key in every context a caller passes it through. ### The rule > **Normative.** This is the rule. > > 1. A workflow exposure entry's `name` must match `^[a-z0-9_-]+$`. > > 2. A name that is absent, empty or outside that alphabet is refused with `R4_NAME_FORMAT` at `schema.{side}.{index}`, where `{side}` is `input` or `output` and `{index}` the entry's position in that side's list. > > 3. Unlike R4.c, this failure does not skip the entry's remaining checks: uniqueness, node existence and port declaration are all still evaluated, so two entries with no name at all yield two `R4_NAME_FORMAT` errors and one `R4_NAME_DUPLICATE` on the empty name. ### Related rules - Names: R4.b, R4.c, R4.e - Referenced by: R4.b, R4.c, R4.d, R4.e ## R4.b — Exposure names are unique per side *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Workflow exposure names must be unique within a side. > > 2. Inputs and outputs are independent, so one name may appear once as an input and once as an output. > > 3. A repeat within one side is refused with `R4_NAME_DUPLICATE` at `schema.{side}.{index}`. ### Related rules - Names: R4.a - Referenced by: R4.a, R4.d ## R4.c — An exposure entry must name a node that exists *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A workflow exposure entry's `node_id` must name a node in the workflow. > > 2. An entry that does not is refused at `schema.{side}.{index}`, and the entry's remaining checks are skipped, since none of them can be decided without the node. ### Related rules - Names: R4.a, R4.d - Referenced by: R4.a ## R4.d — An exposure entry must name a port the node declares *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A workflow exposure entry's `port` must be declared by the named node: as an input parameter for an entry on the input side, as an output for one on the output side. > > 2. An entry naming an undeclared port is refused at `schema.{side}.{index}`. > > 3. Where the node's executor cannot be resolved the port cannot be checked, so this check and R10 are skipped for that entry rather than guessed at; the skip is narrower than the entry, and R4.a's name format and R4.b's uniqueness (both decided from the entry alone) are still reported. ### Related rules - Names: R4.a, R4.b, R10 - Referenced by: R4.c, R10 ## R4.e — An exposure name may not collide with a reserved runtime name *GR-VAL (Part I) · level: extended · profiles: storage-api · added in 1.0* R4.a's alphabet admits names beginning with underscores, which is where the runtime's own injected parameters live. Without this rule an author could claim one of them and shadow it. ### The rule > **Normative.** This is the rule. > > 1. A workflow exposure entry's `name` must not be a name the runtime reserves for parameters it injects into a workflow's manifest; `__interrupt_id__` is such a name. > > 2. A reserved name is refused with `R4_NAME_RESERVED` at `schema.{side}.{index}`, on the input side and the output side alike. ### Related rules - Names: R4.a - Referenced by: R4.a ## W-T — An edge leaving a terminal node warns *GR-VAL (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* A terminal node ends the run, so nothing downstream of it will execute. The edge is legal (the author may be mid-edit), but it is almost certainly not what they meant. ### The rule > **Normative.** This is the rule. > > 1. Every edge whose source node is a terminal node yields one non-blocking warning at `edge.{index}`, naming the terminal source and the target it leads to. > > 2. A terminal node with several outgoing edges therefore produces one warning per edge, and the workflow stays valid. > > 3. Only sources are inspected, so a workflow with no edges looks nothing up, and the target's existence is not checked here: an edge from a terminal node to a deleted node produces both this warning and R8's missing-target error. > > 4. An edge whose source is itself dangling produces no warning; R8 owns that. ### Related rules - Names: R8.a, R8.b ## VAL-LAUNCH — The validator runs again at launch, before anything is created *GR-VAL (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* A definition can become invalid after it was saved: a node type removed, a workflow written by a path that skipped validation. Re-checking at launch means an invalid workflow fails as a refusal, not as a half-built run. ### The rule > **Normative.** This is the rule. > > 1. Launching a workflow re-runs every validation rule against the stored definition. > > 2. On any error the launch is refused before any of its effects exist (no jobs are generated, no pipeline is created, nothing is scheduled and nothing is queued), and the refusal carries the full set of errors. > > 3. Over the API the refusal is `422`, with each error reported as a `code`, a `message` and a `locator`, exactly as the save path reports them. ## R2 — Node ids are unique within a workflow *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* A workflow is read by keying its nodes on their ids. Two nodes sharing one id would silently collapse to whichever was read last, so the duplicate is refused at save instead. ### The rule > **Normative.** This is the rule. > > 1. Node `id`s must be unique within a workflow. > > 2. A workflow containing two nodes with the same `id` is refused with the error code `R2_NODE_DUPLICATE_ID`, and the save does not take effect. ### Why Recorded under OPEN-4. ## R3 — Every edge carries a unique id *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Every edge in a workflow must carry an `id`, and edge `id`s must be unique within the workflow. > > 2. An edge with no `id` is refused with `R3_EDGE_MISSING_ID`, a repeated `id` with `R3_EDGE_DUPLICATE_ID`, and in either case the save does not take effect. > > 3. An implementation may mint an id for an edge that lacks one while reading an already-stored definition; that tolerance is for legacy data only and never relaxes the requirement at save. ### Why Recorded under OPEN-5. ## R9 — A node's node type anchor must resolve *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* R9 and R1 are deliberately disjoint: a node type that does not exist is R9's, a node type that exists but has no executor is R1's. One defect earns one report. ### The rule > **Normative.** This is the rule. > > 1. A node's `node_type_id` anchor must resolve to an existing node type. > > 2. A node anchored to an unknown node type is refused with `R9_NODE_TYPE_UNKNOWN`, and the error names both the node and the node type it asked for. > > 3. A node carrying no anchor is out of scope and is not refused by this rule. ### Why Recorded under OPEN-6. ### Related rules - Names: R1.a, R1.b - Referenced by: R1.a, R1.b, R6.j, R7.e/f ## R10 — A workflow may not expose a port hidden on its node *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* Hidden means hidden in both directions. R7 stops an edge reaching a hidden port; R10 stops the workflow's public surface reaching one. ### The rule > **Normative.** This is the rule. > > 1. A workflow exposure entry must name a port that is exposed on the target node instance, resolved through the same chain as R7.c: the instance's `config.ports[].exposed` where it sets one, otherwise the node type's `exposedByDefault`. > > 2. An entry naming a hidden port is refused with `R10_EXPOSURE_HIDDEN_PORT`, and the error names the workflow port, the node and the node's port. > > 3. A port the node type's metadata does not declare is out of scope, mirroring R7.d. > > 4. R10 and R7 are disjoint by construction (R7 judges edges, R10 judges exposure entries), so one hidden port never earns one exposure entry two reports. ### Why Recorded under OPEN-2. ### Related rules - Names: R7.c, R7.d, R4.d - Referenced by: R7.c, R7.d, R4.d ## R11 — A configured expression must be valid for its engine at save *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* An expression that cannot parse can only fail once the workflow is already running. Catching it at save turns a runtime failure into an editing error. ### The rule > **Normative.** This is the rule. > > 1. Every expression carried in a node's configuration must pass its engine's validation when the workflow is saved. > > 2. A failure is refused with `R11_EXPRESSION_INVALID`, and the error names the node, the config key, the engine and the expression. > > 3. The expressions checked are the ones the node types declare as expression-bearing: a data extractor's `path`, the `value` of each dynamic output of a data mapper, the sources of a data shaper's `mapping` (with reserved source values such as a literal or a now marker skipped, and nested `_source` and `_each` sources walked), and a prompt template's `template`. > > 4. An empty expression is always valid. > > 5. An expression naming an engine the implementation does not know is not reported here; R6's `enum` check on the engine key owns that error. > > 6. An engine whose validation raises rather than returning a verdict is treated as a rejection, yielding one `R11_EXPRESSION_INVALID` for that expression; the failure is never propagated, so a misbehaving engine still produces a refusal against the workflow and never a server error. ### Why Recorded under OPEN-3. ### Related rules - Names: R6.d - Referenced by: R6.k ## R12 — A node may not be wired to itself *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge whose non-empty `source` equals its `target` is refused with `R12_EDGE_SELF` at `edge.{index}`. > > 2. This holds whatever ports the two ends name, so wiring a node's own output into its own input is still refused, and it holds whether or not the named node exists; a self-edge on a node that was deleted also earns R8's two dangling-endpoint errors. > > 3. An edge with an empty `source` is never reported as a self-edge, even when its `target` is empty too; R5.c and R8 own that case. ### Why Recorded under OPEN-7. ### Related rules - Names: R8.a, R8.b ## R13 — Two identical edges between the same ports are refused *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* Duplicate detection compares what the document literally says, not what it means. Two spellings of the same logical connection are therefore two distinct edges, and both are kept. ### The rule > **Normative.** This is the rule. > > 1. Two edges are duplicates when their `source`, `target` and both handle strings are identical. > > 2. A duplicate is refused with `R13_EDGE_PARALLEL_DUPLICATE` at `edge.{index}`, and the error names the position of the earlier edge it duplicates. > > 3. The four values are compared verbatim, with no parsing or normalisation of a handle into a port name, so two edges between the same pair of nodes over different ports stay valid, and two edges over the same logical port written differently (one with the handle omitted, one with it spelled out) are both accepted. > > 4. An edge with an empty endpoint takes no part in the comparison. ### Why Recorded under OPEN-7. ## R6.a — Config is a JSON object *GR-VAL (Part I) · level: core · profiles: storage-api · added in 1.0* Every other rule about config addresses it by key, which presumes an object. This is the rule that says so, and it holds for nodes no schema is judging. ### The rule > **Normative.** This is the rule. > > 1. A node's `data.config`, where present, must be a JSON object. > > 2. A string, number, boolean or JSON array is refused with `R6_CONFIG_INVALID` at `node.{id}.config` (where `{id}` is the node's id), and the save does not take effect. > > 3. This is a requirement on config's shape rather than on its contents, so it applies to every node, including a node that records no node type and a node whose node type does not resolve — neither of which has a derived schema against which the rest of R6 could judge anything (R6.j). ### Related rules - Names: R6.b, R6.j --- # GR-EDGE — EDGE (Part I) ## EDGE-1 — A handle encodes the node, the direction and the port *GR-EDGE (Part I) · level: core · profiles: storage-api · added in 1.0* A wire carries no declared type. Everything the system deduces about it, it deduces from the two handles, so how a handle is spelled and split is grammar, not detail. ### The rule > **Normative.** This is the rule. > > 1. A port handle is spelled `{nodeId}-{input|output}-{portName}`. > > 2. The port name is everything following the first `-input-` or `-output-` marker in the handle, so a port name may itself contain a direction marker. > > 3. Classification by port is an exact suffix match, never a substring match: a handle that merely contains a reserved port suffix is not classified by it unless the handle ends with it. ### Related rules - Names: EDGE-3, EDGE-4 - Referenced by: EDGE-3, EDGE-4, EDGE-6 ## EDGE-2 — No edge key is structurally required *GR-EDGE (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge is accepted structurally whatever keys it carries: an absent key takes its empty default: the empty string for an endpoint or a handle, the empty list for a collection. > > 2. An edge left without endpoints is not a parse failure; it is refused by validation as an edge missing an endpoint, so an author is told what is wrong with the wire rather than that the document could not be read. ## EDGE-3 — A trigger edge is one whose target port is the trigger port *GR-EDGE (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge is a trigger edge when, and only when, its target handle ends with `-input-trigger`. > > 2. Nothing else marks a wire as a trigger. ### Related rules - Names: EDGE-1 - Referenced by: EDGE-1 ## EDGE-4 — A loopback edge targets the port named loop_back *GR-EDGE (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge is a loopback edge when, and only when, its target handle ends with `-input-loop_back`. > > 2. The reserved port name is `loop_back`: a handle ending with `-input-loopback` is an ordinary edge. ### Related rules - Names: EDGE-1 - Referenced by: EDGE-1 ## EDGE-5 — A declared edge type wins over the handle, if it is recognised *GR-EDGE (Part I) · level: core · profiles: storage-api · added in 1.0* An edge may name its own kind instead of leaving it to the handles. Because a named kind overrides what the handles say, a name nobody recognises is refused rather than carried through. ### The rule > **Normative.** This is the rule. > > 1. An edge is a tool edge when its declared `data.edgeType` is `tool_availability`, or, where there is no declaration, when its target handle ends with `-input-tool`. > > 2. A recognised declaration decides in both directions: a declared `tool_availability` makes an edge with an ordinary handle a tool edge, and any other recognised declaration makes an edge with a `-input-tool` handle not one. > > 3. A declaration that is absent, empty, or not a string falls back to the handle. > > 4. A declared type that is not recognised is refused at save with a validation error: an implementation must not carry an unrecognised type through as a kind of its own, where it matches nothing and so is neither classified nor excluded, and a mistyped kind silently becomes an ordinary ordering edge. ### Why Recorded under OPEN-17. ### Related rules - Names: EDGE-7 - Referenced by: EDGE-7, CMP-4 ## EDGE-6 — An error edge routes a failure instead of raising it *GR-EDGE (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge whose source handle ends with `-output-error` is an error edge. > > 2. When a node that has one fails, the failure is routed rather than raised: the node's job is recorded as failed and marked as having been routed, the node yields an error payload of `{message, code, node_id, retryable}`, and the run continues along the error edge. ### Related rules - Names: EDGE-1 ## EDGE-7 — A tool-only node is excluded from the execution graph *GR-EDGE (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node is tool-only when it has at least one outgoing edge and every one of its outgoing edges is a tool edge. > > 2. A node with no outgoing edges is not tool-only, and neither is one with any non-tool outgoing edge. > > 3. A tool-only node is excluded from the compiled execution graph, with `tool_only` as the recorded reason, and generates no job; it runs inline only, when a consumer invokes it as a tool. ### Related rules - Names: EDGE-5 - Referenced by: EDGE-5 ## EDGE-8 — A condition on an edge is tolerated, warned about, and ignored *GR-EDGE (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A condition on an edge is a removed feature. > > 2. A stored `edge.data.condition` is tolerated and round-trips unchanged, and it gates nothing: the edge always routes. > > 3. A non-empty string condition is reported as a warning each time the source node's outgoing edges are resolved, so a source that executes more than once (inside a loop) warns each time rather than once per run. > > 4. An absent or empty condition is silent. ## EDGE-9 — Two edge vocabularies, and they never mix *GR-EDGE (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* An edge is written one way on the wire and another way in the execution record. Each form has its own key names, and a key from the wrong bag produces something nothing reads. ### The rule > **Normative.** This is the rule. > > 1. An edge has two forms, each with its own key names. > > 2. In transport, the form an editor reads and writes, the keys are `source`, `target`, `sourceHandle` and `targetHandle`. > > 3. In the execution record they are `source_handle`, `target_handle`, `is_trigger`, `is_loopback`, `is_tool`, `branch_name` and `edge_id`. > > 4. Neither vocabulary may carry a key belonging to the other. --- # GR-SCHEMA — SCHEMA (Part I) ## SCH-1 — A node's ports come from its two declared schemas *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* Everything an editor draws on a node (its input ports and its output ports) is derived from two schemas the node type declares. Nothing else is a source. ### The rule > **Normative.** This is the rule. > > 1. A node type declares its input surface as one JSON Schema and its output surface as another. > > 2. Those two schemas are the only source of a node's ports. > > 3. The node type's own registration declaration carries no port data and no visual data: a port list, an icon or a category appearing there is a defect. ## SCH-2 — Gate flags come from the node type, and default off *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* Whether a parameter is connectable, configurable or required is the node type's answer, not the declared schema's. A parameter the node type never mentions reaches neither derived schema. ### The rule > **Normative.** This is the rule. > > 1. The `connectable`, `configurable` and `required` gate flags for a parameter are read from the node type's own parameter configuration only, each defaulting to false where the node type declares no entry for that parameter. > > 2. A parameter the node type does not declare therefore appears in neither derived schema and in neither `required` list. > > 3. Same-named keys written in the declared schema are not read by the gate, and are not stripped either: they survive verbatim into the served schemas. > > 4. A property-level `required: true` is read on exactly one path: where a node is exposed as a model-facing tool, it is lifted into the enclosing object's `required` array before the schema is handed to the model. ### Related rules - Names: SCH-3, SCH-6 - Referenced by: SCH-3, SCH-6 ## SCH-3 — One parameter schema splits into two derived schemas *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A parameter belongs to the derived input schema if and only if it is `connectable`, and to the derived config schema if and only if it is `configurable`. > > 2. Both, one, or neither is legal. ### Related rules - Names: SCH-2 - Referenced by: SCH-2, SCH-6 ## SCH-4 — A hidden parameter is dropped from everything *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A parameter marked hidden is dropped from both derived schemas and from the config defaults, overriding whatever the node type says about `connectable` and `configurable`. > > 2. Hiding does not suppress a reserved config-only parameter: one a node type has opted into still reaches the config schema. > > 3. Two spellings are honoured: `format: hidden`, which is the one to write, and a bare `hidden: true`, which is deprecated. > > 4. The deprecated spelling must not be dropped without a release that first warns on it, because dropping it silently un-hides the parameter, and a visible parameter becomes connectable and configurable. ### Related rules - Names: SCH-5, SCH-19, SCH-31 - Referenced by: SCH-5, SCH-19 ## SCH-5 — Reserved config-only parameters always carry a default *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. `dynamicInputs`, `dynamicOutputs` and `branches` are reserved config-only parameters and are never input ports. > > 2. Where a node type opts one in by marking it `configurable`, it enters the config schema and always gets a config-defaults entry: the node type's default, else the schema's default, else an empty array. > > 3. Such an entry is never omitted and never null, unlike an ordinary configurable parameter, whose entry is omitted when its resolved default is null. ### Related rules - Names: SCH-4 - Referenced by: SCH-4 ## SCH-6 — Which required list a required parameter lands in *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > A required parameter is emitted into the input schema's `required` list if and only if it is not `configurable`, and into the config schema's `required` list if and only if it is not `connectable`. ### Related rules - Names: SCH-2, SCH-3 - Referenced by: SCH-2 ## SCH-7 — An output is exposed unless the node type says otherwise *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A declared output survives into a node's metadata unless the node type marks that output not exposed. > > 2. Where the node type says nothing about it, the output is exposed: the default is fail-open. ### Related rules - Referenced by: SCH-18 ## SCH-8 — How a schema property becomes a port *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* One mapping turns a schema property into the port an editor draws, so every schema-derived port on every node is built the same way. ### The rule > **Normative.** This is the rule. > > 1. A schema-derived port takes its `id` from the property key; its `name` from the property's `title`, or from the key where there is no title; its lane from the type mapping; `required` from membership in the schema's `required` list; its default value from the property's `default`, or null where there is none; its exposed-by-default from the exposure defaults; and its display order from `x-port-order`, or 0 where that is absent. > > 2. This is the only schema-to-port mapping. > > 3. The unified input and output ports are not schema-derived: they always carry the `json` lane and display order 0. ### Related rules - Names: SCH-10, SCH-16, SCH-17, SCH-18, SCH-27 - Referenced by: SCH-17, SCH-18, SCH-27 ## SCH-9 — A union type resolves to its first non-null member *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a property's type is an array of types, its lane is derived from the first member that is not `null`. > > 2. An array that is empty, or that holds only `null`, resolves to the sink. > > 3. Every member must itself be a JSON Schema type. ### Related rules - Names: SCH-34, SCH-35 - Referenced by: SCH-34, SCH-35 ## SCH-10 — The map from a schema type to a port lane *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* A port's lane is what an editor colours it by and what it checks a wire against. It is derived from the property's schema type by one closed map. ### The rule > **Normative.** This is the rule. > > 1. A property's schema type maps to exactly one lane: `string` to `string`; `number` and `integer` to `number`; `boolean` to `boolean`; `array` to `array`; `object` to `json`; `null` to the sink. > > 2. Any other value, and an absent type, yield the sink, never `string`. > > 3. A property's `x-data-type` overrides the mapped lane whenever it names a lane the served port configuration declares; any other value is ignored and the schema type maps as usual. > > 4. Every lane this derivation can return, override included, must also be declared by the served port configuration: an undeclared lane leaves the port compatible with nothing an editor can draw. ### Related rules - Names: SCH-34, SCH-35, SCH-36, SCH-37, SCH-41 - Referenced by: SCH-8, SCH-10.a, SCH-34, SCH-35, SCH-41 ## SCH-10.a — A site's lane configuration overlays the shipped one *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* A site may recolour, rename or add lanes. What it stores is a patch on the shipped vocabulary; serving it as a replacement freezes the vocabulary at the moment it was saved. ### The rule > **Normative.** This is the rule. > > 1. A stored site-level lane configuration is an overlay on the shipped defaults, not a replacement. > > 2. Stored entries win outright (a recoloured or renamed lane, a site-only lane, the site's own compatibility rules), and shipped entries the stored value never mentions fill the gaps: lanes are merged per id with unmentioned shipped ids appended after the stored ones, so the site's ordering survives; compatibility rules are unioned on the `from`/`to` pair; scalar keys are stored-wins. > > 3. There is no negative form, so a site cannot suppress a shipped rule by omission, the safe direction, since a missing rule silently refuses a valid edge while an extra one only permits. > > 4. Serving a stored overlay wholesale is a defect: every lane added after it was saved would go missing, and by SCH-10's coupling every port carrying such a lane would be compatible with nothing, not even with itself. ### Related rules - Names: SCH-10, SCH-41, SCH-44, SCH-46 - Referenced by: SCH-36, SCH-41, SCH-44, SCH-46, SCH-45 ## SCH-11 — Every node but a start node gets a trigger input *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > A reserved `trigger` input port is injected on every node type except a start node type, unless the node type already declares one, and carries display order 100. ### Related rules - Names: SCH-16 - Referenced by: SCH-16 ## SCH-12 — Every node but a terminal node gets a trigger output *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > A reserved `trigger` output port is injected on every node type except a terminal node type, unless the node type already declares one, and carries display order 100. ### Related rules - Names: SCH-16 - Referenced by: SCH-16 ## SCH-13 — A tool-exposed node gets a tool output *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > A reserved `tool` output port is injected on a node type if and only if that node type is exposed as a tool, and carries display order 110. ### Related rules - Names: SCH-16 - Referenced by: SCH-16 ## SCH-14 — Every executable node gets a hidden error output *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > A reserved `error` output port is injected on every node type except a non-executable one, carries display order 120, and ships hidden. ### Related rules - Names: SCH-16 - Referenced by: SCH-16 ## SCH-15 — A reserved port states its exposure only when it diverges *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An injected reserved port declares `x-exposed-by-default: false` only where that diverges from the default of true. > > 2. Where it is exposed by default it says nothing, and a reader takes silence as exposed. ### Related rules - Names: SCH-16, SCH-27 - Referenced by: SCH-27 ## SCH-16 — What each reserved port defaults to on the canvas *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The reserved ports default to not exposed (the unified `input` and `output`, the `tool` output, the `error` output and the `loop_back` input), except the `trigger` input and the `trigger` output, which default to exposed. > > 2. A node type may override the default per port, and stores only the keys whose value diverges from the default. ### Related rules - Names: SCH-11, SCH-12, SCH-13, SCH-14, SCH-32 - Referenced by: SCH-8, SCH-11, SCH-12, SCH-13, SCH-14, SCH-15, SCH-32 ## SCH-17 — The unified input port *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A unified `input` port is prepended to a node's ports where its node type exposes one. > > 2. It carries the `json` lane, and its description enumerates the node's connectable parameter keys only. ### Related rules - Names: SCH-8, SCH-19 - Referenced by: SCH-8, SCH-19 ## SCH-18 — The unified output port *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A unified `output` port is prepended to a node's ports where its node type exposes one. > > 2. It carries the `json` lane, and its description enumerates the node's exposed output keys only. ### Related rules - Names: SCH-7, SCH-8, SCH-19 - Referenced by: SCH-8, SCH-19 ## SCH-19 — A unified port's description skips trigger and hidden keys *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Both unified-port key enumerations skip the `trigger` port and every hidden parameter. > > 2. A hidden port does not exist on the instance, so it never appears in a unified port's description. ### Related rules - Names: SCH-4, SCH-17, SCH-18 - Referenced by: SCH-4, SCH-17, SCH-18 ## SCH-20 — Visual type is the node type's, and is offered where there is a choice *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node's visual type comes from its node type, never from a declared schema. > > 2. Where the node type supports more than one visual type, a reserved `nodeType` string property is written into the config schema (its `enum` the supported types, its `default` the node type's own visual type), and the same value is stored as that key's config default. > > 3. This write is unconditional: it overwrites a declared property of the same name. ### Related rules - Names: SCH-21 - Referenced by: SCH-21 ## SCH-21 — The reserved config properties every node carries *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. `instanceTitle` and `instanceDescription` are injected into every node's config schema; `maxRetries` and `ports` into an executable node's config schema only. > > 2. Each injection is skipped where the node type already declares a property of that name. ### Related rules - Names: SCH-20 - Referenced by: SCH-20, SCH-23 ## SCH-22 — A config-edit descriptor appears only where one is provided *GR-SCHEMA (Part I) · level: optional · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node's metadata carries a `configEdit` descriptor only where its node type provides one. > > 2. A node type that provides none carries no such key. ## SCH-23 — When a node has a ui schema, and when it has none *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* The ui schema is what turns a flat config form into grouped sections. Whether there is one at all is decided by a single question about the final schema. ### The rule > **Normative.** This is the rule. > > 1. The ui schema is generated from the final config schema, after every reserved property has been injected. > > 2. It is null exactly when no property carries a registered group: a property tagged with an unregistered group falls into the default bucket, so a schema tagged only with unregistered groups also yields null, and the form stays on its flat path. > > 3. Groups are collapsed by default; a group is opened for a node instance where that instance sets one of the fields in it. ### Related rules - Names: SCH-21, SCH-31 - Referenced by: SCH-31 ## SCH-25 — Enrichment anchors on the node type and changes nothing else *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node is enriched from the node type it records, and from nothing else. > > 2. Enrichment replaces the node's metadata and sets the node's type to the universal node constant; the node's config, id, label and position are untouched, so running it twice changes nothing. > > 3. Where the node records no node type, or where the node type no longer builds, the node is returned unchanged and no metadata key is added to it. ### Related rules - Names: SCH-29 - Referenced by: SCH-29, SCH-24 ## SCH-26 — The catalog an editor reads *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > The node catalog served to an editor lists enabled node types only, sorted by category and then by name. ## SCH-27 — A served port omits everything that matches the default *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* The wire shape is lean by divergence, which means a client cannot read absence as "unknown". Absence is the default, and the defaults are fixed here. ### The rule > **Normative.** This is the rule. > > 1. A served port emits `defaultValue` only where it is not null, `exposedByDefault` only where it is false, and `displayOrder` only where it is not 0. > > 2. A client must read the absence of those keys as no default, exposed, and order 0 respectively. ### Related rules - Names: SCH-8, SCH-15 - Referenced by: SCH-8, SCH-15 ## SCH-28 — A node's executor is resolved from its node type *GR-SCHEMA (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The executor for a node is resolved from that node's node type. > > 2. An executor named in stored node metadata is never trusted. ### Related rules - Names: SCH-29 - Referenced by: SCH-29 ## SCH-29 — Stored metadata is never authoritative at run time *GR-SCHEMA (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Before a workflow runs, every node is re-enriched from its live node type and the workflow is normalized from the result. > > 2. Metadata stored with the workflow is never authoritative at run time. ### Related rules - Names: SCH-25, SCH-28 - Referenced by: SCH-25, SCH-28, CMP-2 ## SCH-31 — Config field order applies inside a ui schema only *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. `x-config-order` orders config fields ascending; a property whose value is absent or not numeric counts as 0, and ties keep declaration order. > > 2. It takes effect inside a generated ui schema only: where no property carries a registered group there is no ui schema, no layout, and `x-config-order` has no effect on the flat form. > > 3. The earlier spelling `x-display-order` carries no meaning; a schema that still writes only it orders at 0, like any untagged property. ### Related rules - Names: SCH-23 - Referenced by: SCH-4, SCH-23 ## SCH-32 — Every re-enterable node gets a hidden loop-back input *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* Re-entry is a property of the graph, not of a handful of node types. Every node that can be re-entered declares the port; it just ships hidden, so no canvas gains a re-entry handle until an author asks for one. ### The rule > **Normative.** This is the rule. > > 1. A reserved `loop_back` input is injected on every re-enterable node type (every node type that is not a start, terminal or non-executable one) with display order 95 and the `trigger` lane, and it ships hidden, so no canvas gains a re-entry handle until an author exposes the port on that instance. > > 2. A node type that declares its own `loop_back` keeps it and the injection is skipped, because such a declaration carries semantics the injection does not. > > 3. Because the port is now declared rather than absent, an edge drawn into an unexposed `loop_back` is refused as an edge into an unexposed port, where previously it fell outside the check entirely. ### Related rules - Names: SCH-16, SCH-37, SCH-38 - Referenced by: SCH-16, SCH-33, SCH-38 ## SCH-33 — A schema that will not load never rewrites stored parameters *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* A momentary failure to load a node type's declared schema once looked exactly like "this node type declares nothing", and a save then wrote that emptiness back as permanent configuration loss. ### The rule > **Normative.** This is the rule. > > 1. A declared parameter schema that fails to load, or that loads without a usable `properties` map, must not be read as a node type declaring nothing; a node type with no parameters declares an empty map, and an absent map loses stored rows exactly as a failure does. > > 2. Where a node type has stored parameter configuration that such a schema would drop, the save is refused and nothing is rewritten; where nothing is stored there is nothing to lose, and the rest of the editing surface stays reachable. > > 3. A write path that is reachable without that refusal writes the stored configuration back verbatim rather than the accidentally empty computed one, and leaves reserved-port exposure untouched. > > 4. Only the render path may treat an unloadable schema as no properties, where that correctly means render no rows. ### Related rules - Names: SCH-32 ## SCH-34 — A property's type is a JSON Schema type, not a port lane *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* The two vocabularies overlap on four words, which is why a lane name written into `type` looks correct, derives the wrong lane, and is caught by nothing. ### The rule > **Normative.** This is the rule. > > 1. A property's `type` is one of JSON Schema's seven type words, or an array whose every member is one. > > 2. It is never a port lane. > > 3. A schema that spells a lane in `type` is malformed, on the input side and the output side alike: the lane derivation answers the miss with the sink and reports nothing, so the only symptom is a port of the wrong lane. > > 4. An implementation states this constraint where a schema is declared, so an offending schema is caught before it ships rather than after a port is drawn from it. ### Related rules - Names: SCH-9, SCH-10, SCH-35, SCH-37 - Referenced by: SCH-9, SCH-10, SCH-35, SCH-37 ## SCH-35 — Lane derivation is total, and an undeclared port is the sink *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* "What does an undeclared port mean?" once had seven disagreeing answers. It has one: the sink. ### The rule > **Normative.** This is the rule. > > 1. Every one of JSON Schema's seven type words derives a lane, and both `null` and an absent `type` resolve to the sink lane `mixed`. > > 2. Derivation never falls back to `string`. > > 3. Every reader of a port's lane gives the same answer: the derivation itself, the served default lane, the projection of a workflow's interface, and an editor's own default alike. > > 4. Because an undeclared port is the sink rather than a string, a caller may supply any JSON value to it, an array or null included. ### Related rules - Names: SCH-9, SCH-10, SCH-34, SCH-40 - Referenced by: SCH-9, SCH-10, SCH-34, SCH-40 ## SCH-36 — One declaration site for the shipped lane vocabulary *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The lanes an implementation ships are declared in exactly one place, and every reader derives from that declaration rather than mirroring it: the derivation's range, the served payload and any picker offering an author a lane included. > > 2. That set is what is shipped, not a ceiling: a site may add lanes of its own, so a stored lane must never be rejected merely because it is not one of the shipped ones. ### Related rules - Names: SCH-10.a, SCH-41 - Referenced by: SCH-10, SCH-41 ## SCH-37 — A control port declares its lane, never a schema type *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. `trigger`, `tool` and `loop_back` carry no value, so no JSON Schema type describes them. > > 2. A control port declares `x-data-type` and no `type`, and a reserved-port injection assigns the lane by the port's role rather than deriving it. > > 3. Control lanes remain full members of the lane vocabulary; what is forbidden is spelling one in `type`. > > 4. A lane that carries no value is not offered as a dynamic port type and is not on the list of lanes an author may pick. ### Related rules - Names: SCH-34, SCH-38 - Referenced by: SCH-10, SCH-32, SCH-34, SCH-38, SCH-40 ## SCH-38 — A loop-back input is a control sink, and `any` is retired *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* `any` and `trigger` differed in one way only (one had compatibility rules built into it from every other lane), and that is a property of being the sink, not of being `any`. ### The rule > **Normative.** This is the rule. > > 1. A `loop_back` input declares the `trigger` lane. > > 2. `any` is no longer a lane a port may newly declare, and the rules that make the sink reachable from every other lane target `trigger` instead. > > 3. `any` is not removed from the served payload: it is served disabled, with compatibility rules in both directions and a description saying it is deprecated, because an editor builds its compatibility map from the served list alone and omitting a lane silently makes every edge on a port still declaring it incompatible. > > 4. A consequence of targeting `trigger` is that a data output may be drawn into an ordinary trigger input; the runtime discards the value rather than delivering it. > > 5. Compatibility is an authoring affordance only: no server path validates a connection against port lanes, so a wire an editor newly permits was already accepted by every server path if authored by hand. ### Related rules - Names: SCH-32, SCH-37, SCH-38.a, SCH-39 - Referenced by: SCH-32, SCH-37, SCH-38.a, SCH-39 ## SCH-38.a — A retired lane keeps one release of served-but-disabled compatibility *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A lane leaves the declarable vocabulary as soon as nothing ships it, but leaves the served payload only a major version later, because the two lists answer different questions: what a port may newly declare, and what an editor knows how to connect. > > 2. Dropping both at once breaks every edge on a port declared under the previous release, with no error anywhere; an editor simply reports the connection incompatible. > > 3. A retired lane is therefore served disabled, with its compatibility rules intact, for at least that release, and the retired lanes are named explicitly so that removing one is a deliberate act rather than drift. ### Related rules - Names: SCH-38 - Referenced by: SCH-38 ## SCH-39 — Compatibility is asymmetric, and the sink needs rules both ways *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* A lane with no rules accepts only its own, which is why the sink (a lane worn by outputs as well as inputs) once refused every wire drawn into it. ### The rule > **Normative.** This is the rule. > > 1. Compatibility maps an output lane to the set of input lanes it may enter. > > 2. It is seeded by exact match and widened only by explicit rules, so a lane with no rules accepts nothing but itself. > > 3. The data sink `mixed` is worn by outputs as well as inputs and so needs rules in both directions; making it an alias of the control sink does not serve, because an alias copies the outgoing set only. > > 4. `tool` is excluded from every sink rule in both directions. > > 5. A rule that widens states its reason where it is declared, since the served `from`/`to` pair cannot carry one. > > 6. The stakes are authoring-time: a wrong rule costs an author a wire they cannot draw, or lets them draw one nothing will reject. ### Related rules - Names: SCH-38, SCH-43 - Referenced by: SCH-38, SCH-43 ## SCH-40 — A port's lane is consumed at a public boundary *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, runtime, editor-client · added in 1.0* The lane a port declares is not only a colour on a canvas. It is the shared answer to "what may a caller supply?" at the endpoints that start a workflow and take a turn in a session. ### The rule > **Normative.** This is the rule. > > 1. A port's declared lane decides what a caller may supply at a workflow's public entry points: launching a workflow and taking a turn in a session answer from the same check. > > 2. A port on the sink or on a control lane places no constraint: any JSON value is accepted, including an array or null. > > 3. Narrowing that, for instance by keying the check off JSON Schema's seven type words alone, is strictly stronger and is a breaking change to a public endpoint: it is announced, never carried in on a refactor. > > 4. The name a projection gives the field that carries the lane is likewise a client-facing contract. ### Related rules - Names: SCH-35, SCH-37 - Referenced by: SCH-35 ## SCH-41 — The lane vocabulary is the served payload, not the shipped set *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* What a port may declare and what an implementation ships are different questions. Answering the first with the second is how a site could add a lane it could never use. ### The rule > **Normative.** This is the rule. > > 1. The declarable lane vocabulary is the served port configuration: the shipped lanes, the port-shape registry (code-declared shapes first, then site-declared ones), and the site's stored overlay, composed in that precedence order. > > 2. Whether a port may declare a lane is asked of that payload, never of the shipped set alone: otherwise a site can add a lane, colour it and write rules for it while no port is able to declare it, the declaration being silently replaced by the schema-derived lane with a wrongly coloured handle as the only symptom. > > 3. A lane the payload does not declare is still refused, because such a port would be compatible with nothing, not even with another port of its own lane. ### Related rules - Names: SCH-10, SCH-10.a, SCH-36, SCH-42 - Referenced by: SCH-10, SCH-10.a, SCH-36, SCH-42, SCH-44 ## SCH-42 — A shape is a named JSON Schema, and the shape id is the lane id *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* Shapes let a lane say what it carries (an order, an entity, a remote contract) without adding a second vocabulary an editor would have to reconcile with lanes. ### The rule > **Normative.** This is the rule. > > 1. A port shape is a named JSON Schema whose id is a lane id: one id space, no second vocabulary axis. > > 2. A shape is declared in one of two places, and the difference is whose it is. > > 3. A shape that belongs to code ships and updates with the ports that wear it, needs nothing installed alongside it, and may compute its schema, so one declaration can yield a family of lanes; a derived member's id is `base:derivative`. > > 4. A shape that belongs to the site is declared in the site's own configuration, added without writing code, and travels and diffs with that configuration. > > 5. Every shipped lane that is not a primitive has a shape. > > 6. A shape names a value; it does not check one. > > 7. Nothing validates a value against a shape. ### Related rules - Names: SCH-41, SCH-43, SCH-44, SCH-46 - Referenced by: SCH-41, SCH-43, SCH-44, SCH-46 ## SCH-43 — Shape compatibility is nominal and mostly derived *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* Two shapes match because they carry the same name, never because their schemas happen to agree; otherwise edge legality would drift every time a schema gained a field. ### The rule > **Normative.** This is the rule. > > 1. A shape is compatible with another because they share an id, never because two schemas were compared. > > 2. Structural matching would make edge legality depend on schema evolution: two unrelated shapes that coincide today would silently interconnect, and silently disconnect when one gained a field. > > 3. Every shape matches itself, and derives a one-way widening into `json` and into the sink: a consumer 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. > > 4. Any further widening is a hand-written rule that states its reason where it is declared. > > 5. Shapes join the lane list before the sink and control rules are generated, so a new lane receives those by construction rather than by a second derivation. ### Related rules - Names: SCH-39, SCH-42 - Referenced by: SCH-39, SCH-42 ## SCH-44 — A shape refines another declaration of its lane; four ids are reserved *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where two sources declare the same lane id, the site's own declaration wins over a code-declared shape, and both win over the shipped entry. > > 2. The winner replaces the entry whole rather than key by key, because a partial shape is a deliberate redefinition. > > 3. A collision with a shipped id is therefore not a break: the lane stays in the vocabulary, stays self-compatible, and keeps its derived rules. > > 4. Precedence is resolved in one place, so which declaration wins never depends on load order. > > 5. The ids `trigger`, `tool`, `mixed` and `any` are reserved (every generated compatibility rule in the payload is built from them), and a shape naming one is ignored rather than fatal, since paths that reach around a form exist and one bad shape must not unwire every canvas. ### Related rules - Names: SCH-10.a, SCH-41, SCH-42 - Referenced by: SCH-10.a, SCH-42, SCH-45 ## SCH-46 — A shape's schema is served on the lane, not on every port *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* One copy of each shape's schema, on the lane entry, keeps it fresh when a site edits a shape and leaves the per-port slot free for the narrower thing that actually needs it. ### The rule > **Normative.** This is the rule. > > 1. A shape's JSON Schema is served on the lane entry, once. > > 2. It is absent rather than empty where the lane has no shape, so a client can tell "promises nothing" from "promises an object with no properties". > > 3. It is not stamped onto each port declaring the lane: a per-port copy repeats on every node type wearing the lane, and it would go stale when a site edited a shape, because the lane payload's invalidation tracks shape edits while a per-node-type payload does not. > > 4. The per-port schema slot stays free for the one refinement that needs it: a narrower schema observed on a run, which is per-instance information and could never live in a payload cached per node type. > > 5. Such an observed schema is authoring information only: it is a sample, not a contract, and must never become what enforcement checks. > > 6. A stored overlay entry for the same lane id replaces the composed entry whole, its schema included. ### Related rules - Names: SCH-10.a, SCH-42 - Referenced by: SCH-10.a, SCH-42, SCH-45 ## SCH-24 — A node type that fails to build does not fail the request *GR-SCHEMA (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* One unbuildable node type costs the reader that node's metadata, not the whole response. ### The rule > **Normative.** This is the rule. > > 1. Where a node type cannot be built from its own declaration or stored data, the failure is contained: the request being served still succeeds, the node is returned unenriched (SCH-25), and the failure is recorded where an operator can find it. > > 2. This governs failures attributable to the node type. > > 3. Behaviour when an implementation's own extension code violates the contract it was written against is outside this specification, which describes conforming implementations and not malfunctioning ones. ### Related rules - Names: SCH-25 ## SCH-45 — A stored lane overlay is checked when it is stored *GR-SCHEMA (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* The overlay is the one piece of the vocabulary a site writes by hand, so it is the one place a mistake should be reported to the person making it. ### The rule > **Normative.** This is the rule. > > 1. Where an implementation lets a site store its own lane overlay (SCH-10.a), the overlay is checked when it is stored: a structurally malformed overlay is refused then, not accepted and discovered when something tries to render it. > > 2. This is distinct from a well-formed declaration the vocabulary declines on its own terms — a shape naming a reserved lane id is ignored rather than fatal (SCH-44). > > 3. A port shape's JSON Schema is not part of this specification's keyspace: an implementation must not refuse a schema keyword merely because it does not recognise it. ### Related rules - Names: SCH-10.a, SCH-44, SCH-46 --- # GR-CFG — CFG (Part I) ## CFG-1 — An unrecorded gate flag is off *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* A node type records per-parameter gate flags: whether a parameter may receive a wire, whether it appears in the config form, whether it is required. Where a flag was never recorded, the answer is no. ### The rule > **Normative.** This is the rule. > > 1. A parameter's `connectable`, `configurable` and `required` gate flags default to false. > > 2. Where a node type records no value for one of them, the parameter is treated as not connectable, not configurable, or not required accordingly. ### Related rules - Names: CFG-2 - Referenced by: CFG-2, CFG-18 ## CFG-2 — An unrecorded default-exposure flag means exposed *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* Default exposure carries the opposite polarity to the gate flags, deliberately. A port nobody has decided about is visible, so a parameter added after a node type was last saved does not silently vanish from the canvas. ### The rule > **Normative.** This is the rule. > > 1. A port's default exposure defaults to true. > > 2. Where a node type records no `exposedByDefault` value for a port, the port is exposed by default. > > 3. This polarity is the opposite of the gate flags (CFG-1) and is deliberate. ### Related rules - Names: CFG-1, EXPO-5, EXPO-8 - Referenced by: CFG-1, EXPO-2, EXPO-5, EXPO-7, EXPO-8 ## CFG-3 — A parameter's effective default comes from the node type, then the schema *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A parameter's effective default is the node type's recorded default where that default is non-null, otherwise the processor's schema default, otherwise null. > > 2. A node-type default recorded as null is not distinguished from one that was never recorded, so it does not suppress the schema default. ### Related rules - Names: CFG-6, CFG-7 - Referenced by: CFG-6, CFG-18 ## CFG-4 — Priority 1, a value delivered on a wire *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* The first place a parameter's value can come from is the run itself: a value another node sent down a wire, or a value the launch payload supplied. ### The rule > **Normative.** This is the rule. > > 1. A parameter takes its value from the runtime inputs when the parameter is internal, or is both connectable and exposed, and the runtime inputs contain a key of that name. > > 2. A key present with a null value counts as supplied (CFG-7). > > 3. A parameter that fails this test falls through to priority 2 (CFG-5). ### Related rules - Names: CFG-5, CFG-6, CFG-7, EXPO-10 - Referenced by: CFG-5, CFG-6, CFG-7, CFG-8, CFG-16, CFG-17, EXPO-10, DATA-7 ## CFG-5 — Priority 2, the author's saved config *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where priority 1 does not apply, a parameter takes its value from the node's saved configuration when the parameter is not internal, is configurable, and the configuration contains a key of that name. > > 2. A key present with a null value counts as supplied (CFG-7). > > 3. A parameter that fails this test falls through to priority 3 (CFG-6). ### Related rules - Names: CFG-4, CFG-6, CFG-7 - Referenced by: CFG-4, CFG-6, CFG-7, CFG-8, EXPO-10, DATA-7 ## CFG-6 — Priority 3, the effective default, unconditionally *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where neither priority 1 (CFG-4) nor priority 2 (CFG-5) applies, a parameter takes its effective default (CFG-3). > > 2. This fallback is unconditional: no gate flag suppresses it. ### Related rules - Names: CFG-3, CFG-4, CFG-5 - Referenced by: CFG-3, CFG-4, CFG-5, CFG-7, CFG-9, EXPO-10, DATA-7 ## CFG-7 — An explicit null at a higher priority wins *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* Sending null is a decision, not a silence. A node that emits null on a wire has said something, and what it said beats whatever the author saved. ### The rule > **Normative.** This is the rule. > > 1. At every priority, a value counts as supplied when a key of the parameter's name is present, whatever that key's value. > > 2. An explicit null therefore wins over any lower priority: a null delivered on a wire beats the author's saved config, and a null in the saved config beats the schema default. > > 3. This holds identically however a workflow is executed. ### Related rules - Names: CFG-4, CFG-5, CFG-6 - Referenced by: CFG-3, CFG-4, CFG-5, DATA-7 ## CFG-8 — Internal parameters take runtime values only *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* Names prefixed `__` are the system's own channel into a processor. They sidestep the gate flags, which also means an author can never see or set one; choosing the prefix is choosing that semantics, not a naming style. ### The rule > **Normative.** This is the rule. > > 1. A parameter whose name begins `__` is internal. > > 2. It always accepts a value from the runtime inputs regardless of its `connectable`, `exposed` and `configurable` flags, never takes a value from the node's saved configuration, and otherwise takes its effective default. > > 3. Bypassing the gate flags does not exempt it from declaration: an internal name absent from the processor's parameter schema is never resolved and never reaches the processor (CFG-13), whoever supplied it. > > 4. A setting that also needs an author-facing surface must not use the prefix. ### Related rules - Names: CFG-4, CFG-5, CFG-13, EXPO-10 - Referenced by: CFG-13, EXPO-10 ## CFG-9 — A required parameter that resolves to null fails the node *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a parameter is required and the resolution chain (CFG-4 to CFG-6) yields null, the node execution fails with a missing-parameter error naming the parameter. > > 2. The check runs after the whole chain has been walked and before the resolved value would be validated (CFG-10). ### Related rules - Names: CFG-6, CFG-10 - Referenced by: CFG-10 ## CFG-10 — A resolved value is validated against the parameter schema *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A resolved value that is not null is validated against the parameter's schema. > > 2. Any violation fails the node execution with a validation error carrying the name of the constraint that failed. > > 3. `type` and `enum` are checked first, and either one failing ends the check there. > > 4. A value that resolves to null is not validated at all; whether a null is acceptable is decided by `required` alone (CFG-9). ### Related rules - Names: CFG-9, CFG-11, CFG-12 - Referenced by: CFG-9, CFG-11, CFG-12 ## CFG-11 — Type checking matches exactly and never coerces *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A `type` check compares the value against the named type exactly: a value of the wrong type fails, and is never coerced to make it pass. > > 2. A type name the implementation does not recognise passes rather than fails, so an unknown type never blocks a node from running. ### Related rules - Names: CFG-10 - Referenced by: CFG-10, CFG-12 ## CFG-12 — An unrecognised format passes rather than fails *GR-CFG (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A `format` an implementation recognises is enforced: a value that does not match it fails validation (CFG-10). > > 2. A `format` name the implementation does not recognise passes, so a schema written against a richer vocabulary never blocks a node from running. ### Related rules - Names: CFG-10, CFG-11 - Referenced by: CFG-10 ## CFG-13 — A processor sees its declared parameters and nothing else *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* The resolved parameter set is shaped by the processor's own schema, not by whatever happens to be in the stored config. A key nobody declared cannot arrive by being typed into config. ### The rule > **Normative.** This is the rule. > > 1. The resolved parameter set contains exactly the keys the processor's parameter schema declares, plus the node's declared dynamic input names (CFG-15). > > 2. A configuration key matching no declared parameter is not resolved and never reaches the processor. ### Related rules - Names: CFG-8, CFG-14, CFG-15 - Referenced by: CFG-8, CFG-14, CFG-15 ## CFG-14 — Every declared parameter is present, null when unresolved *GR-CFG (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Every parameter the processor's schema declares is present in the resolved parameter set. > > 2. One that resolves to no value is present with the value null; it is never absent. ### Related rules - Names: CFG-13 - Referenced by: CFG-13, CFG-15 ## CFG-15 — A dynamic input is forwarded verbatim *GR-CFG (Part I) · level: extended · profiles: storage-api · added in 1.0* Dynamic ports are the author's own additions to one node instance. They are carried through untouched, which is also why the type an author declares on one is documentation and nothing more. ### The rule > **Normative.** This is the rule. > > 1. After the declared parameters are resolved, each dynamic input name present in the runtime inputs is copied into the resolved parameter set verbatim: no exposure gate, no `required` check, and no validation. > > 2. A dynamic input's declared type is therefore descriptive only and is never enforced. > > 3. A dynamic name that is already a key in the resolved set is skipped, so a declared parameter always wins, including one that resolved to null. > > 4. An empty name is skipped. > > 5. Dynamic definitions are read from the node's resolved `dynamicInputs` value, falling back to the raw configuration where none resolved. ### Related rules - Names: CFG-13, CFG-14 - Referenced by: CFG-13, CFG-16 ## CFG-16 — The unified input port is decomposed before resolution *GR-CFG (Part I) · level: extended · profiles: storage-api · added in 1.0* A node may take all its inputs as one bundled object on the reserved `input` port. That bundle is unpacked into individual named inputs before any parameter is resolved, so the rest of the chain cannot tell the difference. ### The rule > **Normative.** This is the rule. > > 1. Where the reserved `input` port carries a value, it is decomposed before any parameter is resolved. > > 2. Where that value is not an object, nothing is decomposed: the runtime inputs are left unchanged and the raw `input` key survives, so a declared parameter literally named `input` receives the value as it stands. > > 3. Otherwise, only keys that name a connectable parameter, an internal parameter, or a declared dynamic input are kept, and the rest are discarded. > > 4. A value wired individually to a port then overwrites the same key taken from the bundle. ### Related rules - Names: CFG-4, CFG-15, CFG-17 - Referenced by: CFG-17 ## CFG-17 — A hidden port cannot be filled through the bundle *GR-CFG (Part I) · level: extended · profiles: storage-api · added in 1.0* Decomposition filters on `connectable` alone, so a hidden port's key can survive the unpacking. Resolution then applies the exposure gate a second time. Two guards, one outcome: a hidden port is not fillable, by any route. ### The rule > **Normative.** This is the rule. > > 1. Decomposition of the unified `input` port (CFG-16) admits a key on the strength of `connectable` alone. > > 2. A value that reaches a hidden connectable port this way is still discarded when the parameter is resolved, because priority 1 requires the port to be exposed (CFG-4). > > 3. Bundling a value must never fill a port that could not be wired directly. ### Related rules - Names: CFG-4, CFG-16, EXPO-10 - Referenced by: CFG-16, EXPO-10 ## CFG-18 — Published config defaults cover configurable parameters only *GR-CFG (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node type's published metadata carries a config default for a parameter only where the parameter is configurable and its effective default (CFG-3) is non-null. > > 2. No entry is published for a parameter that is not configurable, or whose effective default is null. ### Related rules - Names: CFG-1, CFG-3 --- # GR-EXPO — EXPO (Part I) ## EXPO-1 — Exposure resolves the same way everywhere it is consulted *GR-EXPO (Part I) · level: core · profiles: storage-api · added in 1.0* Exposure is asked about in a lot of places: resolving parameters, deciding what an agent may fill, validating a saved workflow, stripping outputs, drawing the author's toggles. All of them must get the same answer. ### The rule > **Normative.** This is the rule. > > 1. Effective exposure of a port is resolved by one rule (EXPO-2) wherever it is consulted, including parameter resolution, tool-parameter scoping, save-time validation, runtime output delivery, and the authoring surface that lets an author show or hide a port. > > 2. A node instance's exposure overrides are read from one location in the stored workflow, `data.config.ports`, so the surface that writes an override and the surfaces that read it cannot disagree. ### Related rules - Names: EXPO-2, EXPO-3 - Referenced by: EXPO-2 ## EXPO-2 — An instance override decides exposure; otherwise the default does *GR-EXPO (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A port is exposed on a node instance when the instance's direction-scoped overrides in `data.config.ports` contain an entry for that port carrying an `exposed` key: that key's value is the answer. > > 2. Where there is no such entry, or the entry carries no `exposed` key, the port's default exposure decides (`exposedByDefault`, CFG-2). ## What it means Being named in the direction-scoped overrides is not, by itself, an override. An entry for a port can exist there for reasons that have nothing to do with exposure, so the only thing that flips a port away from its default is that entry carrying an `exposed` key. A port named but silent on `exposed` reads exactly as a port not named at all. ## Example The port `value` defaults exposed; `error` defaults hidden (EXPO-4). Three override shapes for `value` land on two different answers. ```json title="An override entry with an explicit exposed: false" verdict="hidden" { "id": "value", "exposed": false } ``` ```json title="An override entry with an explicit exposed: true, on a port that defaults hidden" verdict="exposed" { "id": "error", "exposed": true } ``` ```json title="An entry naming value, but with no exposed key at all" verdict="exposed" { "id": "value" } ``` The third entry names the same port as the first, and still resolves to the default: naming a port is not deciding about it. ### Related rules - Names: CFG-2, EXPO-1, EXPO-3, EXPO-4 - Referenced by: EXPO-1, EXPO-3, EXPO-4, EXPO-13, EXPO-15 ## EXPO-3 — Overrides are scoped by direction *GR-EXPO (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Exposure overrides are scoped by port direction: an output port's override is read from the `outputs` map, and an input port's (that of every other direction) from the `inputs` map. > > 2. An input and an output of the same name are separate ports, and an override on one never applies to the other. ## What it means An input and an output that happen to share a name are two different ports, not one port seen from two sides, and their exposure overrides live in two different maps. Hiding an input named `other` says nothing about an output also named `other` — a lookup that ignored direction and matched by name alone would leak one port's override onto the other. ## Example ```json title="The stored overrides: other hidden, but only in inputs" verdict="hidden" { "inputs": [{ "id": "other", "exposed": false }] } ``` Checked as an input, `other` resolves hidden. Checked as an output, the same port name finds no entry at all — the `outputs` map carries none — and resolves to its own default, exposed. ### Related rules - Names: EXPO-2 - Referenced by: EXPO-1, EXPO-2 ## EXPO-4 — A node nobody has touched stores no overrides *GR-EXPO (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node instance on which no port has been shown or hidden stores no `ports` map at all. > > 2. Every one of its ports therefore resolves to its default exposure (EXPO-2), and adding a port to the node type later changes that node's canvas without the node being re-saved. ### Related rules - Names: EXPO-2, EXPO-8 - Referenced by: EXPO-2, EXPO-8 ## EXPO-5 — Only an exact false suppresses default exposure *GR-EXPO (Part I) · level: core · profiles: storage-api · added in 1.0* The schema extension that suggests hiding a port is read by identity, not by truthiness. Anything that is not the boolean false leaves the port exposed, so a malformed or half-migrated value fails towards visible. ### The rule > **Normative.** This is the rule. > > 1. A processor's `x-exposed-by-default` schema extension suppresses a port's default exposure only when its value is exactly the boolean `false`. > > 2. Any other value (including null, zero, an empty string, or the string `"false"`) leaves the port exposed by default, as does the key's absence (CFG-2). ## What it means The comparison is identity, not truthiness. A processor asking for a port to ship hidden has exactly one way to say so — the boolean `false`, and nothing else. Every near-miss a loosely-typed schema or a round trip through another format might produce — `null`, `0`, an empty string, even the string `"false"` — leaves the port exposed. A malformed or half-migrated flag fails towards visible, never towards hidden. ## Example ```json title="The one hiding value" verdict="hidden" { "x-exposed-by-default": false } ``` ```json title="The string false, quoted, where the boolean was meant" verdict="exposed" { "x-exposed-by-default": "false" } ``` ```json title="The key absent altogether" verdict="exposed" {} ``` ### Related rules - Names: CFG-2, EXPO-6, EXPO-7 - Referenced by: CFG-2, EXPO-6, EXPO-7 ## EXPO-6 — A processor's exposure suggestion is authoring input only *GR-EXPO (Part I) · level: core · profiles: storage-api, editor-client · added in 1.0* A processor may suggest that a port ship hidden. That suggestion is consumed once, when a node type's exposure values are first written, and never again, so a consumer reading a node type's published metadata is reading decisions, not suggestions. ### The rule > **Normative.** This is the rule. > > 1. The `x-exposed-by-default` schema extension is consumed at authoring time only: when seeding an author's exposure controls, and when a node type's stored exposure values are first derived from a processor's schema. > > 2. It must never be consulted when a workflow runs, and no execution-time decision may depend on it. > > 3. Published node metadata reports exposure as it stands in the node type's stored configuration (EXPO-7), never a processor's own suggestion; a processor declaring the extension therefore cannot change what an already-configured node type publishes. ## What it means A processor's exposure suggestion is read exactly once: when a node type is first derived from it. After that read, the suggestion is inert — changing the processor's schema later does not change what an already-derived node type publishes, because nothing at execution or publication time goes back to ask the processor again. What a node type reports is always its own stored decision (EXPO-7), never a live read of the processor. ## Example A processor's schema offers no opinion on one port and asks for the other to ship hidden. ```json title="The processor's own schema for two of its ports" { "visible_in": { "type": "string" }, "hidden_in": { "type": "string", "x-exposed-by-default": false } } ``` ```json title="What the node type stores for hidden_in, once, at derivation" verdict="hidden" { "exposed_by_default": false } ``` `visible_in` gets no such key at all — the derivation writes the flag only where it diverges from exposed. From that point on, a change to the processor's own schema reaches neither entry. ### Related rules - Names: EXPO-5, EXPO-7 - Referenced by: EXPO-5, EXPO-7, EXPO-8, EXPO-16 ## EXPO-7 — Published schemas carry the flag only where the default is hidden *GR-EXPO (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a node type's stored default exposure for a port is false, the schema it publishes carries `x-exposed-by-default: false`. > > 2. Where it is anything else, the key is absent from the published schema rather than present and true. > > 3. A processor's own value for the key is overwritten either way, so the published schema states the node type's decision and only where it diverges from the exposed default (CFG-2). ## What it means What a node type publishes states only its own decision, and only where that decision diverges from the default. A processor's own schema for the same port is never consulted at publication time and never survives into what ships — even where the node type's stored value agrees with the processor, or contradicts it outright, the published schema is built from the stored value alone. ## Example ```json title="The node type's stored default exposure for the port: false" verdict="hidden" { "connectable": true, "exposed_by_default": false } ``` ```json title="What that publishes" verdict="materialized" { "x-exposed-by-default": false } ``` Where the node type's own stored value is anything else — unset, or an explicit `true` even against a processor schema that itself declares `x-exposed-by-default: false` for that port — the published schema carries no such key at all. A processor's own value for the key never reaches what publishes; only the node type's stored word does. ### Related rules - Names: CFG-2, EXPO-5, EXPO-6 - Referenced by: EXPO-5, EXPO-6 ## EXPO-8 — A port with no stored decision is exposed, whatever the processor suggested *GR-EXPO (Part I) · level: extended · profiles: storage-api · added in 1.0 · posture: descriptive* A known seam, recorded so nobody is surprised by it. It is the price of the fail-open polarity: a port nobody has decided about shows up rather than disappearing. ### The rule > **Normative.** This is the rule. > > 1. Where a processor gains a parameter after a node type's exposure values were stored, and the node type has not been re-derived, that port has no stored exposure value and resolves as exposed, even where the processor suggests hiding it. > > 2. A processor's suggestion reaches a node type only through the authoring-time derivation of EXPO-6. *This rule records what implementations do rather than requiring it.* ## What it means A port with no stored exposure value reads exactly like a port nobody has an opinion about, even where the processor itself asked for that exact port to ship hidden. That request only ever reaches a node type through the one-time read of EXPO-6, and a port a node type never derived a decision for is a port that read never happened for. Fail-open wins: the port shows up. ## Example ```json title="The processor's own schema for the port" verdict="suggested" { "type": "string", "x-exposed-by-default": false } ``` ```json title="The node type's own stored entry for the same port" verdict="exposed" { "connectable": true } ``` The node type's entry carries no `exposed_by_default` at all — nothing was ever derived for this port. What gets published for it carries no exposure flag either, the same shape as a port nobody has decided about, regardless of what the processor asked for. ### Related rules - Names: CFG-2, EXPO-4, EXPO-6 - Referenced by: CFG-2, EXPO-4, EXPO-16 ## EXPO-10 — Hiding an input port changes precedence, it does not unset the parameter *GR-EXPO (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* A value arriving at a hidden input is ignored, not rejected. The parameter still gets a value (the author's, or the default), so hiding a port never turns a working node into a failing one. ### The rule > **Normative.** This is the rule. > > 1. Where a port is connectable but hidden, priority 1 is skipped rather than failed: a value delivered on a wire or by a caller is ignored and resolution continues to the author's saved config where the parameter is configurable and a key is present (CFG-5), and otherwise to the effective default (CFG-6). > > 2. Such a parameter never takes its value from the runtime inputs, and is never left null merely because its port is hidden. > > 3. Internal parameters sit outside this gate and always accept a runtime value (CFG-8). ## What it means Hiding a connectable input is a precedence change, not a removal. A value arriving on the port's wire, or supplied by a caller, is skipped entirely rather than merged, refused, or recorded — resolution carries on exactly as if that value had never arrived, landing on the author's saved value where one exists. The consequence that is easy to miss is the floor under that skip: it must never leave the parameter unset. Where the parameter is not even configurable, so there is nothing saved to fall back on, resolution continues all the way to the schema's own default rather than stopping short — a hidden port that resolved to null would turn hiding it into breaking the node it hides. Internal parameters sit outside this gate altogether. They are not authored ports a workflow shows or hides, so a runtime value reaches them whatever a port-exposure override happens to say about them. ## Example A parameter is connectable and configurable, the author saved a value for it, and a caller also supplies one for the same call — but the port is hidden. ```json title="The parameter's resolved value, with a caller's value also on offer" verdict="kept" "from-config" ``` Hiding a non-configurable parameter with nothing saved does not leave it unset either: resolution falls through to the schema's own default. ```json title="A hidden parameter with no config to fall back on" verdict="kept" "from-schema" ``` An internal parameter, hidden the same way an authored one would be, still takes the caller's value: the gate was never describing it. ```json title="An internal parameter's port marked hidden" verdict="kept" "from-runtime" ``` Hidden governs which value a wire or a caller may deliver; it never decides whether the parameter ends up with one. ### Related rules - Names: CFG-4, CFG-5, CFG-6, CFG-8, CFG-17 - Referenced by: CFG-4, CFG-8, CFG-17, EXPO-15 ## EXPO-11 — Output stripping covers declared output ports only *GR-EXPO (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* Hiding an output means its value never leaves the node. The pass that enforces this works from the processor's declared output schema, so keys that are not declared ports (dynamic outputs, reserved control ports) are outside its reach by construction. ### The rule > **Normative.** This is the rule. > > 1. When a node execution produces a result, every key naming a declared output port that resolves as hidden is removed from that result before it is delivered anywhere. > > 2. A key the processor's output schema does not declare (a dynamic output, a reserved control port) is not a port and passes through untouched. > > 3. A hidden declared output must be stripped whatever else the result contains, including where the processor declares no output ports at all. ## What it means Stripping only reaches keys the processor's output schema names as ports. Anything else in a node's result — a dynamic output an author added beyond the declared set, a reserved control port — passes straight through, not because it is judged safe but because the pass that removes hidden values has no way to recognise it as a port at all. That reach limit does not shrink the obligation at the edge: a hidden declared output must still be stripped even where the processor's schema declares no output ports at all. An empty declared set is not licence to leave a node type's own hidden-port marking unenforced — it is the one case where enforcing it matters most, because nothing else in the pass would otherwise catch it. ## Example A node's result carries three keys: one declared output the node type keeps visible, one declared output the node type hides, and one key the processor's output schema never names at all. ```json title="A node's raw result" verdict="produced" {"result": "ok", "dynamic_field": "kept", "ssn": "secret"} ``` ```json title="What the result delivers, with ssn a hidden declared output" verdict="stripped" {"result": "ok", "dynamic_field": "kept"} ``` `dynamic_field` is not a name the output schema declares, so it is outside stripping's reach in either direction — kept because it was never examined, not because it was judged harmless. ### Related rules - Names: EXPO-12, EXPO-13, EXPO-14 - Referenced by: EXPO-12, EXPO-13, EXPO-14, DATA-8 ## EXPO-12 — Control outputs are never stripped *GR-EXPO (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The reserved control outputs `active_branches` and `state_update` are never removed from a node's result by exposure stripping. > > 2. They are the engine's own channel, not ports an author shows or hides, and branching and state merging depend on them arriving intact. ## What it means Control outputs are exempt from every lever that strips an ordinary port — the node type's own default, an instance override, all of it. They are not something an author shows or hides at all; they are the engine's own channel, and branching and state merging depend on them arriving whole every time, whatever exposure a workflow otherwise records for that node. ## Example A node's type marks every one of its outputs — `active_branches`, `state_update`, and an ordinary port — hidden, and the workflow's own instance override hides `state_update` again on top of that: the least favourable exposure available for all three. ```json title="What the node produced" verdict="produced" {"state_update": {"iterator": {"index": 1}}, "active_branches": "true", "debug": "internal trace"} ``` ```json title="What the result delivers" verdict="kept" {"state_update": {"iterator": {"index": 1}}, "active_branches": "true"} ``` `debug`, an ordinary output hidden the same way, is the one key stripping removes. ### Related rules - Names: EXPO-11 - Referenced by: EXPO-11, EXPO-14, DATA-8 ## EXPO-13 — A node type can hide an output outright *GR-EXPO (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a node type marks an output port `exposed: false`, that output is stripped from every instance's result unconditionally, and no instance override can restore it. > > 2. Otherwise the instance's own override decides, then the node type's default exposure, and a port with none of these resolves as exposed. ## What it means Two node-type-level signals both start an output hidden, but only one of them can be reversed by the workflow's own instance override. Marking a port `exposed: false` says the port does not exist at all: nothing an instance records can restore it. The node type's default exposure, by contrast, is only a starting position — the instance's own override decides over it, and only the node type's default is consulted when the instance has recorded none. ## Example ```json title="A result carrying an output the node type declares does not exist (exposed: false)" verdict="stripped" {"result": "ok", "debug": "internal trace"} ``` ```json title="A different port, merely hidden by the node type's default exposure" verdict="stripped" {"throttled": true} ``` ```json title="A default-hidden port the instance overrides back on" verdict="kept" {"debug": "now wanted"} ``` The first case takes no override — none could change the outcome. The third starts from the same kind of hiding as the second and reverses it, because a default is only ever a starting position. ### Related rules - Names: EXPO-2, EXPO-11 - Referenced by: EXPO-11, DATA-8 ## EXPO-14 — A hidden output's value never reaches anything downstream *GR-EXPO (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* Stripping is the first thing that happens to a result, which is what makes the invariant hold rather than nearly hold. A value removed before anything else looks at it cannot leak through a checkpoint, a job record, or a tool result. ### The rule > **Normative.** This is the rule. > > 1. Exposure stripping (EXPO-11) runs before any other handling of a node's result: before the unified `output` port is composed, before the result is checked for serializability, before it is wrapped for delivery, and before it is written to run records, checkpoints, real-time updates, or returned as a tool result. > > 2. A hidden output's value therefore never reaches any of them, and a value on a hidden port that could not be serialized never fails the node. ## What it means Stripping is not one check among several that a node's result passes through; it is the first thing that happens to it. Everything else that looks at a node's output — the unified port that bundles values into one object, delivery to run records, checkpoints, real-time updates, a tool result — reads the already-filtered result, never the raw one. That ordering is what makes "a hidden output never reaches anything downstream" a fact about every one of those destinations at once, rather than something each would have to enforce for itself. The unified port is the case worth naming, because composing it looks like a separate step that could plausibly run first: it is built from whatever the result holds by the time composition runs, so if stripping ran after composition a hidden value would still be sitting inside it even though its own port was empty. ## Example A node produces a value on a port the node type hides, and the workflow exposes the unified `output` port that bundles every visible output into one object. ```json title="What the node produced" verdict="produced" {"result": "ok", "ssn": "123-45-6789"} ``` ```json title="The composed unified output" verdict="composed" {"output": {"result": "ok"}} ``` The hidden `ssn` value never appears inside `output` either — composition worked from the result stripping had already produced. ### Related rules - Names: EXPO-11, EXPO-12 - Referenced by: EXPO-11, DATA-8 ## EXPO-15 — A model may fill only visible, unwired parameters *GR-EXPO (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a node is offered to an agent node as a callable tool, a parameter is fillable by the model when it is connectable and exposed and no data edge already feeds that port. > > 2. A hidden parameter, a non-connectable one, and one a wire already supplies are all outside the model's reach. ## What it means A parameter reaches the model only when three things hold at once: connectable, exposed, and not already fed by a wire. Missing any one pins it — the model cannot fill it, and it does not even appear in the schema offered to the model. The wire check is independent of the other two: a wired parameter is withheld because a wire's value is deterministic run state, and the model must not compete with it, whether or not that same parameter would otherwise be exposed. A parameter the workflow hides is withheld the same way, whether the hiding comes from an explicit instance override or from the node type's own default exposure. ## Example A node offers four parameters as a callable tool: two connectable and exposed by default, one not connectable, and one internal. ```json title="The parameters the model may fill, nothing wired" verdict="fillable" ["query", "url"] ``` ```json title="The same parameters, with url fed by a data edge" verdict="fillable" ["query"] ``` The non-connectable and internal parameters never appear in either list — being wired or hidden was never the reason they were excluded. ### Related rules - Names: EXPO-2, EXPO-10 ## EXPO-17 — Port display order is cosmetic *GR-EXPO (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A port's declared display order is presentational metadata for whatever draws the node. > > 2. No execution, resolution, validation or exposure decision may depend on it, and reordering ports must not change what a workflow does. ## EXPO-16 — Seeding a port's default-exposure decision *GR-EXPO (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* A suggestion fills a decision nobody has made yet, and only for a port that is actually there. ### The rule > **Normative.** This is the rule. > > 1. Where an authoring surface presents a port's default-exposure decision, a value already stored for that port decides it. > > 2. Where none is stored, the processor's suggestion (EXPO-6) is applied only to a port the node type declares — an input it declares connectable, an output it declares exposed — and a port the node type does not declare is presented as not exposed by default. > > 3. Exposure values are never recorded for a port the node type does not declare. ### Related rules - Names: EXPO-6, EXPO-8 --- # GR-DYN — DYN (Part I) ## DYN-1 — Dynamic ports are opt-in, and a node that does not opt in has none *GR-DYN (Part I) · level: extended · profiles: storage-api · added in 1.0* Dynamic ports let an author add connection points to one node instance beyond what its processor declares. Nothing gets them by accident: a node type has to ask for them, and the definitions start empty. ### The rule > **Normative.** This is the rule. > > 1. A node type opts a node in to dynamic ports. > > 2. The opt-in declares two reserved parameters, one holding the node's dynamic input port definitions and one its dynamic output port definitions, each a list whose default is empty. > > 3. A node that has not opted in has no dynamic ports. ## DYN-2 — A dynamic port declares a name, a label and a data type *GR-DYN (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Every dynamic port definition carries a `name`, a `label` and a `dataType`. > > 2. The `dataType` defaults to `mixed`, and the set an editor offers an author is exactly those data-type lanes that carry a value; the control lanes are not offered, because a dynamic port carries a value by definition. ## DYN-3 — Dynamic port names are constrained and unique across the node *GR-DYN (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A dynamic port name begins with an ASCII letter and continues with ASCII letters, digits or underscores. > > 2. It must not be a reserved name, and it must be unique across the union of the node's dynamic input and dynamic output names: an input and an output on the same node may not share a name. > > 3. A definition that breaks any of these is refused. ## DYN-4 — An unconnected dynamic input resolves to null, with its key present *GR-DYN (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A dynamic input port with nothing wired into it resolves to null, and its key is present in the node's resolved inputs. > > 2. Nothing arriving on the wire is not the same as the port being absent. ## DYN-5 — A declared parameter wins a name collision with a dynamic port *GR-DYN (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a dynamic port's name is also the name of a parameter the node's processor declares, the declared parameter wins and the dynamic port is ignored. > > 2. An author cannot shadow a declared parameter by adding a dynamic port of the same name. ## DYN-6 — Dynamic ports carry no exposure state and are not addressable from outside *GR-DYN (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* Exposure is a property of a port the processor declares. A dynamic port is not declared, so there is nothing to hide and nothing to strip, and for the same reason nothing an outside caller can name. ### The rule > **Normative.** This is the rule. > > 1. A dynamic port has no exposure: it is always wireable, its value is never stripped from a node's output, and edge validation raises no exposure objection against it. > > 2. For the same reason a dynamic port is not addressable at the workflow boundary: a launch-input manifest entry naming one is refused, because the port is not declared by the node's processor. ## DYN-7 — Dynamic port definitions are configuration, never a wireable input port *GR-DYN (Part I) · level: extended · profiles: storage-api, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The reserved parameter holding a node's dynamic port definitions is never a wireable input port. > > 2. It appears in the node's configuration schema only where the node type marks it configurable, taking its value from the node type's default, else the parameter's schema default, else the empty list. > > 3. Without that opt-in it appears in neither the configuration schema nor the input schema, and the node's dynamic port definitions resolve to empty: stored definitions do not survive an opt-out. --- # GR-MEM — MEM (Part I) ## MEM-1 — Appending a tool result twice appends it once *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* An agent loop that retries a step, or an orchestrator that re-delivers a result, must not double the conversation. The buffer decides by the tool call's id, so a repeat is a no-op rather than a second turn. ### The rule > **Normative.** This is the rule. > > 1. Appending to a conversation buffer is idempotent by tool call id. > > 2. A `tool`-role message whose tool call id already appears anywhere in the buffer (already stored, or added earlier in the same append) is dropped rather than appended, and the reported message count reflects the deduplicated buffer. > > 3. The one exception is an id currently held by a synthetic healed placeholder, which is replaced rather than deduplicated. ## What it means A retried step or a re-delivered result carries the same tool call id it carried the first time, so the buffer can tell a repeat from a second call without any cooperation from whatever is retrying. The count reported back is the deduplicated count, not a running total, so a re-fire is invisible to anything reading it afterward. The exception is the clause worth reading twice: an id currently held by a synthetic healed placeholder is not deduplicated against — it is replaced. Without that exception, a real result landing after MEM-3 healed its call would be dropped as a duplicate of its own placeholder, and the buffer would keep telling the model the call was interrupted after it had in fact returned. ## Example A buffer already holds a turn that declared tool call `c1` and a result that answered it. ```json title="A result naming an id already answered" verdict="dropped" { "role": "tool", "content": "re-fired result", "tool_call_id": "c1" } ``` ```json title="A result naming an id not yet seen" verdict="appended" { "role": "tool", "content": "second result", "tool_call_id": "c2" } ``` Now the same id is held only by a synthetic placeholder, not a real result: ```json title="A placeholder still standing in for c1" verdict="held" { "role": "tool", "content": "Tool call was interrupted; no result.", "metadata": { "tool_call_id": "c1", "healed": true } } ``` ```json title="The real result for c1 arriving late" verdict="replaced" { "role": "tool", "content": "Found 3 cats", "tool_call_id": "c1" } ``` ### Related rules - Names: MEM-2, MEM-10, MEM-11 - Referenced by: MEM-2, MEM-10, MEM-11, MEM-14 ## MEM-2 — An assistant turn is a duplicate only when every call it declares is known *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* The assistant side of the same guard. Dropping a turn that declares one new call would lose that call, so a partial overlap is kept. ### The rule > **Normative.** This is the rule. > > 1. An `assistant`-role message is dropped as a duplicate only when every tool call id it declares is already declared somewhere in the buffer. > > 2. A turn declaring at least one id not yet seen is appended. ## What it means The assistant side of MEM-1's guard is not symmetric with it. A tool result is one id; an assistant turn can declare several at once, and dropping the whole turn because *some* of its ids are already known would silently lose whichever ones are not. A turn is a duplicate only when every id it declares is already declared somewhere in the buffer — a single new id is enough to keep it. ## Example A buffer already holds a turn that declared tool call `c1`. ```json title="A turn declaring only the id already known" verdict="dropped" { "role": "assistant", "content": "", "tool_calls": [{ "tool_call_id": "c1", "name": "search" }] } ``` ```json title="A turn declaring one known id and one new one" verdict="kept" { "role": "assistant", "content": "", "tool_calls": [{ "tool_call_id": "c1", "name": "search" }, { "tool_call_id": "c2", "name": "lookup" }] } ``` ### Related rules - Names: MEM-1 - Referenced by: MEM-1, MEM-11 ## MEM-3 — A user turn heals tool calls that were never answered *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* A crashed or interrupted loop leaves an assistant turn declaring a call with no result. Most providers reject that history outright, so the buffer closes the pair before the conversation moves on. ### The rule > **Normative.** This is the rule. > > 1. Before a `user`-role message is appended, every tool call declared in the buffer that has no matching tool result anywhere in the buffer is answered by a synthetic `tool`-role message stating that the call was interrupted and returned no result. > > 2. The synthetic message is marked as healed, carries the id it answers, and is inserted directly after the turn that declared the call, not at the end of the buffer, because a result must be adjacent to its call. > > 3. Healing runs only at this boundary: appending a `tool` or `assistant` message never triggers it. ## What it means A crashed or interrupted loop can leave a buffer with an assistant turn that declared a call and no result answering it. Healing does not wait for the buffer to end before closing that pair: the synthetic result is inserted directly after the turn that declared the call, wherever that is, because a result has to sit next to its call for the history to be sendable at all. A conversation that carried on past the crash — more turns appended after the dangling call, before anyone appends a `user` message — still gets its synthetic result inserted mid-buffer, not tacked onto the end. The other clause worth noting: healing runs only at a `user`-turn boundary. Appending a `tool` or `assistant` message never triggers it, even when the buffer already holds an unanswered call. ## Example A call goes unanswered, and two more turns are appended before anyone appends a `user` message: ```json title="The buffer before the healing user turn" verdict="unhealed" [ { "role": "assistant", "content": "", "metadata": { "tool_calls": [{ "tool_call_id": "c1", "name": "search" }] } }, { "role": "user", "content": "anything?", "metadata": {} }, { "role": "assistant", "content": "sorry, lost it", "metadata": {} } ] ``` ```json title="What appending a user turn produces" verdict="healed" [ { "role": "assistant", "content": "", "metadata": { "tool_calls": [{ "tool_call_id": "c1", "name": "search" }] } }, { "role": "tool", "content": "Tool call was interrupted; no result.", "metadata": { "tool_call_id": "c1", "healed": true } }, { "role": "user", "content": "anything?", "metadata": {} }, { "role": "assistant", "content": "sorry, lost it", "metadata": {} }, { "role": "user", "content": "try again", "metadata": {} } ] ``` The synthetic result lands at index 1, next to the call it answers, not at the end where the new user turn was appended. Appending a `tool` message against the same dangling call, instead of a `user` message, leaves the call unanswered — healing never runs. ### Related rules - Names: MEM-10, MEM-11 - Referenced by: MEM-5, MEM-10, MEM-11, MEM-16 ## MEM-4 — Assembling messages concatenates in declared port order *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* The node that joins several message sources into one list. Port order is the message order, and an unwired source adds nothing at all. ### The rule > **Normative.** This is the rule. > > 1. A message-assembly node takes any number of dynamically declared message-typed inputs and flattens them into one message-typed output. > > 2. The declared order of the inputs is the order of the messages. > > 3. A connected list contributes each of its items in order; a connected single message contributes one item; an input that is unwired or carries no value contributes nothing; no placeholder is emitted for it. ## What it means The output order follows how the author declared the ports, not the order a caller might expect from the port names or from wiring them up. Declare `b` before `a` and wire both — `b`'s messages come first regardless of what the names suggest. The other clause is the one a reader coming from a message-merging node would get wrong: an input port that is unwired, or carries no value, contributes nothing at all — no null placeholder takes its place in the output. A three-port assembly with the middle port unwired produces a two-item list, not a three-item list with a gap in it. ## Example Two ports declared in the order `b`, then `a`, each wired to one message: ```json title="Ports declared b before a" verdict="assembled" { "b": [{ "role": "user", "content": "second-declared-first" }], "a": [{ "role": "user", "content": "first-declared-second" }] } ``` ```json title="What the assembly outputs" [{ "role": "user", "content": "second-declared-first" }, { "role": "user", "content": "first-declared-second" }] ``` Three ports declared, the middle one left unwired: ```json title="in_1 and in_3 wired, in_2 left unwired" verdict="assembled" { "in_1": [{ "role": "user", "content": "a" }], "in_3": [{ "role": "user", "content": "c" }] } ``` ```json title="What the assembly outputs" [{ "role": "user", "content": "a" }, { "role": "user", "content": "c" }] ``` ### Related rules - Names: MEM-16 - Referenced by: MEM-16 ## MEM-5 — Normalizing a conversation makes it sendable to a provider *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* Stored history and provider history are not the same shape. Normalization is the one place that reconciles them, and it reports what it had to drop. ### The rule > **Normative.** This is the rule. > > 1. A conversation-normalization node takes one message list and emits a normalized message list plus the number of entries dropped, applying four rules in order. > > 2. (1) Each entry is coerced to the flat message shape a reasoner consumes; a non-list entry or one without a role is dropped, and content that is not a scalar is serialized to text rather than blanked, falling back to the empty string if it cannot be serialized. > > 3. (2) `system`-role messages move to the front, with the system group and the remaining group each keeping their own relative order; a system message is never dropped. > > 4. (3) Tool pairing is repaired by the same procedure a reasoner applies mid-loop: an unanswered call gets a synthetic interrupted result adjacent to it, an orphan or duplicate result is dropped, and a tool call id declared a second time is dropped from the later turn, so the first declaration owns the pairing. > > 5. (4) Any message still leading the list with role `tool` is stripped, and because stripping it can leave a call unanswered, rule (3) is applied again; repair is idempotent. > > 6. The reported drop count comes from the repair pass itself, never from a separate reimplementation of its rules. ## What it means The reported drop count has to come from the repair pass itself, never from a second count kept alongside it — the two can disagree on a case a reader would not think to try. A tool call id can be present in the buffer and still be unusable: `"0"` is a legal id string but a falsy value, and once the call carrying it is treated as unusable, the turn that declared it, the id itself, and the result answering it all have to go — one unusable id costing three drops, not one, from a buffer of two entries. The other clause worth reading closely is what happens at the front of the list. A tool result placed before the assistant turn that declares its id is not caught by id-matching alone — the declaring call exists somewhere in the list, just not before its result — so a separate leading-orphan guard strips it. Stripping it un-answers the assistant turn it was answering, so tool pairing is applied again; the result is provider-sendable, not merely orphan-free. ## Example A call id that is technically declared but falsy: ```json title="A call id and its result, both spelled \"0\"" verdict="unusable" [ { "role": "assistant", "content": "", "tool_calls": [{ "tool_call_id": "0", "name": "search" }] }, { "role": "tool", "content": "result", "tool_call_id": "0" } ] ``` ```json title="What normalization resolves it to" verdict="dropped" [] ``` A tool result appearing before the call that declares its id: ```json title="A result placed ahead of its declaring call" verdict="misordered" [ { "role": "tool", "content": "out of order", "tool_call_id": "c1" }, { "role": "assistant", "content": "", "tool_calls": [{ "tool_call_id": "c1", "name": "search" }] } ] ``` ```json title="What normalization resolves it to" verdict="repaired" [ { "role": "assistant", "content": "", "tool_calls": [{ "tool_call_id": "c1", "name": "search" }] }, { "role": "tool", "content": "Tool call was interrupted; no result.", "tool_call_id": "c1" } ] ``` ### Related rules - Names: MEM-3, MEM-16 - Referenced by: MEM-16 ## MEM-6 — A user-scoped memory read or write without a real user refuses *GR-MEM (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* A memory bucket keyed by identity is only meaningful when there is an identity. Degrading to a shared bucket is how one caller's conversation ends up in another's history, so the scope refuses instead. ### The rule > **Normative.** This is the rule. > > 1. Resolving the `user` memory scope refuses whenever the execution context is absent, carries no user identifier, carries one that is not a usable identifier, or carries the identifier that denotes no user. > > 2. A refusal is distinct from resolving to an empty scope identifier: an empty identifier selects the shared global bucket, so a `user` scope that degraded would merge every identity-less execution path into one bucket and every anonymous caller into another. > > 3. Scopes that are not identity scopes continue to resolve to the empty identifier. ## What it means A refusal is not the same outcome as resolving to an empty scope identifier. The empty identifier is a legitimate answer for scopes that are not tied to an identity — it selects one shared bucket, and every caller in that shape of execution shares it on purpose. The `user` scope cannot degrade to that same empty identifier when it has no usable identity, because doing so would merge every identity-less execution path into the one shared bucket, and every anonymous caller into another single bucket alongside every other anonymous caller. So it refuses instead of resolving. The identifier `0` — the value an owner-less record reports for "no owner", not the absence of a value — refuses for the same reason, in both its integer and string spelling: accepting it would put every anonymous caller in one `user:0` bucket. ## Example ```json title="A user scope in a context whose user_id is 0" verdict="refused" { "user_id": 0 } ``` ```json title="A user scope in a context whose user_id is the string \"0\"" verdict="refused" { "user_id": "0" } ``` ```json title="A user scope in a context carrying a real user id" verdict="resolved" { "user_id": "7" } ``` ### Related rules - Names: MEM-7, MEM-8, MEM-13 - Referenced by: MEM-7, MEM-8, MEM-13, MEM-9 ## MEM-7 — A refused scope reaches no storage backend *GR-MEM (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* The refusal is only worth having if every consumer honours it identically, and if nothing is written on the way out. ### The rule > **Normative.** This is the rule. > > 1. When a memory scope refuses, every consumer of it returns its neutral result and the storage backend is not called at all, for neither a read nor a write-back. > > 2. A read returns its configured default and reports that nothing was found; a write and a delete report failure; a conversation-buffer append returns an empty buffer with a count of zero, dropping the turn rather than appending it to a shared history. > > 3. The resolved scope identifier reported back is empty. > > 4. Each refusal emits exactly one warning identifying the node, the pipeline and the workflow, and the refusal is decided in one place so the consumers cannot diverge. ## What it means A refused scope and a scope that legitimately resolves to an empty identifier can look the same from one field alone — both report an empty `resolved_scope_id` — so the rule is what makes them distinguishable as a whole response. A refusal's read reports a fixed default and `found: false` on top of the empty identifier; a non-identity scope resolving to the same empty identifier because it has nothing else to resolve to reports whatever was actually stored there, `found` included. Neither the fixed default nor the stored value crosses paths with the other, because a refusal never calls the storage backend at all. ## Example A `user` scope with no usable identity, reading a key with a fallback default: ```json title="Reading a preference in a refused user scope" verdict="refused" { "value": "fallback", "found": false, "scope": "user", "resolved_scope_id": "" } ``` A `global` scope — not identity-scoped — legitimately resolves to the same empty identifier, and reads normally: ```json title="Reading the same key in the global scope" verdict="resolved" { "value": "val", "found": true, "scope": "global", "resolved_scope_id": "" } ``` The empty `resolved_scope_id` is identical in both; only `found` and the value tell them apart. ### Related rules - Names: MEM-6, MEM-13 - Referenced by: MEM-6, MEM-12, MEM-13 ## MEM-8 — A session's memory principal is its owner or nobody *GR-MEM (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* Memory follows the conversation, not whoever happens to be driving it this turn. An unowned conversation therefore has no user memory at all. ### The rule > **Normative.** This is the rule. > > 1. When a session drives a workflow, the user identity passed into the execution context is the session's own owner, and only when that owner is a real user. > > 2. A session with no owner, or owned by no real user, passes no user identity, and must not fall back to the identity of the caller driving the turn; doing so would hand one caller's memory bucket to the next. > > 3. The identity passed this way is what the `user` memory scope resolves. ### Related rules - Names: MEM-6 - Referenced by: MEM-6, MEM-9 ## MEM-10 — A real tool result replaces the placeholder that stood in for it *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* A healed message says a call was interrupted. If the result later arrives, the buffer must show the answer, not the guess. ### The rule > **Normative.** This is the rule. > > 1. When a tool result arrives for an id whose buffer entry is a synthetic healed placeholder, the real result replaces the placeholder in place (at the same position, so its adjacency to the declaring turn is preserved), and the healed marking is removed with it. > > 2. Only a placeholder is ever overwritten: after the replacement the entry is an ordinary answered result, so a second real result for the same id is deduplicated as usual, and an incoming message that is itself marked healed never replaces anything. ## What it means A healed placeholder exists so the conversation stays readable while a call's real answer is still missing. It is a guess standing in a real slot, and once the real result arrives the guess has to go — not sit alongside it, not get appended after it, but be overwritten in the exact position it held, so the result still reads immediately after the turn that declared the call. The overwrite is the whole effect: the entry becomes an ordinary answered result afterwards, indistinguishable from one that was never healed. A second copy of the same result arriving later is deduplicated the normal way, because by then there is nothing marked healed left to reclaim. ## Example A buffer holds a synthetic placeholder for a call whose real result never arrived before the turn was saved. The real result then arrives for the same id. ```json title="The buffer holding a healed placeholder for call c1" {"role": "tool", "content": "Tool call was interrupted; no result.", "metadata": {"tool_call_id": "c1", "healed": true}} ``` ```json title="What the same slot holds once the real result replaces it" verdict="replaced" {"role": "tool", "content": "Found 3 cats", "metadata": {"tool_call_id": "c1"}} ``` The buffer is no longer than it was: the turns on either side keep their positions, and the append this produces is reported as none, since a replacement changes an entry rather than adding one. ### Related rules - Names: MEM-1, MEM-3 - Referenced by: MEM-1, MEM-3, MEM-14 ## MEM-11 — Both spellings of a tool call's id are read *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* A buffer written from a raw provider payload spells the id differently from one written by the buffer itself. Reading only one spelling makes every guard blind to the other. ### The rule > **Normative.** This is the rule. > > 1. Where an assistant turn declares tool calls, a call's id is read under either the normalized spelling or the provider-flat spelling, whichever is present. > > 2. Deduplication, dangling-call detection and id recording all read the id through the same single definition, so the three cannot disagree about which calls a turn declares. ## What it means A tool call's id can arrive spelled two ways: the shape this buffer writes itself, and the flat shape a provider payload carries straight through. Both name the same call. The rule that bites is that every reader of that id — whether it is checking for a duplicate, looking for a call nobody answered, or recording what a turn declared — has to accept either spelling, because a buffer built from raw provider turns and one built by appending through this node can end up sitting side by side in the same conversation. A checker that only recognises its own spelling does not fail loudly. It simply stops seeing calls written the other way, so a call already answered looks unanswered, or a duplicate looks new. ## Example An assistant turn declares a call in the flat spelling a provider payload uses, and the turn that follows does not answer it. ```json title="A declared call in the provider-flat spelling" {"role": "assistant", "content": "", "metadata": {"tool_calls": [{"id": "call_1", "name": "search"}]}} ``` ```json title="The synthesized placeholder the buffer inserts for it" verdict="healed" {"role": "tool", "metadata": {"tool_call_id": "call_1", "healed": true}} ``` The placeholder carries the normalized spelling, `tool_call_id`, but the id it names, `call_1`, came from reading the declaring turn's flat `id` — the same call the normalized spelling would have named `tool_call_id`. ### Related rules - Names: MEM-1, MEM-2, MEM-3 - Referenced by: MEM-1, MEM-3 ## MEM-12 — Concurrent buffer appends are serialized, and never fatal *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* Two branches appending at once would each read the buffer before the other's turn and write back a version missing it. Serializing the read-modify-write prevents that, but losing a turn is worse than a rare interleave. ### The rule > **Normative.** This is the rule. > > 1. The read-modify-write of a conversation buffer is serialized per storage scope (scope, scope identifier and key together), so concurrent appends queue instead of overwriting one another. > > 2. The wait is bounded: an append that still cannot take its turn proceeds unserialized and emits a warning rather than failing or discarding the turn. > > 3. Serialization taken is always released. > > 4. Nothing more is claimed: not storage-level atomicity, and no protection against a writer that does not go through this path. ### Related rules - Names: MEM-7 ## MEM-13 — A session-scoped memory read or write without a real session refuses *GR-MEM (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* The other identity scope, closing the same leak. A session scope that degraded would splice every identity-less execution path into one shared conversation history. ### The rule > **Normative.** This is the rule. > > 1. Resolving the `session` memory scope refuses whenever the execution context is absent, carries no session identifier, carries one that is not a usable identifier, or carries the identifier that denotes no session. > > 2. As with the `user` scope, refusing is distinct from resolving to the empty scope identifier, which selects the shared global bucket. > > 3. Scopes that are not identity scopes continue to resolve to the empty identifier, and every consumer honours this refusal the same way it honours the `user` one. ## What it means Refusing and resolving to the empty scope identifier look similar from the outside — neither reads a caller-specific bucket — but they are different answers. The empty identifier is a real resolution: it selects the one shared bucket every identity-less execution reads and writes. Refusing means no bucket is selected at all, and nothing is read or written under this scope for this call. A `session` scope with no usable session identifier — absent, empty, zero, or anything that is not a real identifier — refuses; it does not fall through to the shared bucket the way a scope with no identity concept at all does. Every consumer of this scope honours the refusal the same way: none of them treat "no session" as license to read or write the global bucket instead. ## Example A read against the `session` scope, from an execution context that carries no session identifier, with a fallback default of `"fallback"`. ```json title="What the read reports" verdict="refused" {"value": "fallback", "found": false, "scope": "session", "resolved_scope_id": ""} ``` `resolved_scope_id` is the empty string, the same value the shared bucket would use — but `found` stays false and `value` is the caller's own fallback, never something read from storage: the empty string here names a refusal, not a bucket that was actually read. ### Related rules - Names: MEM-6, MEM-7 - Referenced by: MEM-6, MEM-7, MEM-14 ## MEM-14 — An append reports how much of it was new *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* Every drop the buffer makes is silent, and the total message count looks identical across two identical passes. The delta is the only evidence a caller has that a turn actually landed. ### The rule > **Normative.** This is the rule. > > 1. A conversation-buffer append reports how many of the incoming messages were added as new entries, and whether that number is greater than zero, alongside the resulting buffer count. > > 2. The number counts appends and not the change in buffer size, because a windowed buffer can evict as many older messages as the call added. > > 3. A deduplicated message and a placeholder replacement each count as zero, since neither adds an entry. > > 4. An append under a refused identity scope reports zero, together with its empty buffer. ## What it means The count that changes is not the buffer's size. A windowed buffer can evict as many older entries as an append adds, so the total count after a call that genuinely added a message can be exactly what it was before. The number a caller needs is how many of the incoming messages actually became new entries — the delta the buffer's own size cannot show. A message dropped as a duplicate, and a placeholder a real result replaces in place, both add zero entries, so both report zero even though the buffer's size does not tell them apart from a message that landed cleanly. ## Example A three-message window already holds three entries; the incoming message is new content, and the window evicts the oldest to make room. ```json title="A new message arriving at a full three-message window" verdict="appended" {"count": 3, "appended": 1, "appended_any": true} ``` The buffer is still three messages long — the same size it was — but one of those three is the message that just arrived, and `appended` says so where the count alone could not. ```json title="A tool result re-fired under an id the buffer already holds" verdict="dropped" {"count": 2, "appended": 0, "appended_any": false} ``` Here the buffer size does not change either, but for the opposite reason: nothing new landed at all. ### Related rules - Names: MEM-1, MEM-10, MEM-13 ## MEM-16 — Text becomes a message under three closed rules *GR-MEM (Part I) · level: extended · profiles: storage-api, runtime · added in 1.0* The adapter every text producer needs to reach a message-shaped node. Its defaults are chosen so a malformed turn never reaches a provider. ### The rule > **Normative.** This is the rule. > > 1. A text-to-message node turns a text value and a role into one message, emitted both as a one-element message-typed list and as the same row on a plain object-typed output. > > 2. Content is coerced exactly as conversation normalization coerces it: a scalar is cast, a non-scalar is serialized to text rather than blanked, and unserializable content falls back to the empty string. > > 3. Three rules are closed. > > 4. The role is one of `user`, `assistant`, `system` or `tool`; any other value, an absent one included, resolves to `user`, so a role a provider would reject never leaves the node. > > 5. A tool call id is attached only on role `tool`, trimmed, and omitted when blank; on any other role it names no pairing and is discarded. > > 6. Empty content emits an empty list and an empty object rather than an empty turn, so an unwired producer cannot become a silent request on a blank prompt, a `tool` row with no content included, because synthesizing an answer for an unanswered call belongs to tool-pairing repair. ## What it means The node closes three questions an author is not asked to answer for every piece of text a workflow turns into a message. A role a provider would reject — an unrecognised value, or none at all — never reaches the message; it becomes `user` instead of a validation error, so a producer that forgets to set a role, or sets one from a value the author never anticipated, still emits something a provider will accept. A pairing id only means something on a `tool` row: on any other role it is discarded rather than carried through, because attaching it there would claim a pairing that is not real. And a blank pairing id on a `tool` row is treated as no id at all, not as an empty one. ## Example The same text and role pair, differing only in the role's provider validity. ```json title="Text declared with a role no provider defines" {"text": "hello", "role": "operator"} ``` ```json title="What the node emits" verdict="normalized" {"role": "user", "content": "hello"} ``` A pairing id attached where only a `tool` row can carry one: ```json title="A pairing id declared on a user-role row" {"text": "hello", "role": "user", "tool_call_id": "call_1"} ``` ```json title="What the node emits" verdict="discarded" {"role": "user", "content": "hello"} ``` ### Related rules - Names: MEM-3, MEM-4, MEM-5 - Referenced by: MEM-4, MEM-5 ## MEM-9 — Driving a session is a write, and an absent identity owns nothing *GR-MEM (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* A turn spends the owner's memory, so the right to watch a conversation is not the right to continue it. And the caller with no identity is not a caller whose identity happens to be zero. ### The rule > **Normative.** This is the rule. > > 1. Authorization to read a session does not authorize driving it. > > 2. A principal that may only view a session must not be able to send it a turn, stop it or reset it, because the turn runs under the session owner's identity and reads and writes the owner's memory (MEM-8). > > 3. Every ownership test requires a real identity on both sides. > > 4. A principal carrying no identity never owns anything, and a session carrying no owner is owned by nobody rather than by everybody, so an unidentified caller never acquires ownership of a session, a transcript or a run snapshot by matching one absent identity against another. ### Related rules - Names: MEM-6, MEM-8, MEM-15 - Referenced by: MEM-15 ## MEM-15 — A denial is final, and a message is not editable *GR-MEM (Part I) · level: core · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A denial by these access rules is final. > > 2. Where an implementation offers extension points that contribute to an access decision, none of them may grant what these rules have denied. > > 3. A session message is never updatable or deletable except at the administrative tier. > > 4. A message whose parent session cannot be loaded is denied rather than left undecided, since the ownership it would be judged against cannot be established. ### Related rules - Names: MEM-9 - Referenced by: MEM-9 --- # GR-MAN — MAN (Part I) ## MAN-1 — A workflow's launch inputs are declared, never inferred *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* The launch surface is the author's decision, written down. If it were derived from what the nodes happen to expose, adding a parameter to a node would widen what the outside world may send. ### The rule > **Normative.** This is the rule. > > 1. The inputs a caller may supply when launching a workflow are a manifest the author declared on the workflow, and are never derived from port exposure. > > 2. A declared entry is built into the workflow's contract even when the instance hides the port it names, and a port that is exposed but not declared never reaches the contract. ## What it means The launch-input manifest is a list the author wrote, not a computation over the workflow's nodes. That cuts both ways, and the second direction is the one that bites. A node's parameter can be hidden on the instance and still sit in the manifest — the entry still reaches the contract, because hiding a port is an instance setting, not an edit to what the author declared. And a node's parameter can be exposed, wired, fully visible in the editor, and never reach the contract at all, because nothing in the manifest names it. The consequence: adding a parameter to a node type, or exposing one that used to be hidden, never by itself changes what a caller may send. Only an edit to the manifest itself does that (MAN-2, MAN-3). ## Example The node type declares two parameters, `key` and `other`. The instance hides `key` and exposes `other` — the opposite of what the manifest entry below declares. ```json title="The manifest names key, the port the instance hides" { "name": "lookup_key", "node_id": "n1", "port": "key" } ``` ```json title="What the workflow publishes as its contract" { "properties": { "lookup_key": { "type": "string", "x-data-type": "string" } } } ``` `other` is exposed on the instance and never appears. `lookup_key` is hidden on the instance and appears anyway, because the manifest named it. ### Related rules - Names: MAN-2, MAN-3 - Referenced by: MAN-2, MAN-3 ## MAN-2 — A manifest entry names an input and binds it to a port *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* Three fields say what the input is called and where it goes; the rest is documentation for whoever calls the workflow. ### The rule > **Normative.** This is the rule. > > 1. A manifest entry carries a `name`, a `node_id` and a `port`, all required, and may carry `title`, `description`, `examples` and `required`. > > 2. The optional metadata applies to the input side only. > > 3. A `required` entry is folded into the built contract's top-level list of required names rather than surviving as a key on the property fragment. ## What it means Three fields are the entry: `name` is what the caller calls the input, and `node_id` plus `port` are where it lands inside the workflow. Everything else an entry may carry — `title`, `description`, `examples`, `required` — is metadata for whoever calls the workflow, and it applies only to the input side; an entry that binds an output port carries none of it into the built contract. `required` is the field worth pausing on. It is not stored as a key on the property fragment itself; it is folded into the built schema's own top-level `required` list, the way JSON Schema expects required properties to be named (MAN-16). An entry can be required without that fact ever appearing next to its own `type` and `default`. ## Example The workflow declares one input, bound to a port, with author-facing metadata attached. ```json title="A manifest entry naming an input and describing it for callers" { "name": "customer_email", "node_id": "n1", "port": "key", "title": "Customer email", "description": "Email to look up their support history.", "examples": ["a@b.com", "c@d.com"] } ``` ```json title="What that entry contributes to the published contract" { "type": "string", "title": "Customer email", "description": "Email to look up their support history.", "examples": ["a@b.com", "c@d.com"] } ``` `name`, `node_id` and `port` decided where this property comes from; `title`, `description` and `examples` only decorate it. ### Related rules - Names: MAN-1, MAN-6, MAN-15 - Referenced by: MAN-1, MAN-6, MAN-15, MAN-20 ## MAN-3 — However the manifest was written, one validator refuses a bad entry *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* An authoring surface can offer only the ports it knows are valid; an API caller submits whatever it likes. Both land on the same stored shape, so the refusal has to live in one place. ### The rule > **Normative.** This is the rule. > > 1. A manifest may be written by more than one door: an authoring surface that offers the author a filtered choice of ports, or an API mapping a caller-supplied contract. > > 2. Both write the same stored shape and are refused by the same workflow validation, so an entry naming an unknown node, or a port the instance hides, is refused whichever door submitted it. > > 3. Exposure is resolved the same way in both cases: an instance-level port setting overrides the port's declared default, and a port with neither is exposed. ### Related rules - Names: MAN-1, MAN-20 - Referenced by: MAN-1, MAN-20 ## MAN-5 — The published contract carries structure and nothing else *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* What a caller is told about an input is built from the bound port's own schema, reduced to the keys that describe the value's shape. Annotations are re-attached when the contract is read, not stored in it. ### The rule > **Normative.** This is the rule. > > 1. Each contract fragment is built from the schema of the port its entry binds, and reduced to exactly these keys: `type`, `enum`, `format`, `default`, `required`, `properties`, `items`, `minimum`, `maximum`, `minLength`, `maxLength` and `pattern`. > > 2. Every other key is dropped, at every depth, recursing into each declared property and into an item schema. > > 3. Annotations such as `title`, `description` and `examples`, and any extension key, therefore never reach the stored contract; annotations are re-attached only when the contract is read with annotation requested. ## What it means A contract fragment is a reduction of the bound port's own schema down to a fixed set of structural keys — the shape of the value, never the words around it. The strip is not a top-level filter: it recurses into every declared property and into an item schema, so an annotation or an extension key buried two levels inside an object's own properties is dropped exactly as one sitting at the top would be. A non-annotation key at that same depth — `maxLength`, `pattern` — survives, because the rule drops a closed set of keys, not "annotations" as a category. Author-facing words are never baked into the stored snapshot, even when the caller asks nothing special: they are re-attached only when the contract is read with annotation explicitly requested (MAN-6). The stored contract itself stays the same either way. ## Example A port's own schema carries a title and two extension keys, one nested inside `properties` and one inside `items`. ```json title="A port's declared schema, annotated at every depth" { "type": "object", "title": "Payload", "x-exposed-by-default": false, "properties": { "inner": { "type": "string", "title": "Inner", "x-config-order": 3, "maxLength": 10 } }, "items": { "type": "string", "description": "An item.", "x-port-order": 2, "pattern": "^a" } } ``` ```json title="What the published contract keeps" { "type": "object", "properties": { "inner": { "type": "string", "maxLength": 10 } }, "items": { "type": "string", "pattern": "^a" }, "x-data-type": "json" } ``` Asking the same workflow for its contract with annotation requested fills the words back in, without changing what is stored: ```http title="Fetching the contract as stored" verdict="200 unannotated" GET /api/flowdrop/workflows/{workflow}/schema {"parameter_schema": {"properties": {"name": {"type": "string"}}}} ``` ```http title="Fetching the same contract with annotation requested" verdict="200 filled" GET /api/flowdrop/workflows/{workflow}/schema?annotated=1 {"parameter_schema": {"properties": {"name": {"type": "string", "title": "Text value", "description": "A plain text field."}}}} ``` ### Related rules - Names: MAN-6, MAN-7, MAN-21 - Referenced by: MAN-6, MAN-7, MAN-16, MAN-18, MAN-21 ## MAN-6 — The author's words win over the node's *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* A node's own annotation describes the port in general. The author describes what this workflow means by it, so the author's text is the one a caller sees. ### The rule > **Normative.** This is the rule. > > 1. Author-supplied metadata on a manifest entry is overlaid onto the contract fragment after the fragment has been reduced, and overrides any annotation the bound port declares. > > 2. The overlay applies to the input side only. > > 3. An empty value is dropped rather than overlaid, so blank author metadata does not erase the port's own. ## What it means A port's own schema can carry a title or a description written by whoever built the node type — a general description of what the parameter is. The author writing a manifest entry can say something more specific to this workflow, and when they do, their words are what a caller sees, overlaid onto the fragment after MAN-5 has already reduced it. The overlay applies only to the input side. The exception is what makes it safe to overlay unconditionally: an empty value is dropped rather than overlaid, so an author field left blank does not erase the port's own words with nothing. Filling it in later is still possible precisely because leaving it blank did not already win. ## Example The workflow declares one input, `name`, bound to a port whose node type has its own title for the same field. ```http title="The author already wrote a title for this input" verdict="200 kept" GET /api/flowdrop/workflows/{workflow}/schema?annotated=1 {"parameter_schema": {"properties": {"name": {"type": "string", "title": "Author Title"}}}} ``` ```http title="The author left the same field blank" verdict="200 filled" GET /api/flowdrop/workflows/{workflow}/schema?annotated=1 {"parameter_schema": {"properties": {"name": {"type": "string", "title": "Text value", "description": "A plain text field."}}}} ``` An authored title stands even when the bound port's own annotation disagrees; a blank one is filled from the port instead of publishing nothing. ### Related rules - Names: MAN-2, MAN-5 - Referenced by: MAN-2, MAN-5 ## MAN-7 — Only a flat default is published *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* A default is published so a caller knows what happens if they omit the input. Anything with nested structure is dropped rather than half-published, and the rest of the fragment survives the drop. ### The rule > **Normative.** This is the rule. > > 1. A `default` survives into the published contract when it is null, a scalar, or a collection one level deep whose every member is null or a scalar, an empty collection included. > > 2. A default of any other shape, including one with a nested value, is dropped and the drop is logged. > > 3. Dropping a default never affects the rest of the fragment: every other contract key of that entry is retained. ## What it means A default is published so a caller can know what happens without supplying the input, and that only works if the published value is exactly what the runtime would use. A scalar is always safe. A collection one level deep is safe too, as long as every member of it is itself null or a scalar — a flat list, or a flat map, both count. Anything deeper is dropped rather than half-published, because a caller reading a truncated nested default would be told the wrong thing with no way to notice. Dropping a default touches nothing else: every other key of that entry's contract fragment — its `type`, its `maxLength`, whatever else survived MAN-5's strip — is retained exactly as it was. ## Example ```json title="A scalar default" verdict="retained" { "type": "string", "default": "abc" } ``` ```json title="A one-level map whose members are scalar or null" verdict="retained" { "default": { "a": 1, "b": null } } ``` ```json title="A default nested one level too deep" verdict="dropped" { "type": "array", "default": { "nested": { "too": "deep" } } } ``` The third default is gone from the published fragment, but its `type` key is not: ```json title="What the third fragment publishes" { "type": "array", "x-data-type": "array" } ``` ### Related rules - Names: MAN-5 - Referenced by: MAN-5 ## MAN-8 — An entry that cannot be built is skipped, not fatal *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* One broken entry must not cost the workflow its whole contract, so the build drops it and carries on. ### The rule > **Normative.** This is the rule. > > 1. An entry that cannot be resolved (the node it names is missing, the node declares no such port) is skipped with a logged warning, and the remaining entries are still built. > > 2. An entry that is structurally malformed, missing or empty in `name`, `node_id` or `port`, is likewise skipped and never contributes to the contract. ## What it means A manifest can go wrong in two different ways, and neither costs the workflow the rest of its contract. An entry can be structurally malformed — its `name`, `node_id` or `port` missing or empty — in which case it never named anything to resolve in the first place. Or it can be well-formed but point nowhere real: a node that is not in the graph, or a port its node's own type never declared. Either way the entry is dropped and the build carries on with whatever else the manifest names. ## Example ```json title="An entry naming a node absent from the graph" verdict="skipped" { "name": "gone", "node_id": "ghost", "port": "key" } ``` ```json title="An entry naming a port its node's own type never declared" verdict="skipped" { "name": "gone", "node_id": "n1", "port": "nope" } ``` ```json title="An entry with no name at all" verdict="skipped" { "node_id": "n1", "port": "key" } ``` None of the three contributes a property to the built contract; a valid sibling entry elsewhere in the same manifest still would. ### Related rules - Names: MAN-14, MAN-17 - Referenced by: MAN-14, MAN-17 ## MAN-9 — A workflow used as a node cannot build its own contract forever *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* Workflows compose, so a contract build can walk into itself. The guard is per path, not per build, so an honest diamond still resolves. ### The rule > **Normative.** This is the rule. > > 1. When a workflow is used as a node inside another workflow, the contract build descends into it. > > 2. Re-entering a workflow already on the current path is refused: the entry resolves to an empty object schema and a warning is logged, rather than recursing. > > 3. Encountering the same workflow again as a sibling, not an ancestor, is allowed and builds normally. ## MAN-10 — A declared input name wins over an internal node-keyed key *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* The two input shapes can collide on the same key. The declared manifest is the workflow's public face, so it decides. ### The rule > **Normative.** This is the rule. > > Where a launch payload key matches both a declared input name and the internal node-keyed pass-through shape, the declared name wins and the value is resolved through the manifest. ## What it means The launch-input manifest names inputs; the graph names nodes. Nothing stops the two namespaces from colliding — an author can perfectly well have a declared input called `shadow` and, separately, a node whose id is also `shadow`. Resolution has to pick one meaning for a payload key sent under that name, and it is not the obvious "whichever happens to match the node" answer: the manifest is checked first, and if it has an entry for the key, that entry wins outright. The node-keyed reading (MAN-12) is only ever tried for a key the manifest does not declare. The consequence is what makes the rule worth stating: a value sent under the colliding name is delivered to wherever the *manifest entry* points, which may be a different node and port entirely from the one sharing its name. ## Example The workflow declares one input, `shadow`, bound to node `target.1`'s `payload` port. A node in the graph happens to be named `shadow` too. ```json title="A value sent under the declared name" { "shadow": { "k": "v" } } ``` ```json title="Where the launch delivers it" { "target.1": { "payload": { "k": "v" } } } ``` The value lands on `target.1`, not on a node called `shadow` — the manifest entry decided the mapping before the node-keyed reading was ever tried. ### Related rules - Names: MAN-11, MAN-12 - Referenced by: MAN-11, MAN-12 ## MAN-11 — A declared input delivers to the port its entry binds *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* The manifest is a mapping, and this is the mapping it performs. ### The rule > **Normative.** This is the rule. > > A value supplied under a declared input name is delivered to the workflow's initial data under the node identifier and port name that input's manifest entry binds. ### Related rules - Names: MAN-10, MAN-17 - Referenced by: MAN-10, MAN-17 ## MAN-12 — The node-keyed input shape is internal and unreachable from outside *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* Addressing a node and port directly bypasses the manifest entirely, which is the whole of the launch boundary. It stays available to the system's own callers and to nobody else. ### The rule > **Normative.** This is the rule. > > 1. The node-keyed input shape, addressing a node identifier and port name directly, is an internal contract. > > 2. Every externally reachable launch door refuses it: a caller supplies declared input names, and nothing else. ## What it means The node-keyed shape — addressing a node identifier and port name directly — is how initial data is built once a value has already been resolved. It is a real, working shape: read it back and it comes out unchanged, because nothing about it needs the manifest to make sense. That is exactly why it has to be walled off: a shape that "just works" internally is also a shape a caller could send by hand, and if a launch door ever let it through, a caller who had learned a workflow's internal node ids could seed initial data the author never declared as an input at all. So every externally reachable launch door treats a node-keyed key as unknown — not as a second, quieter way in. ## Example ```json title="A node-keyed body arriving where declared input names are expected" verdict="refused" { "chat_input.1": { "message": "injected" } } ``` ```json title="The same shape read back where it belongs, unchanged" { "other.1": { "port_a": 1 } } ``` The first is what a caller gets for reaching a launch door with a node id instead of a declared input name. The second is the identical shape, handled by the implementation's own resolution of already-trusted, node-keyed data — the same notation, admitted only on the side of the boundary that never sees an outside caller. ### Related rules - Names: MAN-10, MAN-13 - Referenced by: MAN-10, MAN-13 ## MAN-13 — An undeclared launch key is refused, and its value never delivered *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* Silently ignoring an input a caller believed in is the worst of the options: the run proceeds with the caller's intent missing and nothing said. ### The rule > **Normative.** This is the rule. > > 1. A launch key that is not a declared input name is refused, and the refusal names the offending key and the workflow. > > 2. The value is never written into the resolved initial data, whether the key was refused or merely reported. ## What it means Naming the offending key and the workflow in the refusal is the easy half of this rule. The half worth stating on its own is the second sentence: the value never reaches the resolved initial data, and that holds regardless of how the key was handled. An implementation is free to choose how loudly it objects to an unrecognised key — refuse the whole request outright, or merely log it and carry on — but "carry on" must never mean "and pass the value through anyway." A caller cannot smuggle an undeclared key past the check by relying on a tolerant mode to let the value slip in unremarked; tolerant only ever means the key is dropped more quietly, never that it is honoured. ## Example ```json title="A key that is not a declared input name" verdict="refused" { "nmae": "typo" } ``` The refusal names both the offending key and the workflow it was refused by. Where an implementation instead only logs and continues, the resolved result for that same body is empty — the key contributes nothing either way. ### Related rules - Names: MAN-12, MAN-14, MAN-15 - Referenced by: API-2, MAN-12, MAN-14, MAN-15 ## MAN-14 — Strictness polices the caller, never the stored manifest *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* A caller can fix the key they sent. They cannot fix a manifest entry someone else stored, so failing their launch over it helps nobody. ### The rule > **Normative.** This is the rule. > > 1. A malformed manifest entry (one missing a `name`, a `node_id` or a `port`, or carrying an empty one) is dropped with a warning while resolving launch inputs, including where undeclared caller keys are refused. > > 2. Strictness applies to what the caller supplied, not to what the workflow stored. ## What it means MAN-13 refuses a caller for sending a key the manifest never declared, and it is tempting to read strictness as one blanket setting: reject anything the resolution step cannot make sense of. This rule draws the line strictness is not allowed to cross. A malformed manifest entry — one an author (or an earlier version of the workflow) left with a missing or empty `name`, `node_id` or `port` — is not the caller's mistake, and refusing the caller's launch over it fixes nothing: the caller cannot edit a manifest they do not own. So a launch that is otherwise as strict as it gets still drops a broken entry with a warning and keeps resolving everything else, exactly as a lenient resolution would. ## Example The workflow's manifest entry for `message` names a node, `deleted.1`, no longer in the graph. ```json title="A value sent for a declared input whose target node is gone" verdict="dropped" { "message": "hello" } ``` The same happens when an entry's `node_id` or `port` is empty outright: ```json title="Values sent for two entries with an empty node id and an empty port" verdict="dropped" { "a": 1, "b": 2 } ``` Neither launch is refused; both drop the broken entry's value and resolve whatever else the manifest can still make sense of. ### Related rules - Names: MAN-8, MAN-13 - Referenced by: MAN-8, MAN-13 ## MAN-15 — Launch inputs are checked in one fixed order and answered once *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* A caller gets one problem to fix at a time, in the order that makes the next attempt useful: what you sent that does not exist, then what you did not send, then what is wrong with what you sent. ### The rule > **Normative.** This is the rule. > > 1. Launch inputs are checked in three stages (keys that are not declared inputs, then required inputs that are absent, then the values themselves), and the check stops at the first stage that fails, returning one message. > > 2. Every message ends by naming the inputs the workflow does accept, or by stating that it accepts none. > > 3. An input counts as required when its manifest entry says so, or when its name appears in the published contract's top-level required list; either source is sufficient, so a manifest written before the contract was last rebuilt is still enforced. ## What it means A launch can be wrong in more than one way at once, and the three stages are ordered so a caller is never told about a problem fixing the first one would have made moot. A key that is not a declared input name is checked before whether a required input is missing, which is checked before the values themselves — and the check stops the moment a stage fails. A caller who both misspelled an input's name and left a required one out only hears about the misspelling: the missing-required message would be about a state the caller has not actually reached yet, since fixing the typo might supply it. Every message, at whichever stage it stops, ends the same way: it names the inputs the workflow accepts, or says it accepts none. And "required" has two sources, not one — a manifest entry can say so directly, or the input's name can simply appear in the published contract's top-level required list. Either is enough on its own, so a manifest written before that contract was last rebuilt is still enforced through the newer source. ## Example The workflow declares one input, `message`, required. ```json title="A misspelled key, sent alongside a missing required input" verdict="refused" { "mesage": "typo" } ``` ```json title="No unknown keys, but the required input never arrives" verdict="refused" {} ``` The first refusal reports only the unknown key and the inputs the workflow accepts; it says nothing about `message` being missing, even though it is. The second, sent once the typo is gone, is the first point at which the missing-required stage gets to speak. ### Why Recorded under OPEN-16. ### Related rules - Names: MAN-2, MAN-13, MAN-16 - Referenced by: API-2, MAN-2, MAN-13, MAN-16 ## MAN-16 — The value check enforces type and enum, and declares the rest *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* A published contract says more than the launch boundary enforces. That is a real distinction and worth stating, so a caller does not read an unenforced keyword as a guarantee. ### The rule > **Normative.** This is the rule. > > 1. The per-value stage of the launch check enforces exactly two things from a contract fragment: `type` and `enum`. > > 2. Every other published key (`minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, and nested `properties` or `items`) is declared in the contract and not enforced here, as is a `type` that is not a single string. > > 3. The top-level required list is the one declared key that is enforced, and it is enforced by the earlier missing-input stage. > > 4. Values are never coerced. > > 5. Every structured type collapses to a single check that the value is a collection. > > 6. The types meaning "any value" waive the type check entirely, matching every value including null and a collection, while still honouring `enum`; they are declared no-constraint types, and a boundary that refused what the runtime accepts would give a port's declared type two meanings. > > 7. `enum` compares by identity, so a number and its string spelling are different values. > > 8. Violations across several inputs are aggregated into one refusal, and an input with no contract fragment skips the value check. ## What it means A published contract fragment says more than the boundary checks. Past the required list — enforced earlier, by the stage that checks presence — the value stage reads exactly two keys: `type` and `enum`. `minLength`, `maxLength`, `pattern`, `minimum`, nested `properties` and `items` are all published, all declared, and none of them is enforced here. A caller who violates every one of them at once still gets through this stage. A type meaning "any value" is not a stricter case of `type` — it waives the type check entirely, matching a collection, a scalar or null alike, while still honouring `enum` if one is published. Every structured type collapses to one check: is the value a collection at all. Nothing is coerced, and `enum` compares by identity, so a number and its string spelling are different values. An input with no contract fragment skips the value check outright, and violations across several inputs are aggregated into one refusal rather than answered one at a time. ## Example The workflow declares three inputs against three fragments: `count` against `{"type": "integer"}`, `direction` against `{"type": "string", "enum": ["asc", "desc"]}`, and `variables` against no `type` at all. ```json title="A string where the fragment declares an integer" verdict="refused" { "count": "five" } ``` ```json title="A value outside the fragment's enum" verdict="refused" { "direction": "up" } ``` ```json title="A structured value sent to a port with no declared type" verdict="accepted" { "variables": { "live_state": "{\"a\":1}" } } ``` A fourth input, `code`, is published against `{"type": "string", "minLength": 8, "maxLength": 3, "pattern": "^\\d+$", "minimum": 5}` — a fragment no single value could satisfy. It still launches: ```json title="A value violating every other declared constraint at once" verdict="accepted" { "code": "abcdef" } ``` ### Why Recorded under OPEN-16. ### Related rules - Names: MAN-5, MAN-15, MAN-21 - Referenced by: MAN-15, MAN-21 ## MAN-17 — Declared outputs are collected by the same mapping, in reverse *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* The output side of the manifest, with one distinction that matters: a node that produced null produced something, and is not the same as a node that produced nothing. ### The rule > **Normative.** This is the rule. > > 1. Each declared output name resolves to the result the bound node produced on the bound port. > > 2. Presence is decided by whether the key exists, so a null value counts as produced and is returned. > > 3. An output whose node produced nothing at all is dropped from the result with a warning. > > 4. A malformed output entry is skipped. ## What it means Presence is decided by whether the key exists, not by whether the value is truthy. A node that produced `null` on its bound port produced something, and that null is returned under the declared output name. That is different from a node whose bound port never appears in what it produced at all — that case is dropped from the result, with a warning, because there is nothing to return. A malformed entry — one missing its name, its node id or its port — is skipped without a warning: it never named a real output in the first place, so there is nothing to warn about. ## Example The workflow declares two outputs bound to the same node: `answer`, on its `text` port, and `hidden`, on a `debug` port the node did not produce this run. ```json title="What the bound node produced" verdict="retained" { "llm.1": { "text": null } } ``` ```json title="The declared outputs resolved against it" verdict="retained" { "answer": null } ``` The `debug` port never appears in `llm.1`'s results at all, so `hidden` is dropped from the outputs, with a warning naming it. A malformed entry — an empty name, an empty node id, or an empty port — is skipped the same way, but without the warning the dropped case above gets. ### Related rules - Names: MAN-8, MAN-11 - Referenced by: MAN-8, MAN-11 ## MAN-18 — The contract is rebuilt when the manifest changes, and versioned when it is *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* Rebuilding is triggered by the manifest, not by the schemas underneath it, so a caller reads a contract that was published deliberately rather than one that drifts. ### The rule > **Normative.** This is the rule. > > 1. A workflow's contract is rebuilt when its declared input or output manifest differs from the stored one, or when a rebuild is forced. > > 2. A change inside a bound port's own schema does not trigger a rebuild, so the published contract stays as it was until something else rebuilds it. > > 3. A side with no declared entries stores no schema for that side. > > 4. On rebuild the contract version is bumped: from an all-zero version any of a major, minor or patch bump yields the first released version; otherwise the named digit is incremented and the lower digits reset. > > 5. A rebuild that bumps nothing writes the schemas and leaves the version untouched. > > 6. A build that fails logs an error, preserves the previously published contract, and does not prevent the workflow from being saved. ## What it means The trigger is the port list, not what changed underneath it. A bound port's own schema can change entirely — a fragment's `type`, its `minLength`, anything a node type declares — and the published contract does not move, because nothing rebuilds it until the input or output port list itself differs from the one already stored, or a rebuild is forced. A caller reading the published contract is reading what was last published, not a live reflection of the ports it names. A side with no declared entries stores no schema for that side at all — there is nothing to build. And a build that fails leaves the previously published contract in place: the workflow still saves, the failure is recorded, and nothing about the failed rebuild reaches the caller. ## Example ```json title="An input port list identical to the one already published" verdict="skipped" [ { "name": "in", "node_id": "n1", "port": "p1" } ] ``` ```json title="A workflow declaring no output ports at all" [ ] ``` ```json title="What is stored for that side" verdict="none" null ``` ### Related rules - Names: MAN-5, MAN-21 - Referenced by: MAN-21, STORE-8 ## MAN-19 — A workflow declared asynchronous cannot be launched and waited on *GR-MAN (Part I) · level: core · profiles: storage-api · added in 1.0* Asking to wait for a result the workflow has already said it will not deliver inline is a mistake worth catching before anything runs. ### The rule > **Normative.** This is the rule. > > A launch requesting a synchronous wait against a workflow declared asynchronous is refused before any execution is created, so no run is started and nothing is left behind. ## MAN-20 — The API maps a client interface onto the stored manifest *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* What an editor calls an interface entry and what the server stores as a manifest entry are the same thing under two vocabularies. This is the mapping, including what it deliberately ignores. ### The rule > **Normative.** This is the rule. > > 1. A workflow's API accepts an `interface` object and maps it onto the stored input and output manifests. > > 2. An entry's client-side identifier becomes the server-side input name; renaming either is a breaking change for callers. > > 3. An entry carries exactly one binding, whose node and port identifiers become the entry's `node_id` and `port`; an entry carrying more than one binding is refused with 400 and nothing is stored. > > 4. An entry carrying no binding is a client-side draft: it is skipped, not stored, and not an error. > > 5. A declared data type, a schema, a default value and free-form metadata on an entry are ignored on write; the type and schema are derived server-side from the bound port, and the other two have no server representation. > > 6. Input-side author metadata (the entry's display name, description, examples and required flag) round-trips as author-written manifest metadata does; an output entry carries only its name and binding. > > 7. The mapped manifests are applied before the workflow is validated, so whether a named node or port exists is decided by workflow validation and refused with 422; the API's own 400s cover the entry's shape only. ## What it means An `interface` entry can only ever name one binding. An entry naming two is refused outright, before anything else about it is even looked at — a shape failure, not a decision about whether the node or port it names exists. An entry naming none is not a failure at all: it is a client-side draft, quietly skipped, and never stored. That shape check happens before the workflow is validated, so it answers a different question than validation does. A binding whose node or port is made up entirely is not caught here — it is caught by the same check that would catch it on a directly-stored port list, and answered with a different status. The API's own refusals stop at the entry's shape; whether what it points to is real is somebody else's answer. ## Example ```http title="An entry naming the same binding twice" verdict="400 refused" POST /api/flowdrop/workflows {"name": "Over-bound", "nodes": [ … ], "interface": {"inputs": [ {"id": "numbers", "bindings": [ {"nodeId": "calc1", "portId": "values"}, {"nodeId": "calc1", "portId": "values"} ]} ]}} ``` ```http title="An entry naming no binding, alongside one that does" verdict="201 stored" POST /api/flowdrop/workflows {"name": "Draft entry", "nodes": [ … ], "interface": {"inputs": [ {"id": "draft", "bindings": []}, {"id": "numbers", "bindings": [{"nodeId": "calc1", "portId": "values"}]} ]}} ``` ```http title="An entry whose binding names a node that does not exist" verdict="422 refused" PUT /api/flowdrop/workflows/{workflow} {"name": "Ghost", "interface": {"inputs": [ {"id": "numbers", "bindings": [{"nodeId": "ghost_node", "portId": "values"}]} ]}} ``` The draft entry is not stored either, but silently: the response's `interface.inputs` names only `numbers`. ### Related rules - Names: MAN-2, MAN-3, MAN-21 - Referenced by: STORE-14, MAN-3, MAN-21 ## MAN-21 — A contract entry states the port's lane as well as its schema *GR-MAN (Part I) · level: extended · profiles: storage-api · added in 1.0* A port's lane and its JSON Schema type are two different vocabularies. Answering one with the other made a contract entry contradict the port it was bound to. ### The rule > **Normative.** This is the rule. > > 1. An interface entry states its bound port's lane and its structural schema separately: the lane is the port's declared or derived data-type lane, and the schema is the JSON Schema fragment. > > 2. The lane is never the fragment's `type`. > > 3. It is resolved once, when the contract is built, against the vocabulary the implementation actually serves, and is pinned into the stored fragment, so a reader sees the lane the workflow was published against rather than one that could drift since. > > 4. Reading the contract only reads that pin; a stored contract built before the pin existed falls back to the lane derivable from the fragment's type, which is never wider than the truth, and self-corrects on the workflow's next rebuild. > > 5. The schema emitted alongside it is the structural contract only: the pinned lane, the title, the description, the examples and property-level required flags are stripped on the way out, because the entry states each of them itself. > > 6. None of this changes what a caller must pass: the launch check reads `type` and `enum` from the stored contract and never consults this projection. ## What it means A port's lane and its JSON Schema type are two different vocabularies, and they diverge exactly where a port carries a domain-specific meaning over an ordinary structural shape: a list of conversation messages is, structurally, just an array. Reading the schema's `type` and calling that the lane would answer `array` for a case the lane can name precisely. The lane is resolved once, when the contract is built, and pinned into the stored fragment — it is not re-derived on every read. A contract built before the lane existed has no pin to read, so it falls back to the lane the schema's `type` implies, which is never wider than the truth, and corrects itself the next time the contract is rebuilt. The schema published alongside an entry is stripped of the lane, the title, the description and the examples on the way out, because the entry states each of those itself; none of it changes what the launch check reads, which is still `type` and `enum` on the stored contract, never this projection. ## Example A node produces two outputs: one declares a `messages` lane over a JSON Schema `array`, the other declares nothing over an `object`. ```http title="Two outputs whose declared lane is not their JSON Schema type" verdict="201 stored" POST /api/flowdrop/workflows {"name": "Lane WF", "nodes": [ … ], "interface": {"outputs": [ {"id": "out_list", "bindings": [{"nodeId": "msg1", "portId": "messages"}]}, {"id": "out_one", "bindings": [{"nodeId": "msg1", "portId": "message"}]} ]}} ``` ```json title="What the published contract reports for those two ports" { "outputs": [ {"id": "out_list", "dataType": "messages", "schema": {"type": "array"}}, {"id": "out_one", "dataType": "json", "schema": {"type": "object"}} ] } ``` The node type's own fragment carries more than the pin keeps: ```json title="A node type's own schema fragment for a bound port" { "type": "array", "title": "Messages", "x-data-type": "messages" } ``` ```json title="What the built contract stamps onto that fragment" verdict="pinned" { "type": "array", "x-data-type": "messages" } ``` ### Related rules - Names: MAN-5, MAN-16, MAN-18, MAN-20 - Referenced by: MAN-5, MAN-16, MAN-18, MAN-20 --- # GR-LANG — LANG (Part I) ## LANG-1 — Extraction engines query the context; transformation engines bind it *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* The engines an author can choose split into two families, and the split decides what the surrounding data means to the expression they write. ### The rule > **Normative.** This is the rule. > > 1. An extraction engine treats the evaluation context as data to be queried by a path. > > 2. A transformation engine treats the same context as a map of variables the expression may reference by name. ### Related rules - Referenced by: LANG-4 ## LANG-2 — A node's expression engine must be one the implementation provides *GR-LANG (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node that embeds an expression selects its engine through the reserved `engine` parameter, whose permitted values are exactly the engine identifiers the implementation provides. > > 2. A workflow naming an engine outside that set is refused when it is saved and again when the parameter is resolved. > > 3. An unrecognised engine identifier that reaches evaluation by some other route fails before any expression is evaluated. ## LANG-3 — An empty expression is not an error *GR-LANG (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An empty expression is valid: it passes validation, and it produces a defined result rather than a failure. > > 2. A mapper output whose expression is empty is null, whatever the engine. > > 3. An extractor whose path is empty returns its input unchanged, reports success, and reports no match. ## LANG-4 — A context that is not a map reaches a transformation engine as `data` *GR-LANG (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > Where the evaluation context is not a map, a transformation engine receives it bound to the single variable `data`. ### Related rules - Names: LANG-1 ## LANG-6 — Expression-language evaluation fails loudly *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > An expression-language expression that cannot be parsed, that cannot be evaluated, or that references a name absent from the context fails: it raises an error and yields no value. ### Related rules - Names: LANG-7, LANG-8, LANG-9 - Referenced by: LANG-10 ## LANG-7 — Twig yields an escaped string and treats a missing variable as empty *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A Twig template always produces a string, HTML-escaped unless the value is piped through `raw`. > > 2. A variable absent from the context renders as the empty string rather than failing. > > 3. A template that cannot be compiled, and a template that fails while rendering, both fail with an error saying which of the two occurred. ### Related rules - Referenced by: LANG-6, LANG-13, LANG-26 ## LANG-8 — A property path that cannot be read yields null *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* Property paths trade diagnosis for calm: nothing raises, and a reader cannot tell a missing value from a stored one. ### The rule > **Normative.** This is the rule. > > 1. Property-path evaluation never raises. > > 2. A path that cannot be read yields null, which is indistinguishable from a path that reads a stored null. ### Related rules - Referenced by: LANG-6, LANG-9, LANG-24, LANG-20 ## LANG-9 — A JSONPath query never raises; it falls back to the caller's default *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. JSONPath evaluation never raises. > > 2. A query that is well-formed but matches nothing, and a query that cannot be compiled or cannot be run, both yield the default the caller supplied, null where none was supplied. > > 3. The returned value does not tell the two apart. ### Related rules - Names: LANG-8 - Referenced by: LANG-6 ## LANG-10 — Expression-language validation is a syntax check only *GR-LANG (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Validating an expression-language expression checks its syntax, with every identifier the expression mentions treated as declared. > > 2. A reference to a name that will not exist when the workflow runs therefore validates successfully and fails at run time. ### Related rules - Names: LANG-6 ## LANG-11 — Property-path validation accepts everything evaluation can resolve *GR-LANG (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Validating a property path parses it with the same parser evaluation uses, after the same dot-to-bracket normalization. > > 2. Every path evaluation could resolve therefore validates, and only a path that cannot be parsed at all (an unclosed bracket, an empty segment) is rejected. ### Related rules - Names: LANG-17 ## LANG-12 — A path that does not begin with `$` always passes JSONPath validation *GR-LANG (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Under the jsonpath engine, validation checks a `$`-rooted query. > > 2. A path that does not begin with `$` validates unconditionally. ### Related rules - Names: LANG-14 ## LANG-13 — Twig validation is a compile check only *GR-LANG (Part I) · level: extended · profiles: storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Validating a Twig template compiles it. > > 2. A template that compiles but fails only while rendering validates successfully. ### Related rules - Names: LANG-7 ## LANG-14 — A leading `$` decides JSONPath from property path *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A path is a JSONPath query if and only if the string, once trimmed of surrounding whitespace, begins with `$`. > > 2. Every other path is a property path. ### Related rules - Referenced by: LANG-12, LANG-23 ## LANG-15 — A single JSONPath match is unwrapped unless the path selects many *GR-LANG (Part I) · level: extended · profiles: runtime · added in 1.0* Arity is read off the path, not off the result, so a query written to select many keeps a list shape even on the day it matches exactly one thing. ### The rule > **Normative.** This is the rule. > > A JSONPath query that returns exactly one match yields that match rather than a one-element list, unless the path is written to select many, containing a wildcard, a filter or a slice, in which case the result stays a list even when only one item matched. ### Related rules - Referenced by: LANG-23 ## LANG-16 — A string context is parsed as JSON where it parses *GR-LANG (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where the evaluation context is a string, it is parsed as JSON and the parsed value is what the path queries. > > 2. A string that is not valid JSON is used as-is. > > 3. The string `null` parses to null, like any other JSON document. ## LANG-17 — Dot and bracket path segments are interchangeable everywhere *GR-LANG (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A property path may be written with dot segments, bracket segments, or a mixture of the two. > > 2. An implementation normalizes them to a single form before parsing, and every place it reads a property path resolves the two spellings identically. ### Related rules - Referenced by: LANG-11 ## LANG-18 — A trigger mapping value can be escaped as a literal *GR-LANG (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. In a trigger mapping, a value prefixed `literal:` or wrapped in matching single or double quotes is taken as the literal string it spells, and is not extracted from the context. > > 2. The escape applies to trigger mappings only. ## LANG-19 — Extracting all matches for a property path always yields a list *GR-LANG (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > Extracting all matches for a property path yields a list: a single result is wrapped in a one-element list, and a null result or no match yields the empty list. ## LANG-21 — A failed mapper expression fails the node and names the port *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > When the expression for a mapper output fails, the node fails, the failure names the output port whose expression failed, and no partial output map is produced. ### Related rules - Names: LANG-29 - Referenced by: LANG-29 ## LANG-22 — An extraction error fails the node and never takes the default *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. When an extraction fails (a path the engine cannot evaluate, an engine failure), the node fails and the failure routes to the error edge, naming the path and the engine. > > 2. The configured default is not substituted on an error; it applies only where the expression evaluated cleanly and found nothing. ### Related rules - Names: LANG-24, LANG-29 - Referenced by: LANG-24, LANG-29 ## LANG-23 — Only a `$`-rooted JSONPath query unwraps a list to its first match *GR-LANG (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An extractor reduces a list result to its first match only where its engine is jsonpath, the path is `$`-rooted, and extract-all is off. > > 2. A property-path engine delivers a list result whole, even for a `$`-prefixed path, and a jsonpath engine that falls back to property-path resolution never unwraps. > > 3. The extractor's reported JSONPath flag reports the `$` prefix alone and is independent of the engine in force. ### Related rules - Names: LANG-14, LANG-15 ## LANG-24 — A null extraction is a miss, not a failure *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* Extraction engines cannot tell a stored null from no match at all, so the extractor treats both the same way, and only null, never a falsy value. ### The rule > **Normative.** This is the rule. > > 1. An extraction that evaluates cleanly and yields null is a miss: the configured default is delivered, the extractor reports success as false (meaning no non-null match was found), and the node succeeds. > > 2. Only null is a miss; false, zero and the empty string are delivered as found. ### Related rules - Names: LANG-8, LANG-22 - Referenced by: LANG-22, LANG-29 ## LANG-25 — Shaper sentinels resolve before any engine is consulted *GR-LANG (Part I) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A shaper source is checked for sentinels before its engine is consulted. > > 2. `_NOW_` yields the current timestamp in ISO 8601 form; `_NULL_` yields null; `_EMPTY_ARRAY_` yields the empty list; `_EMPTY_OBJECT_` yields the empty object; and a source prefixed `_LITERAL:` yields the rest of the string verbatim. > > 3. A source that does reach the engine and fails there fails the node, and the failure names the source expression and the engine. ### Related rules - Names: LANG-29 - Referenced by: LANG-29 ## LANG-26 — A prompt template that fails to render fails the node *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > When a prompt template fails to render (it cannot be compiled, it cannot be loaded, or it fails while rendering), the node fails and the failure routes to the error edge, preserving the original cause. ### Related rules - Names: LANG-7, LANG-29 - Referenced by: LANG-29 ## LANG-27 — A switch compares its value; it does not evaluate it *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* Despite the parameter's name, a switch gateway runs no expression engine. The value is matched against each branch as it stands. ### The rule > **Normative.** This is the rule. > > 1. A switch gateway does not evaluate its `expression`. > > 2. The value is compared unchanged against each branch's `value` in declaration order, with no coercion: a branch matches only where the two are of the same type as well as equal. > > 3. The first matching branch is the sole active branch, and its name is what the gateway reports as active. > > 4. Where no branch matches, the branch named by `default_branch` is active; where no branch matches and no usable default is named, the node fails. ## LANG-28 — A condition node's operators are a closed set, and case folding spares the pattern *GR-LANG (Part I) · level: core · profiles: storage-api, runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A condition node compares with exactly six operators (`equals`, `not_equals`, `contains`, `starts_with`, `ends_with` and `regex`) declared as an enumeration. > > 2. A workflow naming an operator outside that set is refused when it is saved and again when the parameter is resolved, and an operator that reaches execution outside the set fails the node. > > 3. A pattern that cannot be compiled fails the node when it runs. > > 4. Where the comparison is case-insensitive it is performed case-insensitively; the pattern itself is never rewritten, so a regular expression keeps its case-sensitive character classes. ### Why Recorded under OPEN-19. ## LANG-29 — An expression error fails the node; an empty result does not *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* One policy across every node that evaluates an author's expression, so a broken expression is visible and routable rather than silently absorbed. ### The rule > **Normative.** This is the rule. > > 1. An expression error (a path the engine cannot evaluate, a failure raised by the engine) fails the node, and the failure routes to the error edge. > > 2. This holds uniformly across every node that evaluates an author's expression. > > 3. An expression that evaluates cleanly and finds nothing is not an error, so an extractor's default-on-no-match is unaffected. > > 4. Leniency toward genuine errors is only ever an explicit, opt-in node configuration, never the default. ### Why Recorded under OPEN-8. ### Related rules - Names: LANG-21, LANG-22, LANG-24, LANG-25, LANG-26 - Referenced by: LANG-21, LANG-22, LANG-25, LANG-26 ## LANG-20 — Testing whether a path exists *GR-LANG (Part I) · level: core · profiles: runtime · added in 1.0* The test answers for every path, including the two that have no obvious answer: one that cannot be walked, and one with nothing in it. ### The rule > **Normative.** This is the rule. > > 1. Testing a path for existence reports whether that path can be read, and answers for every path. > > 2. A path that cannot be read reports absence, a property path applied to a scalar among them, since a scalar has no property to read (LANG-8). > > 3. The empty path consumes no segments and therefore denotes the evaluation context itself, which is always present, so the empty path reports presence. ### Related rules - Names: LANG-8 --- # RT-CMP — CMP (Part II) ## CMP-1 — Compilation is one operation with one failure surface *RT-CMP (Part II) · level: core · profiles: runtime · added in 1.0* Compiling a stored workflow produces the executable plan in a fixed stage order. A caller that hands over an invalid workflow sees one kind of failure, whichever stage detected it. ### The rule > **Normative.** This is the rule. > > 1. Compiling a workflow produces a dependency graph over all of its edges, an execution graph of the nodes that will run, and, for each of those nodes, the mapping the runtime executes it through. > > 2. The stages run in this order: structure preconditions, dependency graph, cycle detection, tool wiring, execution graph, node mappings. > > 3. A failure in any stage is reported to the caller as a single compilation failure, whose message carries the detecting stage's message prefixed exactly once and whose underlying cause remains reachable. ## What it means Compilation runs six stages in a fixed order: structure preconditions, dependency graph, cycle detection, tool wiring, execution graph, node mappings. Wherever in that order something goes wrong, the caller sees one failure shape, not six, and its message names which stage caught it. The detail a fixed order does not make obvious on its own: the prefix is never doubled. A structure precondition (CMP-3) is itself already reported as a compilation failure — the cheapest stage runs first and reports in the same shape everything else does — so wrapping its message a second time could easily stack the prefix twice. It does not: compilation reports the outer message once, and the failure that was actually detected stays reachable underneath it rather than being discarded. ## Example A workflow whose only node has no declared type — a structure precondition, caught at the first stage: ```json title="A node with no declared type" verdict="given" { "id": "wrap_probe", "nodes": [{ "id": "node1" }] } ``` ```json title="What compilation reports" verdict="reported" "Workflow compilation failed: Node node1 must have a type" ``` The failure reachable underneath that message is `Node node1 must have a type` — the detecting stage's own message, present once, not twice. ### Related rules - Names: CMP-3, CMP-5, CMP-6, CMP-9, CMP-11 - Referenced by: CMP-3, CMP-5, CMP-11 ## CMP-2 — Compilation always re-enriches node metadata from the live node types *RT-CMP (Part II) · level: core · profiles: runtime · added in 1.0* Stored node metadata is a cache, and an author-editable one. Compilation refreshes it from the node types themselves so a run can never be planned against stale or attacker-supplied metadata. ### The rule > **Normative.** This is the rule. > > 1. Compilation re-enriches every node's metadata from the live node type definitions before planning the run. > > 2. This holds at every entry point into compilation: no caller can compile a workflow whose stored node metadata is taken on trust. > > 3. The re-enriched workflow is normalized before it is compiled. ## What it means Stored node metadata is a cache the author can edit, not a source of truth. Every time a workflow is compiled, its node metadata is thrown away and rebuilt from the node types themselves, so whatever a stored record claims about a node — up to and including which processor runs it — never survives past compilation unless the live node type still agrees with it. ## Example A stored node claims a description that no longer matches its node type: ```json title="What the stored node claims" verdict="stale" { "node_type_id": "live_probe", "description": "Stale description" } ``` ```json title="What compilation records for it" verdict="fresh" { "node_type_id": "live_probe", "description": "Fresh description" } ``` The node type's own description wins; the stored claim never reaches the compiled workflow at all. ### Why Recorded under OPEN-19. ### Related rules - Names: SCH-29 ## CMP-3 — Structure preconditions are checked before anything is planned *RT-CMP (Part II) · level: core · profiles: runtime · added in 1.0* The cheapest checks run first, so a workflow that cannot possibly execute is rejected before any graph work happens. ### The rule > **Normative.** This is the rule. > > 1. Before any graph is built, compilation checks, in order: the workflow has an identifier; it has at least one node; and then, per node in definition order, that the node has an identifier and that it has a type. > > 2. The first failure refuses compilation, and the failure names which precondition failed and, where the failure is a node's, which node. ## What it means The cheapest checks run first, and in a fixed order: the workflow's own identifier, then whether it has any node at all, then — per node, in definition order — that the node has an identifier and that it has a type. A workflow missing its own identifier is refused before any node is examined at all, whatever shape those nodes are in. Within the per-node pass, the first node in definition order that fails is the one named in the failure; a later node's problems are never reported instead. ## Example ```json title="A workflow with no identifier" verdict="given" { "nodes": [{ "id": "node1", "type": "text_input" }] } ``` ```json title="What compilation reports" verdict="refused" "Workflow must have an ID" ``` The same shape, with an identifier but a node missing its type: ```json title="A node with no declared type" verdict="given" { "id": "test_workflow", "nodes": [{ "id": "node1" }] } ``` ```json title="What compilation reports" verdict="refused" "Node node1 must have a type" ``` The message names the node — `node1` — not merely that some node failed. ### Related rules - Names: CMP-1 - Referenced by: CMP-1 ## CMP-4 — An edge's type is derived, never declared *RT-CMP (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The type of an edge is derived from the handles it connects. > > 2. An edge does not declare its own type, and a declared type on an edge is not authoritative. ### Related rules - Names: EDGE-5 ## CMP-5 — Only tool, loopback and agent-result cycles are legal *RT-CMP (Part II) · level: core · profiles: runtime · added in 1.0* Rejecting every other cycle is what makes the forward graph acyclic. Loop membership is defined by reachability sweeps over that acyclic graph, so loosening this rule would make loop extent ill-defined rather than merely permissive. ### The rule > **Normative.** This is the rule. > > 1. Cycle detection ignores tool-availability, loopback and agent-result edges. > > 2. A cycle formed only from those edge types is legal. > > 3. Any other cycle refuses compilation. > > 4. The graph that remains once the ignored edge types are removed is therefore acyclic, and rules that depend on that acyclicity may assume it. ## What it means Cycle detection does not walk the graph as stored — it first removes every tool-availability, loopback and agent-result edge, then checks what is left for a cycle. A cycle is legal exactly when it disappears once those edge types are taken out, whatever mix of edge types formed it. The one case a mix does not save: a cycle built entirely from ordinary connections has nothing for the removal to take out, so it always refuses compilation. ## Example The same two-node shape, back and forth, once with both edges ordinary and once with the return edge a loopback: ```json title="Two edges back and forth, both ordinary connections" verdict="refused" [ { "source": "node_a", "target": "node_b" }, { "source": "node_b", "target": "node_a" } ] ``` ```json title="The same shape, the return edge a loopback" verdict="legal" [ { "source": "iterator_1", "target": "node_b", "sourceHandle": "iterator_1-output-item" }, { "source": "node_b", "target": "iterator_1", "targetHandle": "iterator_1-input-loop_back" } ] ``` The first refuses compilation with `Circular dependency detected in workflow`; the second compiles, because removing the loopback edge before checking leaves nothing but a single forward connection. ### Related rules - Names: CMP-1, CMP-6 - Referenced by: CMP-1, CMP-6, ORC-7 ## CMP-6 — Which nodes reach the execution graph *RT-CMP (Part II) · level: core · profiles: runtime · added in 1.0* A loop head needs a forward entry edge: being reachable by loopback alone is not enough to be scheduled, even though every re-enterable node type carries a loopback port. ### The rule > **Normative.** This is the rule. > > 1. A node is excluded from the execution graph if its type is non-executable, if it is wired only as a tool provider, or if its only incoming edges are loopback edges. > > 2. A node with no incoming edges is included, and so is a node with no edges at all. > > 3. The exclusions are applied in that precedence. ## What it means Being wired to something real does not earn a node a place in the execution graph. A node whose type is marked non-executable is excluded regardless of what touches it — including an ordinary incoming connection from a node that does run — because the exclusion is decided from the node's own type before its wiring is considered at all. The same precedence protects the opposite case: a node with no edges at all, or none incoming, is included rather than treated as an orphan to drop. ## Example A record-only node wired downstream of a node that does run: ```json title="A record-only node with a real incoming connection" verdict="given" { "nodes": [ { "id": "input", "type": "text_input" }, { "id": "mynote", "type": "note" } ], "edges": [{ "source": "input", "target": "mynote" }] } ``` ```json title="What compilation records for it" verdict="excluded" { "id": "mynote", "reason": "non_executable" } ``` The upstream node is unaffected — only the record-only one is left out of the execution graph. ### Related rules - Names: CMP-5 - Referenced by: CMP-1, CMP-5 ## CMP-7 — Trigger dependencies displace data dependencies for ordering *RT-CMP (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a node has both trigger dependencies and data dependencies, its execution dependencies are its trigger dependencies alone. > > 2. Data dependencies order a node only when it has no trigger dependency. ### Related rules - Names: CMP-8 - Referenced by: CMP-8 ## CMP-8 — Execution order is a topological order, and no more than that *RT-CMP (Part II) · level: core · profiles: runtime · added in 1.0* Two nodes with no dependency between them may run in either relative order. An author who needs one before the other must say so with an edge. ### The rule > **Normative.** This is the rule. > > 1. The execution order is a topological order of the execution dependencies: a node never precedes a node it depends on. > > 2. The relative order of nodes with no dependency relation between them is not specified, and an author must not rely on it. ## What it means The order that comes out of compilation is a topological order and nothing more: a node runs only after everything it depends on, but two nodes with no dependency between them can come out in either relative order. An implementation may even produce the same order on every run without that constancy being anything an author may depend on — nothing about a node's identifier or its position in the stored workflow decides the order; only a wire does. Forcing one node to run before another that it does not otherwise need means adding an edge between them. ## Example One node, `root`, feeds two others, `zulu` and `alpha`, which share no dependency between themselves. One order the implementation produced: ```json title="One legal execution order" verdict="topological" ["root", "zulu", "alpha"] ``` `root` precedes both, as it must. The relative order of `zulu` and `alpha` is not guaranteed by this run or any other — only an edge between them, which this workflow does not draw, would fix it. ### Related rules - Names: CMP-7 - Referenced by: CMP-7 ## CMP-9 — Tool names are unique per consumer, checked at compile time *RT-CMP (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. For each node that consumes tools, the names of the tools wired to it must be unique across the flattened set of leaf tools it will see. > > 2. A collision refuses compilation. > > 3. Passthrough tools are exempt from this check and are checked at the consumer instead. ## What it means Two things about the check are easy to guess wrong. First, it is not scoped to the tools wired straight onto a node: the flattened set includes every tool reachable through any number of passthrough hops, so a collision two hops away is refused exactly as one on a direct wire would be, and a passthrough node's own name plays no part in it — only the leaf tools it forwards count, checked where they finally land. Second, the check is scoped to one node's own view, not to the workflow as a whole: the same name landing on two different nodes is not a collision at all. ## Example Two tools, reached by an agent through a passthrough box, whose labels look different but resolve to the same name: ```json title="Two tools reaching one node through a box" verdict="refused" { "nodes": [ { "id": "tool_a", "type": "http_request", "data": { "label": "Web Search" } }, { "id": "tool_b", "type": "http_request", "data": { "label": "web-search" } }, { "id": "box_1", "type": "toolbox", "data": { "label": "My Tools" } } ], "edges": [ { "source": "tool_a", "target": "box_1", "data": { "edgeType": "tool_availability" } }, { "source": "tool_b", "target": "box_1", "data": { "edgeType": "tool_availability" } }, { "source": "box_1", "target": "agent_1", "data": { "edgeType": "tool_availability" } } ] } ``` The same pair of labels causes no trouble at all once they sit on two different nodes instead of one: ```json title="The same tool name at two different consumers" verdict="accepted" { "nodes": [ { "id": "tool_a", "type": "http_request", "data": { "label": "Search" } }, { "id": "tool_b", "type": "http_request", "data": { "label": "Search" } } ], "edges": [ { "source": "tool_a", "target": "agent_1", "data": { "edgeType": "tool_availability" } }, { "source": "tool_b", "target": "agent_2", "data": { "edgeType": "tool_availability" } } ] } ``` ### Related rules - Names: CMP-10 - Referenced by: CMP-1, CMP-10 ## CMP-10 — A node with tools wired to it must be able to receive them *RT-CMP (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node that has tools wired to it must be of a type that accepts tools. > > 2. Wiring a tool to a node type that cannot consume tools refuses compilation. ### Related rules - Names: CMP-9 - Referenced by: CMP-9 ## CMP-11 — The compiled plan keeps each node's node type identity *RT-CMP (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. For each node it will execute, the compiled plan records both the node type the node declares and the processor selected to run it. > > 2. The runtime resolves a node's definition by node type identity. ### Related rules - Names: CMP-1 - Referenced by: CMP-1 --- # RT-ERR — ERR (Part II) ## ERR-1 — A node-level failure becomes an error output, not a thrown failure *RT-ERR (Part II) · level: core · profiles: runtime · added in 1.0* The error edge is a data channel, not an exception channel. A node that fails in a routable way still finishes; the verdict travels on its output. ### The rule > **Normative.** This is the rule. > > 1. A failure a node signals as a node-level error is converted into an error-status output for that node and does not propagate out of the node. > > 2. The node's lifecycle is reported as completed, not failed; it finished, and the error verdict rides its output. ## What it means A failure a node signals in a routable way never shows up as a failed node. The node's own lifecycle record reports it as completed — the same status a node that raised nothing at all would report — because the node did finish; only the value on its output carries the verdict. A reader expecting the node's own status to reflect the failure will read the trail backwards: `failed` is reserved for a failure that escapes the node altogether (ERR-3), never for one that converted cleanly. Where the node also has an error edge, ERR-7 governs what happens to that output next. ## Example A node raises an ordinary failure inside its own run. ```json title="The status trail broadcast for the node's own run" verdict="completed" ["running", "completed"] ``` The trail never reaches `failed`; that status is reserved for a failure that escapes the node entirely, not one converted to an output. ### Related rules - Names: ERR-3, ERR-7 - Referenced by: ERR-3, ERR-4, ERR-5, ERR-6 ## ERR-2 — Retryability is marked by presence, not by a boolean *RT-ERR (Part II) · level: extended · profiles: runtime · added in 1.0* The absence of the marker is load-bearing: the retry gate tests for the literal value true, so anything else (including an explicit false) means do not retry. ### The rule > **Normative.** This is the rule. > > 1. An error output that came from a retryable failure carries `error_retryable` set to `true`. > > 2. An error output from a non-retryable failure omits the key entirely rather than setting it to `false`. > > 3. Retry applies only where the value is literally `true`. ## What it means Retryability is not a boolean field with a default; it is a key that either exists or does not. A retry gate that treats it like an ordinary flag — `false` as an authoritative "no", any truthy value as a "yes" — reads this backwards. The gate here checks the value against the literal `true` and nothing else, so writing `false` on purpose changes nothing: the failure still does not retry, exactly as an absent key does not. ## Example The same node failing twice, once in a way the runtime marks retryable and once in a way it does not. ```json title="A failure the runtime marks retryable" verdict="true" {"error_retryable": true} ``` ```json title="The same shape of failure, unmarked" verdict="absent" {} ``` ### Related rules - Names: ERR-11 - Referenced by: ERR-6, ERR-11 ## ERR-3 — A failure that is not node-level fails the run *RT-ERR (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A failure that is not converted into an error output escapes the node: the node is recorded as failed, no output exists for it, no error edge is followed, and the run fails. > > 2. The failure reported to the caller preserves the original cause. ## What it means A failure that never becomes a routable error output is not a milder version of ERR-1's outcome — it is the escape hatch that has to exist for an error edge to mean anything at all. The node itself is recorded failed, and no output exists for it, so there is nothing an error edge could route to, however the workflow wires the node downstream: the run fails outright. What does carry forward is the original cause — the failure reported to the caller keeps it, rather than a generic message that discards where the failure actually came from. ## Example The same kind of node run as ERR-1, but with a failure the runtime does not convert. ```json title="The status trail broadcast for an escaping failure" verdict="failed" ["running", "failed"] ``` Contrast ERR-1's trail for a converted failure: it never reaches `failed`. ### Related rules - Names: ERR-1, ERR-8 - Referenced by: ERR-1 ## ERR-4 — An interrupt propagates unchanged *RT-ERR (Part II) · level: extended · profiles: runtime · added in 1.0* An interrupt is a request for something outside the run (human input, an external resolution), not a failure. Treating it as one would route it down an error edge. ### The rule > **Normative.** This is the rule. > > 1. A node that interrupts sets its status to interrupted, the interrupt is announced, and the interrupt itself propagates unchanged to the caller. > > 2. It is never converted into an error output and never takes an error edge. ## What it means An interrupt is not a third kind of failure sitting between ERR-1 and ERR-3 — it gets its own status, distinct from both, and it must reach the caller as the exact object the node raised, not wrapped or replaced, because whatever resolves the interrupt needs its own identity intact. That identity is read from whatever attached itself to the interrupt along the way, not fixed by the node itself: a run with nothing attached still pauses, carrying empty identity fields rather than failing where a stricter reading might expect one. ## Example ```json title="The status trail broadcast for an interrupting node" verdict="interrupted" ["running", "interrupted"] ``` ```json title="What the broadcast carries when nothing attaches an identity" verdict="empty" {"interrupt_id": "", "interrupt_type": ""} ``` ### Related rules - Names: ERR-1, ORC-13 - Referenced by: ORC-13 ## ERR-5 — A node may stop the run successfully *RT-ERR (Part II) · level: extended · profiles: runtime · added in 1.0* Stopping is a deliberate early finish, not a failure. Everything the run had not yet done is abandoned, and the run reports success. ### The rule > **Normative.** This is the rule. > > 1. A node may stop the whole run. > > 2. The stop is not converted into an error output: the node broadcasts completed, its unit of work is recorded as completed and never as failed, the value the stop carries is recorded under the node's result key, all downstream and not-yet-started work is abandoned, and the run finishes with status completed. ## What it means A node that stops the run is not failing quietly — the run finishes with the same status a run that executed everything normally reports. Everything downstream of the stopping node, and anything else not yet started, simply never happens: not marked failed, not marked skipped, not recorded at all. A reader who expects a partially-executed run to say so somewhere is expecting the wrong signal; `completed` here means the run ended deliberately, not that every node ran. ## Example Three nodes in a line; the middle one stops the run. ```json title="Nodes executed before the stop, in order" verdict="stopped" ["node_a", "node_b"] ``` ```json title="What the run itself finishes as" verdict="completed" "completed" ``` The third node never runs and never appears in the run's results. ### Related rules - Names: ERR-1, ORC-11 ## ERR-6 — A node's output must be serializable throughout *RT-ERR (Part II) · level: extended · profiles: runtime · added in 1.0* Checked over the node's whole output, before any unexposed value is filtered out, so a value the node type does not expose is policed exactly like one it does. ### The rule > **Normative.** This is the rule. > > 1. Every leaf of a node's output must be a JSON value: a string, number, boolean, null, an array, or an object with a defined JSON representation. > > 2. The check covers the node's full output, including values its type does not expose. > > 3. A violation is a node-level error naming the node, its type, the dotted path to the offending leaf and the offending type; the resulting error output is not retryable and follows the ordinary error-edge-or-fail path. ### Why Recorded under OPEN-19. ### Related rules - Names: ERR-1, ERR-2 ## ERR-7 — An error edge replaces the node's output with an error envelope *RT-ERR (Part II) · level: core · profiles: runtime · added in 1.0* The envelope is the whole contract between a failing node and its handler: a handler can be written against it without knowing which node type failed. ### The rule > **Normative.** This is the rule. > > 1. Where a node produces an error output and has at least one error edge, its unit of work is recorded as failed and marked as error-routed, its outputs are replaced by `{"error": {"message", "code", "node_id", "retryable"}}`, and the run continues. > > 2. The envelope carries an additional `details` key only where the failure supplied structured detail. ## What it means The envelope does not sit alongside the node's own output — it replaces it. Whatever the node would have produced on success is gone; a node downstream of the error edge sees only `message`, `code`, `node_id` and `retryable`, and can be written against that shape without ever knowing which node type failed or what it would otherwise have returned. The node's own unit of work is still recorded failed, not merely routed as if routing undid the failure — the run continuing is a decision about what happens next, not a change to what happened. `details` is the one key that is not always there: it appears only when the failure itself supplied something structured, never as an empty placeholder. ## Example A node with a wired error edge fails; a node downstream reads its output. ```json title="What arrives in place of the failing node's own output" verdict="routed" {"error": {"message": "boom blew up", "node_id": "boom"}} ``` ### Related rules - Names: ERR-8, ERR-9, ERR-10, ERR-13 - Referenced by: ERR-1, ERR-8, ERR-9, ERR-10, ERR-13, RT-GATE-4 ## ERR-8 — An error with nowhere to go fails the run *RT-ERR (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > Where a node produces an error output and has no error edge, its unit of work is recorded as failed without the error-routed marker, and the run fails. ### Related rules - Names: ERR-7, ERR-9 - Referenced by: ERR-3, ERR-7, ERR-9, ERR-13, RT-GATE-4 ## ERR-9 — A run whose every failure was handled completes *RT-ERR (Part II) · level: core · profiles: runtime · added in 1.0* The verdict is computed from unhandled failures, not from failures. One unrouted failure fails the run no matter how many routed ones accompany it. ### The rule > **Normative.** This is the rule. > > 1. A run finishes with status completed when every failed unit of work in it carries the error-routed marker, and fails when any failed unit of work does not. > > 2. A routed failure is still announced on the ordinary "unit of work finished" channel, so subscribers see it and discriminate on its status. > > 3. A failed tool invocation always counts as handled: a tool failure is delivered to its consumer as a recoverable result. ### Related rules - Names: ERR-7, ERR-8 - Referenced by: ERR-7, ERR-8, ORC-11, RT-GATE-6 ## ERR-10 — A routed failure activates error edges only, and only when it is current *RT-ERR (Part II) · level: extended · profiles: runtime · added in 1.0* Two independent staleness rules guard the handler, because a loop can both re-run a node and route a failure from an earlier round. ### The rule > **Normative.** This is the rule. > > 1. A node whose failure was routed satisfies only its error edges. > > 2. Error edges are evaluated before trigger and data edges and combine with OR semantics: one satisfied error edge activates the handler. > > 3. Success-path successors are never made ready by a routed failure. > > 4. A routed failure does not activate its handler when a newer unit of work exists for the same node, nor when it was routed in an earlier round than the round the handler has already handled. ### Related rules - Names: ERR-7, SG-19 - Referenced by: ERR-7 ## ERR-11 — Retry is opt-in, in place, and immediate *RT-ERR (Part II) · level: extended · profiles: runtime · added in 1.0* There is no backoff and no delay, so a retry is only ever appropriate where the node is idempotent. That is the author's contract, not the runtime's. ### The rule > **Normative.** This is the rule. > > 1. A node retries only where its configured maximum retry count is greater than zero; the default is zero. > > 2. Only a retryable error output retries. > > 3. A retry re-executes the node in place and immediately, with no backoff, and the attempt count persists across the run so the maximum bounds the total attempts. ### Related rules - Names: ERR-2 - Referenced by: ERR-2 ## ERR-12 — Direct synchronous execution has no error-handling divergence *RT-ERR (Part II) · level: core · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Withdrawn in favour of ERR-13. > > 2. The rule promised that direct synchronous execution does not record an error output and continue where the other strategies route it; ERR-13 states the uniform requirement for every strategy. *This rule is deprecated. It is kept so it stays citable.* Superseded by ERR-13. ### Related rules - Names: ERR-13 ## ERR-13 — Every execution strategy routes errors identically *RT-ERR (Part II) · level: core · profiles: runtime · added in 1.0* A workflow that handles its own failures must mean the same thing whichever engine runs it, including the direct synchronous one. ### The rule > **Normative.** This is the rule. > > 1. All execution strategies route error outputs the same way. > > 2. Where the failing node has an error edge, its outputs are replaced by the shared error envelope and the run continues; where it has none, the run fails. > > 3. No strategy may substitute its own error-handling behaviour. ## What it means The contract holds even for the strategy with the least machinery behind it. A strategy that runs a workflow in one pass, with nothing persisted between steps, still owes the same envelope a strategy built around persisted steps delivers: the same four keys, in the same shape, to the same downstream input. Having no step record to carry the envelope in is not a reason to carry less of it, or to shape it differently because the surrounding machinery differs. Nothing about how a strategy is built earns it its own error-handling behaviour. ## Example A node with a wired error edge fails under the strategy that keeps no record between steps; the downstream node's input arrives exactly as it would under any other strategy. ```json title="What the downstream node's input port receives" verdict="routed" {"message": "boom blew up", "code": "E42", "node_id": "boom", "retryable": false} ``` ### Why Recorded under OPEN-1. ### Related rules - Names: ERR-7, ERR-8 - Referenced by: ERR-7, ERR-12 --- # RT-ORC — ORC (Part II) ## ORC-1 — Four execution strategies, each with a stable identifier *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* The same workflow can be run four ways. Which way is a deployment choice, not a property of the workflow. ### The rule > **Normative.** This is the rule. > > 1. An implementation provides four execution strategies: direct synchronous, synchronous pipeline, asynchronous, and state graph. > > 2. Each reports a stable identifier for itself, and that identifier is what callers and stored configuration name it by. ### Related rules - Names: ORC-2, ORC-5 - Referenced by: ORC-2, ORC-5, ORC-13 ## ORC-2 — How the execution strategy for a run is resolved *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* Every configured step is checked before it is honoured, so an engine that is configured but not available cannot capture the default and strand a run. ### The rule > **Normative.** This is the rule. > > 1. The strategy for a run is resolved in this order: a run started from a pre-save trigger uses direct synchronous execution; otherwise the strategy configured on the trigger; otherwise the strategy configured for the implementation; otherwise asynchronous. > > 2. A configured strategy is accepted only if it validates as usable, and a step that does not validate is skipped in favour of the next. ## What it means The chain runs presave first, unconditionally. Even where a trigger declares asynchronous and the implementation's own default is state graph, a run fired by a presave event still executes synchronously, because the record's save is still in flight and only an in-request pass can observe it before it lands. What counts as presave is a substring match, not a fixed prefix: any event name that carries `.presave` anywhere in it qualifies. A differently-prefixed event forces the same synchronous strategy as the conventional one. Past that first step, each remaining step in the chain is honoured only if it validates as usable — a step naming a strategy the implementation cannot resolve is skipped in favour of the next, so a stale or uninstalled configuration cannot silently capture the default. ## Example A run whose trigger defaults to asynchronous and whose implementation defaults to state graph, fired by two differently-named presave events: ```json title="A conventionally-prefixed presave event" verdict="synchronous" "entity.node.presave" ``` ```json title="A presave event with an unrelated prefix" verdict="synchronous" "flowdrop.custom.presave.something" ``` Both resolve to the synchronous strategy, ahead of either configured default. ### Related rules - Names: ORC-1, ORC-3, ORC-4 - Referenced by: ORC-1, ORC-3, ORC-4, ORC-12 ## ORC-3 — An unknown strategy identifier falls back, and says so *RT-ORC (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A request for a strategy identifier the implementation does not know falls back to the default strategy and records a warning. > > 2. Where no fallback strategy is available either, the request fails rather than silently choosing one. ### Related rules - Names: ORC-2 - Referenced by: ORC-2 ## ORC-4 — State graph is the interactive-session default only *RT-ORC (Part II) · level: extended · profiles: runtime · added in 1.0* It is a caller's default, offered by the session surface, not a rung in the global chain, so a background run never silently becomes a state-graph run. ### The rule > **Normative.** This is the rule. > > 1. The state graph strategy is the default for interactive session and playground execution, and applies only where neither the session nor the workflow declared a strategy. > > 2. The general resolution chain never resolves to it. ### Related rules - Names: ORC-2 - Referenced by: ORC-2 ## ORC-5 — A strategy's capabilities are declared, not asked for *RT-ORC (Part II) · level: extended · profiles: runtime · added in 1.0* The declaration is the single source. A strategy that behaves statefully but does not declare it is treated as stateless, and the behaviours gated on the capability (checkpoint storage among them) are chosen accordingly. ### The rule > **Normative.** This is the rule. > > 1. A strategy declares its capabilities as part of its definition. > > 2. Whether a strategy is treated as stateful, and whether it is treated as synchronous, is determined from those declarations alone: stateful means the capability is declared, synchronous means synchronous execution is declared and stateful is not. > > 3. Behaviour that depends on a strategy's nature is gated on the declared capabilities and not on the strategy's identity. ## What it means Whether a strategy is treated as stateful comes only from its own declaration: nothing infers it from behaviour, and nothing infers it from which strategy it is. "Synchronous", as a capability query, means something narrower than "runs synchronously": it is the declared capability for synchronous execution *and* the absence of the stateful capability. State graph is synchronous in its mechanics, but it also declares itself stateful, so the synchronous query answers false for it — the two capabilities are read together, not as alternatives. A strategy with no declaration at all, or a declaration that carries no capabilities, answers every capability query the same way: false. There is no separate "unknown" outcome. ## Example Two strategy declarations, queried for the same two capabilities: ```json title="A definition declaring both capabilities" verdict="stateful" ["synchronous_execution", "stateful"] ``` ```json title="A definition declaring only one" verdict="synchronous" ["synchronous_execution"] ``` The first answers true for stateful and false for synchronous; the second answers the other way round. ### Related rules - Names: ORC-1 - Referenced by: ORC-1 ## ORC-7 — Direct synchronous execution refuses a workflow that loops *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* The engine walks the compiled order once and never re-enters a node, so it cannot iterate a loop. It refuses the workflow rather than running the body once and reporting success. ### The rule > **Normative.** This is the rule. > > 1. Direct synchronous execution walks the compiled execution order once, top to bottom, carrying results in memory, and never re-enters a node. > > 2. After compiling and before any node executes, it checks the compiled graph for loopback edges; where any is present it refuses the run with a distinguishable refusal that names the offending edges and nodes and points at the strategies that do iterate, and no node executes. ### Why Recorded under OPEN-13. ### Related rules - Names: CMP-5, ORC-9 - Referenced by: ORC-8 ## ORC-8 — Naming a firing trigger drops the other triggers *RT-ORC (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where a run's initial data names a trigger node, direct synchronous execution runs that trigger node and drops the workflow's other trigger nodes. > > 2. Where it does not, the compiled order runs verbatim. > > 3. Nodes that are not triggers are never filtered. ## What it means The filter is opt-in, keyed only on whether a run's initial data names a trigger node. Naming one selects exactly that trigger to run and drops every other trigger node the workflow declares — but only trigger nodes are ever dropped this way; anything downstream of the firing trigger still runs exactly as compiled. Leaving the name out — a manual or externally launched run, rather than one started by a specific trigger firing — runs the compiled order verbatim, trigger nodes included. ## Example A workflow with two trigger nodes feeding a shared step, launched two ways: ```json title="A run naming the trigger that fired" verdict="filtered" { "trigger_node_id": "cron_trigger" } ``` ```json title="The compiled order, filtered to that trigger" verdict="filtered" ["cron_trigger", "worker"] ``` ```json title="A run naming no trigger at all" verdict="unfiltered" {} ``` ```json title="The compiled order, run verbatim" verdict="unfiltered" ["cron_trigger", "webhook_trigger"] ``` ### Related rules - Names: ORC-7 ## ORC-9 — A pipeline engine is a ready-work loop *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* Re-entering a node on a later pass is what makes loops possible at all, and the empty pass is what defines the end of the run. ### The rule > **Normative.** This is the rule. > > 1. A pipeline engine repeatedly asks for the work that is ready. > > 2. Each pass promotes every idle unit of work whose dependencies are met to pending and persists that promotion, then returns all pending work (including work already pending from an earlier pass), ordered ascending by priority, so a lower priority number runs first. > > 3. A unit whose dependencies are unmet is left idle and untouched. > > 4. The engine stops when a pass returns nothing; that empty return is what quiescence means. ### Related rules - Names: ORC-10, ORC-11 - Referenced by: ORC-7, ORC-10, ORC-14 ## ORC-10 — A run has a scheduler budget, and exhausting it pauses rather than fails *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* This budget counts scheduler passes across the whole run. It is not a per-loop round count and not the hard safety valve; the three exist separately and resolve differently on resume. ### The rule > **Normative.** This is the rule. > > 1. A run is bounded by a maximum number of scheduler passes, defaulting to 100, and a maximum execution time. > > 2. Exhausting either breaks the loop and pauses the run with the reason recorded, and a pending system pause signal is raised. > > 3. A paused run is resumable and resumes with a fresh budget; re-entry clears a stale pause reason. > > 4. This budget is shared by every loop in the workflow and resets on resume, which is what makes a budget-paused run resumable; a per-loop round count is a separate bound, is restored from the run's own record and keeps accumulating across resumes. ### Why Recorded under OPEN-9. ### Related rules - Names: ORC-9, ORC-11, INT-12, SG-7, SG-14, SG-16 - Referenced by: ORC-9, ORC-11, INT-12 ## ORC-11 — How a run's terminal status is decided *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* A single ladder, in precedence order, so a run that both failed and paused reports the failure. ### The rule > **Normative.** This is the rule. > > 1. A run's terminal status is decided in this precedence: an unhandled failure makes the run failed and the remaining work skipped; otherwise an interrupt pauses the run with no reason recorded; otherwise work still ready to run pauses the run with the reason recorded; otherwise the run is completed. > > 2. A cancelled run never reaches this ladder; a cancel signal ends the run before it and is announced there. ### Related rules - Names: ERR-9, ORC-10, ORC-15, INT-12 - Referenced by: ERR-5, ORC-9, ORC-10, INT-11, INT-12, ORC-15 ## ORC-12 — Asynchronous execution returns immediately *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Asynchronous execution returns the status `queued` to its caller immediately and executes no node in the calling request. > > 2. It may seed the run from a snapshot of already-completed work. ### Related rules - Names: ORC-2, INT-12 - Referenced by: INT-12 ## ORC-13 — Resuming after an interrupt re-enters through the run's own strategy *RT-ORC (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > When an interrupt is resolved, the run resumes through the strategy the run declared, never through a different one. ### Related rules - Names: ERR-4, ORC-1 - Referenced by: ERR-4, INT-3 ## INT-11 — A terminal run refuses re-entry; re-running makes a new run *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* This is what makes cancellation durable: no requeue or rerun can resurrect a dead run and fire its side-effecting nodes again. ### The rule > **Normative.** This is the rule. > > 1. A run in a terminal status (completed, failed or cancelled) refuses re-entry, on every path that could re-enter it: no work is executed and the run is left untouched. > > 2. Re-running always creates a new run, seeded with the source run's input and recording which run it is a re-run of. ## What it means Re-running is not resuming. A run that already reached completed, failed or cancelled is dead, and re-entering it — through whichever door tries — does nothing: no work executes and the run's own record is left exactly as it was. What looks like "starting it again" always means a second, independent run, seeded with the first one's input. That second run starts from scratch, not from wherever the first one left off. It carries the same input the dead run carried, but its own status begins at the beginning — nothing about a terminal run's outcome, progress or identity survives into the run created from it, beyond the input and a record of which run it was re-run from. ## Example A cancelled run carrying one input value, and the run created by re-running it. ```json title="A cancelled run's stored input" verdict="cancelled" { "answer": 42 } ``` ```json title="The new run created by re-running it" verdict="pending" { "input": { "answer": 42 } } ``` The new run also records which run it was re-run from; the cancelled run keeps its status untouched. ### Related rules - Names: ORC-11, ORC-15 ## INT-12 — Only a budget pause resumes itself *RT-ORC (Part II) · level: core · profiles: runtime · added in 1.0* The absence of a pause reason is the marker of a human pause. Do not give a human pause a reason: that is what tells the machinery to leave it alone. ### The rule > **Normative.** This is the rule. > > 1. A paused run auto-resumes only where the pause was caused by a budget: the execution-time budget or the scheduler-pass budget. > > 2. A pause with no reason recorded is a pause awaiting a person, and holds until an explicit resume. > > 3. A worker that picks up paused runs resumes and re-queues budget pauses only, and leaves a reason-less pause paused. > > 4. Asynchronous execution in chunks depends on this: it works by pausing on budget, auto-resuming and re-queueing. ### Related rules - Names: ORC-10, ORC-11, ORC-12 - Referenced by: ORC-10, ORC-11, ORC-12 ## ORC-14 — A unit of work is claimed atomically *RT-ORC (Part II) · level: extended · profiles: runtime · added in 1.0* Without this, two workers can promote and execute the same unit of work, and a side-effecting node fires twice. ### The rule > **Normative.** This is the rule. > > 1. Claiming a unit of work for execution is atomic with respect to other workers: no unit of work is executed twice because two workers claimed it concurrently. > > 2. Queueing a run for execution does not deliver the same work twice. ### Related rules - Names: ORC-9 ## ORC-15 — A cancelled run is announced like any other terminal outcome *RT-ORC (Part II) · level: extended · profiles: runtime · added in 1.0* Cancellation leaves the run through its own path, so it is easy to forget to announce. Anything watching for a run to finish must see a cancelled run finish. ### The rule > **Normative.** This is the rule. > > 1. Every path that cancels a run announces the run's completion carrying the status cancelled, exactly once per cancellation, with the run's identity and its duration. > > 2. Consumers of that announcement must treat cancelled as its own outcome and not as a completed run. > > 3. A cancelled sub-workflow resolves to its caller with status cancelled and empty outputs. ## What it means Anything watching for a run to finish must not mistake a cancelled run for one still in progress: cancellation ends the run through its own path, and that path is easy to forget to announce, so this rule pins the announcement as mandatory rather than incidental. A listener that only checks for completion, and treats silence as "still running," is exactly the bug this closes. A cancelled sub-workflow resolving to its caller is the same rule seen from the other side. Whatever the resolved response looked like — no response at all, a bare status, or an outputs field that is not itself a set of values — none of it is treated as partial results. The caller sees an empty result, never a fabricated one assembled from whatever happened to be present. ## Example A sub-workflow's resolved response, in shapes a cancellation or an outputless resume can leave behind: ```json title="A resolved response with no outputs field at all" verdict="cancelled" { "status": "cancelled" } ``` ```json title="What the caller receives from it" verdict="empty" {} ``` ### Related rules - Names: ORC-11 - Referenced by: ORC-11, INT-11, INT-5, INT-18 --- # RT-BR — BR (Part II) ## BR-1 — An edge is gated only when the source actually made a branch decision *RT-BR (Part II) · level: core · profiles: runtime · added in 1.0* Gateways decide which paths stay alive, but most edges in a workflow are not branch edges at all. This rule says exactly when a branch decision is allowed to stop an edge, so that everything else keeps flowing. ### The rule > **Normative.** This is the rule. > > 1. An edge is subject to branch gating only when all three of these hold: it leaves a named source port, the source emitted a non-empty active-branch list, and that port is a branch port rather than a value port (BR-2). > > 2. Where they hold, the edge is followed if and only if the port's name appears in the source's active-branch list, both sides compared after trimming surrounding whitespace and lower-casing. > > 3. In every other case the edge is followed. ### Related rules - Names: BR-2, BR-3, BR-5 - Referenced by: BR-2, BR-3, BR-5, BR-6, BR-7 ## BR-2 — The three cases in which a branch decision never gates an edge *RT-BR (Part II) · level: core · profiles: runtime · added in 1.0* Read on its own, "follow the edge if its port is active" would strand every edge that has nothing to do with branching. These three cases are checked first and answer before the active-branch list is ever consulted. ### The rule > **Normative.** This is the rule. > > 1. Three cases return "follow the edge" before membership of the active-branch list is tested, checked in this order: the source port is unnamed; the source emitted no active-branch list, or an empty one; and the port is a value port rather than a branch port. > > 2. A port is a branch port when the source declares it among its configured branches, matched without regard to case; a port the source does not so declare is a value port and is never gated. > > 3. A source that declares no branches at all therefore gates nothing, whatever it emitted: with no port declared a branch port, every edge leaving it is followed. ### Related rules - Names: BR-1, BR-4 - Referenced by: BR-1, BR-4 ## BR-3 — An active-branch list is a list of names, trimmed and lower-cased *RT-BR (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A source's active-branch list is a list of strings. > > 2. A bare string is one branch name taken verbatim and is never split on commas or any other separator. > > 3. Entries that are not strings are dropped. > > 4. Surviving entries are trimmed of surrounding whitespace and lower-cased, which is the form BR-1 compares a port name against. ### Related rules - Names: BR-1 - Referenced by: BR-1, BR-4 ## BR-4 — An outcome matching no branch is loud, never a silent gate-off *RT-BR (Part II) · level: core · profiles: runtime · added in 1.0* A gateway whose configured branches do not cover the value it produced used to emit a branch name no port carried, gating off everything downstream with nothing said. The target is that this cannot happen quietly. ### The rule > **Normative.** This is the rule. > > 1. A gateway's branch authority is its configured branches, each an entry pairing a name with a value; an entry whose name is not a non-empty string is discarded before any matching. > > 2. Matching an outcome to a branch is type-aware and never a loose cast: a boolean outcome matches only a branch whose configured value denotes a boolean: a boolean, the integer `0` or `1`, or the strings `true`, `false`, `1` or `0` without regard to case. > > 3. Two strings compare without regard to case, and anything else must be equal in both type and value. > > 4. A gateway must not emit a branch name that no port of the node carries: where no configured branch matches the outcome, either the gateway declares a default branch port that receives it, or the node fails with an error output that an error edge can route (ERR-7). ### Why Recorded under OPEN-15. ### Related rules - Names: BR-2, BR-3 - Referenced by: BR-2 ## BR-5 — Branch gating applies to trigger and data edges alike *RT-BR (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Branch gating applies to an edge regardless of the kind of port it arrives at. > > 2. A trigger edge and a data edge leaving the same gated port are both gated, on the same test. ### Related rules - Names: BR-1 - Referenced by: BR-1 ## BR-6 — A stale source does not satisfy an edge *RT-BR (Part II) · level: core · profiles: runtime · added in 1.0* Inside a loop, a source can be completed and branch-active and still be speaking for the wrong round. Two independent staleness tests sit on every edge, and a source gated by either does not satisfy. ### The rule > **Normative.** This is the rule. > > 1. A source that has been superseded does not satisfy an edge: where a newer execution of the source node already exists, that source's branch decision must not activate a trigger edge, an error edge, or a data edge leaving a named port of a source that emitted an active-branch list; a source vouches only for its own iteration. > > 2. Independently, a completed source from a round behind the consumer's, on a loop both are inside, does not satisfy a trigger, error or ordinary data edge, named port or not; a source that shares no loop with the consumer is never gated on this ground, so no loop deadlocks. > > 3. An edge whose source is gated by either test is unsatisfied, and a consumer therefore never runs on a mixture of rounds. ### Related rules - Names: BR-1, BR-7, DATA-4, SG-19, SG-20 - Referenced by: BR-7 ## BR-7 — A node whose triggers are all unsatisfied is skipped, not executed *RT-BR (Part II) · level: core · profiles: runtime · added in 1.0* This is the line the whole branching design rests on: an untaken branch does not merely fail to fire, it terminates cleanly, visibly, and without failing the run. ### The rule > **Normative.** This is the rule. > > 1. A node with at least one incoming trigger edge and no satisfied trigger is skipped and must not be executed. > > 2. The skip is announced with the machine-readable reason `branch_not_active`, on the node-level skip event, on the real-time status update, and on the run's snapshot; the node's identifier also appears among the run's skipped nodes in the response metadata and is counted there. > > 3. A node with no incoming trigger edge always executes, whatever its data sources did. > > 4. At the end of a run, every node still unexecuted is collected as skipped the same way, whether or not it had already been promoted to ready. ### Related rules - Names: BR-1, BR-6 - Referenced by: BR-6 --- # RT-DATA — DATA (Part II) ## DATA-1 — An edge delivers one named output port to one named input port *RT-DATA (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge delivers a value from a named output port on its source node to a named input port on its target node. > > 2. Both port names are taken from the edge's endpoint handles; an endpoint that names no port delivers nothing, and neither warns nor fails the run. > > 3. Delivery turns on the presence of the port's key in the source node's output, not on the value being non-null: a source that emits an explicit `null` delivers `null`, and that delivered `null` outranks the target's configuration and its schema default. > > 4. A source that omits the key delivers nothing at all, and the target port falls through to its configuration and then to its schema default. ## What it means What decides whether a value is delivered is whether the source's output contains the wired key at all, not whether the value found there is useful. Even an explicit `null` counts as delivered, and a delivered `null` still outranks whatever the target's own configuration or schema default would otherwise supply. The other side of the same clause is silent absence: an edge whose handle cannot be split into a port name — because the direction marker is missing — names no port, and delivers nothing. Nothing is logged and the run is not failed; the target simply falls through to its own configuration and default, exactly as if the edge had never been drawn. ## Example A node wires another node's `result` output to its `data` input, but the source's actual output never carried a `result` key. ```json title="What the source node produced" verdict="produced" {"other": "alpha"} ``` ```json title="What the target's data port receives" verdict="empty" {} ``` A handle with no `-output-`/`-input-` marker to split on behaves the same way: the port name resolves to nothing, and nothing arrives. ### Related rules - Names: DATA-6, DATA-7 - Referenced by: DATA-4, DATA-6, DATA-7 ## DATA-2 — Several sources on one port resolve to a single latest value *RT-DATA (Part II) · level: core · profiles: runtime · added in 1.0* A merge point in a workflow is not a collector. A port fed by three edges is still one port, and what a node reads there is one value. ### The rule > **Normative.** This is the rule. > > 1. A port fed by several edges receives exactly one value, never a list of them. > > 2. The value is the one produced by the most recent execution among the sources: a source carrying a later execution order wins; a source carrying an execution order wins over one carrying none; and where neither carries one, the source node identifier decides, lexicographically. > > 3. A collision between sources on one port is reported as a warning and never fails the run. ## What it means A port fed by several edges never accumulates a list; it resolves to exactly one value, and the resolution has a strict order of preference. A source that completed later wins over one that completed earlier. Only when neither source recorded a completion order at all does the tie-break fall to the source node's own identifier — and that fallback is a genuine lexicographic sort, not the order the edges happen to be drawn in a workflow: which edge was drawn first has no bearing on which source wins. ## Example Two sources, `node_early` and `node_late`, both wire into the same port. Each completes with its own execution order recorded. ```json title="Resolved by completion order" verdict="resolved" {"data": "second"} ``` The same port, fed by `node_a` and `node_b`, neither of which recorded a completion order — and the edge from `node_b` is drawn first in the workflow, ahead of `node_a`'s: ```json title="Resolved by source node identifier" verdict="resolved" {"data": "from_a"} ``` `node_a` wins on the alphabetical sort even though its edge was declared second. ### Related rules - Names: SG-8 - Referenced by: SG-8 ## DATA-3 — A trigger edge carries no data *RT-DATA (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A trigger edge conveys only that its source completed. > > 2. It delivers no value, and no input port on its target is ever filled by one. ## What it means A trigger edge carries only the fact that its source finished, never a value — and that holds even where the edge's target handle names an ordinary input port. Pointing a trigger edge at a port that looks like a data port does not make it one: the port is never filled. ## Example `node_a` produces an output, and a trigger edge wires it to `node_z`'s `data` port. ```json title="What node_a produced" verdict="produced" {"result": "alpha"} ``` ```json title="What node_z receives on the wired port" verdict="empty" {} ``` ## DATA-4 — When a node is ready to run *RT-DATA (Part II) · level: core · profiles: runtime · added in 1.0* Readiness is the whole of a workflow's scheduling contract: it decides what runs, in what order, and what a node is guaranteed to have in hand when it does. ### The rule > **Normative.** This is the rule. > > 1. A node runs once its incoming edges are satisfied. > > 2. A node with at least one incoming trigger edge is ready as soon as any one of those trigger edges is satisfied, and its data ports are not evaluated at all; such a node may legitimately run with an unfilled data port. > > 3. Otherwise its incoming data edges are grouped by the input port they target; a group is satisfied by at least one source that has completed, is on an active branch, and is not a round behind the consumer on a loop the two share; and every group must be satisfied. > > 4. That is an OR within a port and an AND across ports. ### Related rules - Names: DATA-1, DATA-5, SG-19, SG-20 - Referenced by: BR-6, DATA-5, SG-19, SG-20 ## DATA-5 — Loopback and tool edges create no execution dependency *RT-DATA (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge into the reserved `loop_back` input port, and an edge that wires a tool to a consumer, create no execution dependency. > > 2. Neither is considered when the graph is checked for circular dependencies, and neither is considered when a node's readiness is evaluated. > > 3. A node whose only incoming edges are of those two kinds is therefore ready at once. > > 4. For tool edges this is required rather than convenient: a tool node never becomes a unit of execution, so it can never complete, and a tool edge that gated its consumer would starve it forever. ### Related rules - Names: DATA-4, DATA-10 - Referenced by: DATA-4, DATA-10 ## DATA-6 — Initial data fills a node's ports only where no edge did *RT-DATA (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Initial data supplied with a run, keyed by node identifier, fills that node's input ports underneath anything an edge delivered: an edge-delivered value wins, including an edge-delivered `null`. > > 2. An entry keyed for another node, or one that is not a map of port names to values, is ignored. ## What it means Initial data is a fallback, not a second source competing with an edge: it only ever fills the ports an edge left untouched. An edge-delivered `null` still counts as delivered, so it still wins over a seeded value at the same port — the merge is keyed on presence, not on usefulness. An entry keyed for a node other than the one running is ignored outright, and an entry that is not itself a map of port names to values is dropped rather than merged in some other shape. ## Example A node's `message` port is fed by an edge; its `seed` port is not. Initial data carries a value for both ports, keyed to this node. ```json title="What the run's initial data offers this node" verdict="offered" {"message": "from_initial_data", "seed": "only_in_initial_data"} ``` ```json title="What the node actually receives" verdict="merged" {"message": "from_edge", "seed": "only_in_initial_data"} ``` The wired port keeps the edge's value; the unwired one takes the seed. Where the edge instead delivers an explicit `null` on `message`, the node still receives `{"message": null}` — the seed does not get a second chance because the delivered value happens to be empty. ### Related rules - Names: DATA-1 - Referenced by: DATA-1 ## DATA-7 — Inside a node, a wire outranks configuration outranks the default *RT-DATA (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Within a node, a value delivered on an input port outranks the value the author configured on that node, which outranks the port's schema default. > > 2. Presence is decided key by key at every level, so an explicit `null` at a higher level wins over a value at a lower one. ### Related rules - Names: CFG-4, CFG-5, CFG-6, CFG-7, DATA-1 - Referenced by: DATA-1 ## DATA-8 — Unexposed outputs are stripped where they are produced *RT-DATA (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node's unexposed outputs are removed at the point the node produces them, before anything else observes the result: before the unified output port is composed, before the result is checked for serializability, before it is recorded against the node's execution, before it is written to a checkpoint, before it is published to real-time observers, and before it is returned as a tool result. > > 2. A hidden port's value therefore cannot reappear downstream. ### Related rules - Names: DATA-9, EXPO-11, EXPO-12, EXPO-13, EXPO-14 - Referenced by: DATA-9, SG-3 ## DATA-9 — The unified output port carries exposed outputs only *RT-DATA (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The unified `output` port composes a node's exposed outputs and nothing else. > > 2. Keys prefixed with `_` and the reserved `trigger` key are excluded from it. > > 3. An output key the node type does not configure counts as exposed and is kept. ## What it means The exclusion of `_`-prefixed keys and the reserved `trigger` key does not depend on how the node type configures them. Marking a key like `_debug` exposed on the node type has no effect: the leading underscore itself marks the value as plumbing rather than published data, and `trigger` is excluded the same way whatever its own exposure setting says, because it carries no data to begin with. The other clause runs the opposite way from what a stricter reading would suggest. An output key the node type's own configuration never mentions still counts as exposed and is composed into `output`. That is what stops a node whose behaviour produces more than its declared output schema from silently losing the extra value: nobody has to configure a key for it to be kept. ## Example A node's result carries an ordinary output alongside an internal key that the node type happens to mark exposed anyway. ```json title="What the node produced" verdict="produced" {"result": "ok", "_debug": "trace"} ``` ```json title="What the unified output port composes" verdict="composed" {"result": "ok"} ``` The same node type declares only `result`, but the node also produces `extra`, a key its configuration never mentions. ```json title="What the node produced, one undeclared key" verdict="produced" {"result": "ok", "extra": "bonus"} ``` ```json title="What the unified output port composes with nothing declared" verdict="composed" {"result": "ok", "extra": "bonus"} ``` ### Related rules - Names: DATA-8 - Referenced by: DATA-8 ## DATA-10 — A tool edge binds a tool, it does not deliver data *RT-DATA (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An edge on the reserved `tool_availability` port makes the source's tools available to its target as tool bindings. > > 2. It is not a data delivery and fills no input port. > > 3. Where a node forwards the tools it received rather than providing its own, the forwarding must not form a cycle, and a workflow in which it does is refused. ### Related rules - Names: DATA-5, DATA-12 - Referenced by: DATA-5, DATA-11, DATA-12 ## DATA-11 — A tool's model-facing schema hides the parameters the workflow already fixed *RT-DATA (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The tool schema offered to a model omits every parameter the workflow has already decided: one pinned in the tool node's configuration, and one fed by an edge. > > 2. The model is asked only for the parameters that remain open. ## What it means The two ways a workflow can already decide a parameter are treated identically, and a reader might expect otherwise: a value the author typed into the tool node's own configuration and a value another node feeds it on a wire both drop out of the model-facing schema the same way. The model is never asked to guess at something the workflow has already committed to supply, however that value arrives — it makes no difference to what the model is offered. ## Example A tool node's parameter schema declares two connectable parameters, `url` and `query`; the workflow pins a value for `url` in the node's own configuration. ```json title="The tool node's declared parameter schema" verdict="declared" { "type": "object", "properties": { "url": { "type": "string" }, "query": { "type": "string" } }, "required": ["url"] } ``` Only `query` survives into the model-facing `properties`, and `required` is left empty: the pinned `url` drops out of both. An edge feeding `url` from another node instead of a pinned configuration value removes it the same way — the same `properties`, the same empty `required`. ### Related rules - Names: DATA-10 ## DATA-12 — Tools reach only a node that declares itself a tool consumer *RT-DATA (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Tools are handed to a node only where the node type declares that it consumes them. > > 2. Wiring tools to a node type that neither consumes nor forwards them is a validation error; a node type that forwards them is accepted and passes them on. > > 3. Should such a workflow run regardless, the node receives no tools. > > 4. A node re-entered on a later round of a loop receives its tools on every round. ## What it means A node type that only forwards tools is accepted for wiring even though it never consumes any of them itself — it exists purely to fan the same tools out to more than one consumer, and every consumer wired to it sees the whole set, not a private share carved out for it alone. The other clause cuts against treating tool binding as something decided once and then left alone. A node reached again on a later round of a loop is not running on a binding made the first time it was reached: every round re-derives which tools it receives, so a node reached a second, third, or hundredth time still gets everything it is wired to. ## Example Two calculators, wired as `adder` and `multiplier`, both feed a single shared forwarding node; that node feeds two separate tool-consuming nodes. ```json title="Each consumer's tool manifest" verdict="listed" ["adder", "multiplier"] ``` A single tool-consuming node sits inside a loop, re-entered on every round, with one calculator wired to it directly. ```json title="The consumer's tool manifest, every round of the loop" verdict="listed" ["adder"] ``` ### Related rules - Names: DATA-10 - Referenced by: DATA-10 --- # RT-INT — INT (Part II) ## INT-1 — An interrupt pauses the run and reports itself in full *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* When a node stops to ask a question, the caller gets a complete answer about what happened: which question is outstanding, which run holds it, and everything that finished before it. ### The rule > **Normative.** This is the rule. > > 1. When a node raises an interrupt, its job is marked interrupted and carries the interrupt's identifier, and the run is paused. > > 2. The response shape is part of this rule: status `interrupted`, the persisted interrupt's public representation (identifier, node identifier, status) under the response metadata, the run's identifier, and results holding every node that completed before the interrupt. ## What it means An interrupt is not reported as a bare "paused" flag. The job that raised it carries the interrupt's own identifier, so a caller reading the job already knows which question it is waiting on. The response goes further: it hands back the persisted interrupt's public shape — its identifier, which node raised it, and its status — under the response metadata, and it hands back every node that finished before the pause. A caller does not have to make a second call to learn what already ran. The results are exactly the completed prefix, no more: a node reached after the interrupt does not appear at all, not even as an empty placeholder. ## Example A confirmation node interrupts a three-node run partway through. ```json title="The response to a run that stopped to ask" verdict="interrupted" { "status": "interrupted", "results": { "log_before": "…" }, "metadata": { "interrupt": { "id": "…", "nodeId": "confirm", "status": "pending" } } } ``` The node after the confirmation is missing from `results` entirely — it never ran — while the node before it is there in full. ### Related rules - Names: INT-2, INT-3, INT-22 - Referenced by: INT-2, INT-3, INT-22 ## INT-2 — The interrupt a run reports is the persisted one *RT-INT (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > An interrupt is persisted before the run reports it, and the identifier and status a caller sees are read from the persisted record, never from the in-flight signal that requested the pause, which carries no identifier and cannot be updated. ### Related rules - Names: INT-1 - Referenced by: INT-1 ## INT-3 — Resolving an interrupt resumes only a paused run *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* Answering a question must never restart a run that is already moving. The reset and the resume are two steps in that order, which is what keeps two executors off the same run. ### The rule > **Normative.** This is the rule. > > 1. Resolving an interrupt locates the job it interrupted by the job identifier stamped on the interrupt, and returns that job to pending unconditionally. > > 2. Only then, and only if the run is paused, is the run resumed, through the engine the run declares (ORC-13), never a different one. > > 3. A run that is not paused is left strictly alone and must not be re-entered. > > 4. A stateless run has nothing to resume, and resolution is a no-op for it. ### Related rules - Names: INT-1, INT-4, ORC-13 - Referenced by: INT-1, INT-4 ## INT-4 — A node resumes only when all four ownership conditions hold *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* Handing an answer to the wrong node, or to a node that never asked, is worse than asking again. So the fallback is a fresh execution, which at most re-asks. ### The rule > **Normative.** This is the rule. > > 1. A node's resume path is taken only when all four hold: the resolved parameters carry an interrupt identifier, the node's executor supports resuming, the named interrupt is resolved, and its stamped job identifier matches the job being executed. > > 2. If any fails, the node is executed afresh rather than resumed: a safe re-ask. ### Related rules - Names: INT-3 - Referenced by: INT-3 ## INT-5 — Cancel and pause signals are observed between job iterations *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* A signal never interrupts a job mid-flight. It is observed at the boundary between iterations, which is why a cancelled run has no half-executed node. ### The rule > **Normative.** This is the rule. > > 1. An engine polls for a pending signal between job iterations; the in-flight job always finishes first, and the absence of a signal continues the loop. > > 2. A cancel signal marks the run cancelled, stamps its execution time, and announces the outcome twice: a cancellation event and a run-completed announcement carrying the cancelled status, so that cancellation is announced like any other terminal outcome (ORC-15). > > 3. It then answers with status `cancelled` and metadata naming the engine, the signal and the reason. > > 4. A pause signal pauses the run and announces only the pause, with no completion announcement, answers with status `paused` and the same metadata keys, and leaves the signal record pending, because that record is the run's resume key. ## What it means A cancel and a pause are both observed only at the boundary between job iterations, never mid-node — the job already running always finishes first. Past that boundary the two diverge in a way that is easy to get backwards. Cancel is terminal: it announces the cancellation itself, and separately announces the run as complete (carrying the cancelled status), because anything that only listens for completion — a session closing out, a parent run waiting on this one — must hear that the run is over. Pause is not terminal: it announces only the pause, and nothing tells a completion listener the run has ended, because it has not. The signal a pause left behind stays exactly as it was, because that record is the same one a later resolution resumes against (INT-3). ## Example The same kind of poll, answered with a cancel and with a pause. ```json title="A run answering a pending cancel" verdict="cancelled" { "status": "cancelled", "metadata": { "signal_id": "signal-1", "reason": "operator stopped the run" } } ``` ```json title="The same run, answering a pending pause instead" verdict="paused" { "status": "paused", "metadata": { "signal_id": "signal-1" } } ``` Only the cancel is followed by a second, completion announcement; the pause produces exactly one. ### Related rules - Names: INT-17, ORC-15 - Referenced by: INT-16, INT-17 ## INT-6 — What a run snapshot contains, and that it never fails the run *RT-INT (Part II) · level: extended · profiles: runtime · added in 1.0* A snapshot is a best-effort record of progress. It is precise about what it carries, and it is never allowed to be the reason a run breaks. ### The rule > **Normative.** This is the rule. > > 1. A snapshot of a run carries the workflow identifier (the literal `unknown`, with an empty workflow version, when the run has no workflow), the structural workflow version (INT-7), the execution identifier, the caller-supplied status, the run's initial input, metadata naming the engine and the run, and one node snapshot per job keyed by node identifier. > > 2. Per node, job status maps one-to-one onto node status except that both `skipped` and `cancelled` become `skipped`, and any unrecognised status becomes `idle`; a node's output is carried only for a completed job and its error only for a failed one, alongside whether the node was injected, its execution order, and its job identifier. > > 3. Snapshot generation must never fail the run: where no state is available, or generation raises an error, the result is no snapshot, recorded in the log. ## What it means A snapshot's per-node status is not a direct copy of the job status behind it — two collapses are deliberate, and both go against the more intuitive guess. A cancelled job is recorded as skipped, not as failed and not with its own separate status: a cancelled node did not run, and a resume must not treat "did not run because cancelled" any differently from "did not run because the workflow finished first." A status the reader does not recognise at all becomes idle, on the same reasoning: unrecognised means "not done yet," the only assumption that cannot make a resume worse. Output and error are gated the other way round from what a merge might suggest: a job's output is carried only when that job actually completed, and its error only when it actually failed — a failed job never carries stale output forward, and a completed one never carries a phantom error. None of this may cost the run anything. Where there is no state to read, or producing the snapshot itself raises an error, the result is simply no snapshot — recorded in the log, not thrown. ## Example Four jobs mid-pause, and what the snapshot records for each. ```json title="Four jobs' own statuses" verdict="mid-run" { "n_done": "completed", "n_failed": "failed", "n_cancelled": "cancelled", "n_bogus": "not_a_status" } ``` ```json title="What the snapshot records for each" verdict="recorded" { "n_done": { "status": "completed", "output": { "result": "ok" } }, "n_failed": { "status": "failed", "error": "it blew up" }, "n_cancelled": { "status": "skipped" }, "n_bogus": { "status": "idle" } } ``` A run with no workflow behind it still gets a snapshot, with a placeholder identity rather than a missing one: ```json title="A run with no workflow to snapshot" verdict="unknown" { "workflowId": "unknown", "workflowVersion": "" } ``` ### Related rules - Names: INT-7, INT-9, INT-13 - Referenced by: INT-7, INT-9, INT-10, INT-13 ## INT-7 — The workflow version is a structural digest *RT-INT (Part II) · level: extended · profiles: runtime · added in 1.0* Two implementations must agree on whether a snapshot still fits its workflow. Moving a node on the canvas is not a change to the workflow it snapshots. ### The rule > **Normative.** This is the rule. > > 1. A workflow's version is the first 16 hexadecimal characters of a SHA-256 digest over the workflow's structural data only. > > 2. Presentational data (labels, canvas positions) must not change it. ### Related rules - Names: INT-6, INT-8 - Referenced by: INT-6, INT-8 ## INT-8 — Snapshot validation reports named errors and non-fatal warnings *RT-INT (Part II) · level: extended · profiles: runtime · added in 1.0* Validating a snapshot against a workflow definition answers with codes a caller can act on, and draws a hard line between what invalidates a snapshot and what is merely worth saying. ### The rule > **Normative.** This is the rule. > > 1. Validating a snapshot against a workflow definition yields a result carrying named codes. > > 2. Errors, each of which makes the result invalid: a version mismatch, raised only when the snapshot's workflow version is non-empty (an empty version skips the check and is not an error); one unknown-node error per snapshot node absent from the definition; and one dependency-not-met error per completed node whose dependency has no recorded state or is neither completed nor skipped. > > 3. Warnings, which never make the result invalid: one per definition node missing from the snapshot, and one per completed injected node that has dependencies. ## What it means Validating a snapshot against a workflow definition treats two kinds of mismatch very differently. A snapshot node the definition no longer knows — the graph changed under a run that is trying to resume into it — is an error: the result is invalid and resuming must not proceed. A definition node the snapshot has simply not reached yet is only a warning: that is the ordinary shape of a mid-run snapshot, and a warning must never flip the result invalid, or every resumable snapshot would be refused. The version check has an exception worth naming: an empty snapshot version skips the comparison entirely rather than counting as a mismatch. A snapshot built with no known version has nothing to compare, and treating "unknown" as "different" would refuse every such snapshot regardless of the definition it is checked against. ## Example The workflow declares two nodes, `node_1` and `node_2`. Only `node_1` has completed so far. ```json title="A snapshot node the definition does not know" verdict="invalid" {"id": "unknown_node"} ``` ```json title="A definition node the snapshot has not reached yet" verdict="valid" {"id": "node_2"} ``` ```json title="What validation reports for the second case" {"code": "WARNING_ORPHAN_NODE", "nodeId": "node_2"} ``` The first case invalidates the result; the second is recorded as a warning and the result stays valid. ### Related rules - Names: INT-7, INT-14 - Referenced by: INT-7 ## INT-9 — One execution has at most one stored snapshot *RT-INT (Part II) · level: extended · profiles: runtime, storage-api · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Storing a snapshot is an upsert keyed by execution identifier: an existing record for the same execution is overwritten in place, so one execution never accumulates two snapshot records. > > 2. Cleanup deletes records created before a given time and, where a set of statuses is given, only those whose status is in it, answering with the number deleted, zero when nothing matches. > > 3. Access to a snapshot is enforced at the API boundary and enforced once there, not repeated by the layer that stores them. > > 4. A scheduled cleanup runs with no principal and deletes on the predicate above alone. ### Related rules - Names: INT-6 - Referenced by: INT-6 ## INT-10 — A queued run seeded from a snapshot performs only the remaining work *RT-INT (Part II) · level: extended · profiles: runtime · added in 1.0* Handing a partially finished run to a queue must not re-fire what already ran. The seeded jobs are marked as borrowed, so nothing later mistakes them for work this run performed. ### The rule > **Normative.** This is the rule. > > 1. A queued run may be seeded from a snapshot: each snapshot node with a matching job is recorded as completed carrying the snapshot's output, marked as injected and stamped with the execution it came from and the time it was seeded, so that only genuinely remaining work is scheduled and no consumer mistakes a seeded record for work this run performed. > > 2. A snapshot node with no matching job is skipped and the discrepancy recorded in the log. > > 3. The number seeded is reported in the response results and metadata. ## What it means Deferred execution cannot replay a run's history; the only currency it understands is work still to be scheduled. Seeding it from a snapshot means writing back each already-completed node's output and marking it distinctly, so that only what genuinely remains is scheduled and nothing downstream mistakes a seeded record for work this run actually performed. The marker matters because a seeded record and a genuine one look identical except for it: the same completed status, the same output. A snapshot node with no matching job in the resumed run — the workflow changed since the snapshot was taken — is not an error here; it is simply skipped, and the discrepancy is noted rather than blocking the handover. ## Example A run got as far as two of three nodes before being handed over. The third node is left exactly as an unseeded run would leave it: no `injected` key at all. ```json title="A node the snapshot already completed" verdict="injected" {"status": "completed", "output": {"message": "two"}, "injected": true} ``` A later snapshot naming a node the workflow no longer has seeds nothing for it: ```json title="A snapshot node the workflow no longer has" verdict="skipped" {"id": "deleted_since", "output": {"message": "gone"}} ``` ### Related rules - Names: INT-6, INT-15 - Referenced by: INT-15 ## INT-13 — A checkpoint round-trip preserves cancellation *RT-INT (Part II) · level: extended · profiles: runtime · added in 1.0* Whether a run was cancelled or completed cannot be re-derived after the fact; "finished" looks identical either way. So the outcome is recorded, not inferred. ### The rule > **Normative.** This is the rule. > > 1. Execution state carries its terminal outcome explicitly (completed, failed or cancelled, and unset while the run is live), and a checkpoint persists it. > > 2. A snapshot's status is the persisted outcome, never a re-derivation from whether the run finished, which cannot express cancellation. > > 3. A cancel signal marks the state cancelled and writes the final checkpoint. > > 4. Seeding a new turn from a terminal state clears the run state, outcome and execution position alike (SG-12), so a recorded cancellation never closes a conversation thread; only explicit resumption of a run is gated (INT-14). ## What it means "Finished" looks the same whether a run completed, failed or was cancelled, if all that is stored is whether it finished at all. Deriving the outcome from completion alone cannot tell a deliberate cancellation apart from an ordinary finish, so the outcome has to be written down as its own fact, and a checkpoint has to carry that fact forward rather than reconstructing it. Cancellation recorded this way still does not close a conversation thread: seeding a new turn from a terminal state clears the run's outcome along with its execution position, so the next turn starts clean. Only resuming the run itself is gated (INT-14); continuing the conversation is not. ## Example ```json title="A checkpoint written with the outcome recorded" verdict="cancelled" {"isComplete": true, "error": null, "status": "cancelled"} ``` ### Related rules - Names: INT-6, INT-14, SG-12 - Referenced by: INT-6, INT-14 ## INT-14 — A terminal snapshot is not resumable *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* A finished run stays finished. Resuming from a snapshot of one would re-fire side-effecting nodes against a run whose outcome is already recorded and already announced. ### The rule > **Normative.** This is the rule. > > 1. A snapshot whose status is terminal (completed, failed or cancelled) must not be resumed. > > 2. An engine offered one refuses before creating a run, naming the snapshot and its status; the same refusal covers explicit resumption from a checkpoint capturing terminal state, naming the checkpoint. > > 3. Continuing a conversation thread is not resumption and is not gated: restoring a thread's latest state to seed a new turn carries accumulated conversation state forward and is permitted whatever the previous turn's outcome. ## What it means A finished run stays finished. Resuming it — whether from the snapshot itself or from the checkpoint that captured it — would re-fire side-effecting nodes against an outcome that is already recorded and already announced, so both paths are refused before any run or job is created, and the refusal names the snapshot or checkpoint and its status. The clause that bites is what does *not* count as resuming. Restoring a thread's latest checkpoint to seed a new turn is continuing a conversation, not resuming the finished one, and it is never gated: it carries the accumulated conversation state forward whatever the previous turn's outcome was, completed or cancelled alike. The gate binds to run resume, not to thread continuity — the same checkpoint can be refused down one path and accepted down the other. ## Example A run finishes and its final checkpoint records the outcome. The same checkpoint answers two different requests differently, because the requests are different operations, not because the checkpoint changed. ```json title="That checkpoint, explicitly resumed as a run" verdict="refused" {"status": "completed"} ``` ```json title="The same checkpoint, restored to seed a new turn" verdict="continued" {"status": "completed"} ``` ### Why Recorded under OPEN-10. ### Related rules - Names: INT-13, INT-15 - Referenced by: INT-8, INT-13, INT-15 ## INT-15 — An engine that cannot use a snapshot refuses it, never restarts *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* Silently discarding a snapshot and starting over looks like resilience and is the opposite: it re-runs every side effect the snapshot recorded as already done. ### The rule > **Normative.** This is the rule. > > 1. An engine that cannot consume a snapshot it has been given must refuse, naming the snapshot, and must not silently discard it and start the run from the beginning. > > 2. An engine with no snapshot-seeding mechanism therefore refuses any snapshot attached to a run, before any run or job exists. > > 3. No snapshot attached remains a plain fresh start. ### Why Recorded under OPEN-10. ### Related rules - Names: INT-10, INT-14 - Referenced by: INT-10, INT-14 ## INT-16 — A refused signal says why in a code a client can act on *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* Three different refusals share one status code, and a client has to tell them apart to say anything useful to an operator. The discriminator is a stable code, not the wording of a message. ### The rule > **Normative.** This is the rule. > > 1. The signal API refuses in three distinguishable ways, all `409`, each carrying a stable machine-readable code beside its human-readable message: the run is in a terminal state and cannot be signalled or resumed (`PIPELINE_TERMINAL`); an inward signal is already pending for the run (`INWARD_SIGNAL_ALREADY_PENDING`); and there is no active pause to resume (`NO_ACTIVE_PAUSE`). > > 2. A client classifies a refusal by that code; the message wording is not a contract and an implementation may change it. > > 3. A refusal answers with the error envelope (API-1). > > 4. A newly created cancel or pause signal answers `202`; a resume that resolved a pending pause answers `200`. > > 5. A signal addressed to a run that does not exist answers `404` to a caller holding blanket authority over runs (existence is no secret from someone who may act on any run), and an opaque `403` to every other caller, indistinguishable from the answer for a run that exists but is not theirs, so that the route cannot be used to enumerate which runs exist. ## What it means Three different refusals on the signal API share the one status, `409`, so the status alone cannot tell an operator which of the three happened; the `error_code` is what does. A code, once published, keeps its meaning forever — API-8 covers that guarantee for two of the three codes here; the third, `PIPELINE_TERMINAL`, is the same contract applied to a run that has already finished. Success is not one code either: creating a new signal and resolving one that already existed are different events on the same door, and they answer differently so a caller can tell which happened without inspecting the body. The existence check on a missing run is scoped to who is asking. A caller who may already act on any run loses nothing by being told a run does not exist, so they get an honest `404`. Everyone else gets the same opaque `403` whether the run does not exist or simply is not theirs — the two cases must be indistinguishable, or the difference between them becomes a way to enumerate which runs exist. ## Example ```http title="Cancelling a run that has already finished" verdict="409 PIPELINE_TERMINAL" POST /flowdrop/api/pipelines/{pipeline}/cancel {} ``` ```http title="Pausing a run for the first time" verdict="202 accepted" POST /flowdrop/api/pipelines/{pipeline}/pause {} ``` ```http title="Resuming that same pause" verdict="200 resolved" POST /flowdrop/api/pipelines/{pipeline}/resume {} ``` A caller with blanket authority over runs who cancels one that does not exist gets `404`; every other caller gets `403` for that same request, and `403` again for a run that exists but is someone else's — the two answers are identical on purpose. ### Why Recorded under OPEN-18. ### Related rules - Names: API-1, API-8, INT-5 - Referenced by: INT-17 ## INT-17 — A terminal outcome reaps the signals it never observed *RT-INT (Part II) · level: extended · profiles: runtime · added in 1.0* A signal is accepted whenever the run is still alive, but the run only looks at it between iterations. A run that finishes first leaves the request pending against a corpse. ### The rule > **Normative.** This is the rule. > > 1. When a run reaches a terminal outcome, inward signals still pending against it are cancelled, after the outcome's own handling has completed. > > 2. A paused run must not be reaped: a pending inward pause signal is that run's resume key (INT-5), and cancelling it would strand the run with no way back. > > 3. A stateless run has nothing targeting it and is skipped. > > 4. A failure while reaping is recorded and never raised; the outcome is already recorded and hygiene must not corrupt it. ### Related rules - Names: INT-5, INT-16 - Referenced by: INT-5 ## INT-18 — An expired outward interrupt ends the run it was holding open *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* An outward interrupt is the only thing that can resume the run that raised it. Once it expires the resume key is gone, and without this rule the run would sit paused forever: never terminal, never announced, invisible to everything that reports on finished runs. ### The rule > **Normative.** This is the rule. > > 1. When an outward interrupt expires, the run that raised it is ended: its still-active jobs are cancelled, the run is marked cancelled, and the outcome is announced like any other terminal outcome (ORC-15). > > 2. Two exclusions bind. > > 3. An expiring inward signal must not end a run: it acts on a run it does not own, and a lapsed cancel or pause request is a request that went unanswered, not a run that ended. > > 4. A run that has already recorded an outcome keeps it: a late expiry never overwrites it and never announces a second time. > > 5. A failure while ending one run is recorded and never raised, so a sweep is not aborted mid-backlog. > > 6. An interrupt with no expiry (INT-23) is never swept and never reaches this path, so an open-ended question to a human waits indefinitely; a confirmation gate interrupt is the deliberate exception, always carrying an expiry (RT-GATE-1), so an abandoned gated run ends here instead of holding an inbox entry forever. ### Related rules - Names: INT-20, INT-23, ORC-15, RT-GATE-1 - Referenced by: INT-20, INT-23, RT-GATE-1, RT-GATE-13 ## INT-19 — A machine answers an outbound wait through its own route *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* A remote system cannot use the route a human uses; it has no session and so cannot present the token that route requires. It gets a route built for its threat model, and an authority that buys it nothing else. ### The rule > **Normative.** This is the rule. > > 1. A machine caller resolves an outbound wait by posting `{"value": …}` (the same key the human resolution route takes) to a dedicated callback route addressed by the interrupt's unguessable identifier. > > 2. The human route requires a session-bound request token that a machine cannot obtain; the callback route does not require one, and must therefore admit only authentication schemes a cookie-authenticated browser cannot present, so there is no session to ride and nothing for such a token to protect. > > 3. Authorisation is a dedicated authority to resolve interrupts by callback: it is not satisfied by, and does not grant, the authority to resolve interrupts as a human. > > 4. The route is scoped by interrupt type, not by direction: only an external-call interrupt (the one shape a remote system was invited to answer) is resolvable here, and any other is refused `409`. > > 5. Every human prompt shape is stamped outward exactly as an external call is, so a direction-only guard would hand the callback credential a person's approval prompt and resume the run as though someone had answered; a direction check is kept behind the type check as defence in depth. > > 6. An unknown identifier answers `404`; an interrupt that is not pending, or has expired, answers `409`, so a replayed callback never resolves twice. > > 7. A request omitting `value` is refused `400` before the interrupt is looked up, so a malformed request reveals nothing about which identifiers exist. ## What it means A remote system has no session to ride, so the human route's request token is not something it can ever present; it gets a callback route built for what it actually is, addressed by the interrupt's own unguessable identifier instead of a session. Reaching that route does not make a caller a human: resolving by callback is a distinct authority that grants nothing else, in particular not the authority to answer as a person would. The bite is in the guard order, not just its existence. The route is scoped by interrupt *type* first — only the one shape a remote system was invited to answer is resolvable here — and the direction check sits behind it. Every human prompt is stamped outward exactly as an external call is, so checking direction alone would let the callback credential answer a person's approval prompt. And the missing-`value` check runs before the interrupt is even looked up: a malformed request gets refused without learning whether the identifier it named exists at all. ## Example ```http title="A machine caller answering the call it was invited to answer" verdict="200 resolved" POST /api/flowdrop/interrupts/{interrupt_id}/callback {"value": {"ok": true}} ``` ```http title="A malformed request against an identifier that does not exist" verdict="400 refused" POST /api/flowdrop/interrupts/{interrupt_id}/callback {} ``` ```http title="The same body, against a human-facing prompt" verdict="409 refused" POST /api/flowdrop/interrupts/{interrupt_id}/callback {"value": true} ``` The second request is refused for what it is missing, before anything is looked up; only a well-formed request against an unknown identifier reaches `404`. ### Related rules - Names: INT-20, INT-22 - Referenced by: INT-20, INT-22 ## INT-20 — Call-and-wait creates the interrupt before it makes the call *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* The order is the rule. The callback address is built from the interrupt's identifier, so there is nothing to tell the remote until the interrupt exists, and a fast remote can answer before the outbound request has even returned. ### The rule > **Normative.** This is the rule. > > 1. An outbound call-and-wait creates its interrupt first, then makes the remote call, then pauses. > > 2. The outbound body carries the callback address, the interrupt identifier and the caller's payload, and the answer returns through the callback route (INT-19). > > 3. A call that fails to go out cancels its interrupt before failing the node; a run waiting on a message nobody was asked to send is worse than a failed node. > > 4. A target address refused before the call leaves nothing behind: the address is validated before the interrupt is created, so a rejected target creates no pending record. > > 5. A target refused only mid-flight, because a redirect led the call somewhere it may not go (NET-2), is a failed outbound call and takes that path: the interrupt is cancelled and nothing pauses. > > 6. The wait is bounded by construction: there is no value meaning "wait forever", a non-positive expiry is refused, and every such interrupt is stamped with an expiry, so with INT-18 a remote that goes silent yields a cancelled run rather than a permanently paused one. > > 7. The wait is operational, not human-facing: it must not appear in a human inbox, where an operator could hand the workflow a fabricated response as though the remote had sent it. > > 8. Resumption passes the callback body through verbatim; only the workflow's author knows the remote's contract. ## What it means The callback address a remote system is told to answer at is built from the interrupt's identifier, so the interrupt has to exist before there is anything to tell the remote — the order is the rule, not an incidental detail of how the node happens to be written. A fast remote can answer the callback before the outbound call has even returned, and the interrupt must already be there to receive it. The reverse direction is guarded just as deliberately. A call that fails to go out cancels the interrupt it already created rather than pausing on it: a run waiting on a message nobody was ever asked to send is worse off than a failed node. The wait itself is bounded by construction — there is no value meaning "forever" — and it is operational rather than human-facing, so it never surfaces where an operator could hand the workflow a fabricated answer. ## Example ```json title="What the remote receives — the interrupt already exists" {"payload": {"order": 42}, "interrupt_id": "{interrupt}", "callback_url": "…/api/flowdrop/interrupts/{interrupt}/callback"} ``` The `interrupt_id` in the body and the id embedded in `callback_url` are the same value: the identifier the interrupt was given before this request was ever sent. ```json title="An answer arriving through the callback" verdict="unchanged" {"decision": "approved", "nested": {"score": 7}} ``` Resumption hands this payload downstream exactly as received; only the workflow's author knows what the remote's contract means. ### Related rules - Names: INT-18, INT-19, INT-23, NET-2 - Referenced by: INT-18, INT-19, INT-23 ## INT-22 — An interrupt is published as 24 keys, every one always present *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* One shape, from every endpoint that publishes an interrupt. A consumer reads a key rather than testing whether it is there, because an absent value is present and null. ### The rule > **Normative.** This is the rule. > > 1. Every endpoint that publishes an interrupt publishes the same entry: 24 keys, uniformly camelCase, in this 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`. > > 2. `id` is the interrupt's UUID; `type`, `status` and `direction` are the enumerated values (STORE-11); and all four timestamps are ISO-8601 strings or null, never integers. > > 3. Every optional member is present and null rather than omitted. > > 4. An endpoint listing interrupts answers a JSON array, and must still answer an array after access filtering has removed entries. ## What it means Every endpoint that publishes an interrupt publishes the same 24 keys, in the same order, whichever door answered. A consumer that reads this shape once can read it everywhere; a consumer that has to special-case one endpoint's fields is reading a contract that was never actually shared. The clause worth catching is presence, not content: an unset member is published as the key with a `null` value, never left out of the object. A consumer that checks whether a key exists, instead of reading its value, would read an unset member as though the endpoint had a different shape than the one that set it — the two are indistinguishable unless absence is never allowed to mean anything. ## Example ```json title="The interrupt entry's key set, 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"] ``` ```json title="One interrupt's unset members" verdict="null" {"schema": null, "options": null, "context": null, "expiresAt": null, "scheduledAt": null, "resolvedAt": null, "resolvedBy": null} ``` Nothing here is omitted; each of these keys sits in its place in the 24-key entry above, carrying `null` rather than being left out. ### Related rules - Names: INT-1, INT-19, PLAY-4, STORE-11 - Referenced by: INT-1, INT-19 ## INT-23 — No expiry is a sentinel, and the two expiry paths differ on purpose *RT-INT (Part II) · level: extended · profiles: runtime · added in 1.0* An interrupt with no expiry waits indefinitely, and must survive every sweep. When one does expire, whether that ends the run depends on which path expired it, and that asymmetry is the rule, not an oversight. ### The rule > **Normative.** This is the rule. > > 1. An interrupt waits indefinitely unless it carries a positive expiry: an absent or zero expiry is the no-expiry sentinel, is never treated as overdue, and must be excluded by any sweep, so an open-ended question to a human is never reaped. > > 2. An overdue interrupt is expired by two paths that differ in exactly one observable. > > 3. A sweep expires the interrupt, records it, and announces the expiry, which is what ends the run holding it open (INT-18). > > 4. Answering an already-overdue interrupt expires and records it as a side effect of refusing the answer, and announces nothing: that path is one caller answering one question, and announcing there would let a late click end the whole run. ## What it means Two different shapes both mean "wait indefinitely" — a missing expiry and one set to zero — and a sweep has to exclude both, or an open-ended question to a human would be reaped the first time the sweep runs. Treating only one of the two as the sentinel is the mistake this rule rules out. The two paths that do expire an interrupt differ in exactly one observable, and that asymmetry is deliberate rather than an oversight. A sweep finding an overdue row expires it and announces the expiry — that announcement is what ends the run holding it open (INT-18). A caller answering a row that turns out to already be overdue also expires it, as a side effect of refusing the answer, but announces nothing: that path is one person answering one question late, and letting it end the run would let a late click end a run nobody meant to stop. ## Example ```json title="An interrupt with no expiry set" verdict="never expires" {"expires": null} ``` ```json title="An interrupt whose expiry is zero" verdict="never expires" {"expires": 0} ``` Both stay pending through every sweep, and both are still answerable long after they were created. ### Related rules - Names: INT-18, INT-20 - Referenced by: INT-18, INT-20 --- # RT-GATE — GATE (Part II) ## RT-GATE-1 — A gated node never executes without consent for that exact call *RT-GATE (Part II) · level: core · profiles: runtime · added in 1.0* The gate sits at the single point every node execution passes through, so it covers a node scheduled in the graph and the same node invoked as a tool by an agent loop. The operator approves the resolved arguments, not the intention. ### The rule > **Normative.** This is the rule. > > 1. A node whose effective confirmation requirement is true must not execute without a consumed, hash-matching, confirmed consent (RT-GATE-2, RT-GATE-3). > > 2. The gate is evaluated after parameters are resolved and before the node executes, so the operator approves the arguments the call will actually use, and it applies only to a first execution: resuming a node continues a side effect that already passed the gate. > > 3. The pause is an ordinary interrupt carrying a boolean confirmation prompt, and the prompt shows only the arguments a model may fill, so values supplied by configuration (credentials, endpoints) never reach an operator's inbox. > > 4. A gate interrupt is a distinct flavour of interrupt and must never be consumed as a node's own resume answer, so a node that is both resumable and side-effecting does not mistake an operator's consent for the reply it was waiting for. > > 5. Every gate interrupt carries a bounded expiry, and expiry is fail-closed on both paths: an interrupt swept as overdue on a persisted run ends that run, cancelled and announced (INT-18); an interrupt found already expired on re-entry is consumed as a decline with the reason `expired`. ## What it means A run can arrive at an expired question in two different ways, and they do not resolve the same way. A question swept up as overdue while the run is still sitting there ends the run outright — cancelled, and announced (INT-18). A question that is instead found already past its expiry the moment execution re-enters it does not end anything: it is simply consumed as a decline, and the run continues down whatever path a decline takes. Nothing about either path leaves the question answerable a second time. ## Example A gate question nobody answered before its expiry, found only when the run re-enters it: ```json title="A gate question found already past its expiry on re-entry" verdict="declined" { "reason": "expired" } ``` ### Related rules - Names: INT-18, RT-GATE-2, RT-GATE-3, RT-GATE-4, RT-GATE-7 - Referenced by: INT-18, RT-GATE-2, RT-GATE-3, RT-GATE-4, RT-GATE-7, RT-GATE-11, RT-GATE-12, RT-GATE-13, RT-GATE-15 ## RT-GATE-2 — Consent is consumed exactly once, declines included *RT-GATE (Part II) · level: core · profiles: runtime · added in 1.0* An approval authorises one execution. An agent loop re-issuing the same call is asked again every time, which is the point. ### The rule > **Normative.** This is the rule. > > 1. The first execution that finds a confirmed, hash-matching consent marks it consumed and proceeds; a second execution of the same call finds no unconsumed consent and asks again with a fresh interrupt. > > 2. A decline is consumed the same way, so a decline never re-fires. > > 3. Checking and consuming a consent must be atomic against concurrent execution of the same call: a copy of the consent read before the check began must never authorise a second execution. > > 4. An execution that loses that race pauses on the pending interrupt raised by the winner rather than raising a duplicate question; a contended entry and an unconsented one are distinct causes of the same pause. ## What it means An approval is not the only verdict that gets consumed exactly once. A consent that was declined, one whose question was cancelled, and one left to lapse all settle the same way as an approval: consumed the moment they are read, never re-fired on a later entry, and never mistaken for authorization either. What repeats is the question, not the answer — the same call, entered again, finds nothing left to reuse and pauses on a fresh question of its own. ## Example The same node, called again with arguments that have not changed at all: ```json title="A call already answered, entered again unchanged" verdict="paused" { "message": "send it", "level": "info" } ``` ### Related rules - Names: RT-GATE-1, RT-GATE-3 - Referenced by: RT-GATE-1, RT-GATE-3 ## RT-GATE-3 — Consent binds to the node and its exact resolved arguments *RT-GATE (Part II) · level: core · profiles: runtime · added in 1.0* The operator approved one call, not "this node from now on". If the arguments drift, the approval no longer describes what would happen. ### The rule > **Normative.** This is the rule. > > 1. A consent binds to a hash over the node identifier and its canonicalized resolved arguments: keys sorted, scalars encoded stably, and internal parameters (those whose names carry the reserved `__` prefix) excluded. > > 2. Any mismatch between the hash a consent carries and the hash of the call at hand is treated as no consent: the gate asks again and must never reuse the earlier consent. > > 3. Where a question is still pending for arguments that have since drifted, it is withdrawn before the new one is asked; where a question is still pending for the same call, that question is re-raised rather than duplicated. ## What it means A pending question is not safe from being replaced by a later one for the same node. Where the arguments have drifted since the question was raised, the earlier question is withdrawn before the new one is asked — an operator who has not yet answered never sees a question about a call that no longer matches what would actually run. Only a question for the identical call is re-raised as itself rather than duplicated. ## Example A question already waiting in the inbox, for arguments that no longer match the call at hand: ```json title="The pending question already in the inbox" verdict="withdrawn" { "message": "old args" } ``` ```json title="The call that arrives instead" verdict="paused" { "message": "send it", "level": "info" } ``` ### Related rules - Names: RT-GATE-1, RT-GATE-2, RT-GATE-9, RT-GATE-12 - Referenced by: RT-GATE-1, RT-GATE-2, RT-GATE-5, RT-GATE-9, RT-GATE-12 ## RT-GATE-4 — A declined node routes a structured verdict out its error port *RT-GATE (Part II) · level: core · profiles: runtime · added in 1.0* A decline is a real outcome an author can handle, delivered where every other node failure is delivered. It is deliberately not a pair of branch ports that appear and disappear with a configuration flag. ### The rule > **Normative.** This is the rule. > > 1. A declined node that was scheduled in the graph must not be executed, and the decline becomes an error output on the node's reserved error port carrying the code `confirmation_declined` and details naming the interrupt type, the reason, who declined, when, and the interrupt's identifier; those details are forwarded as the error edge payload's optional details. > > 2. Where an error edge is wired the decline is a handled failure (ERR-7); otherwise the run's default failure behaviour applies (ERR-8). > > 3. A decline must not be surfaced as branch ports that exist only when confirmation is configured; a port surface that depends on a configuration flag breaks the canvas contract. > > 4. An author who wants first-class branching on a human decision uses an explicit confirmation node. ## What it means A decline is not a second set of branch ports appearing on the node because confirmation happens to be turned on for it. It is the same error port every other failure on that node would use, carrying a verdict shaped like any other structured error. An author who wants a decision a workflow can branch on wires an explicit confirmation node instead; a gate is not that node. ## Example A node the operator declines never runs; what its reserved error port carries instead: ```json title="What the declined node's error port carries" verdict="declined" { "code": "confirmation_declined", "details": { "type": "confirmation_declined", "reason": "declined" } } ``` ### Related rules - Names: ERR-7, ERR-8, RT-GATE-1, RT-GATE-5 - Referenced by: RT-GATE-1, RT-GATE-5 ## RT-GATE-5 — A declined tool call is model-recoverable, never run-fatal *RT-GATE (Part II) · level: core · profiles: runtime · added in 1.0* Failing a whole agent run because a person said "no" to one tool call would be wrong. The model is told, and carries on. ### The rule > **Normative.** This is the rule. > > 1. Where a gated node is invoked as a tool and the call is declined, the consumer receives a structured denial as a tool error result naming the decline, and the run continues to its own completion. > > 2. Where the call is approved, the consumer is re-fired and the same call re-issued: its arguments are the consumer's recorded inputs rather than a fresh sample from the model, so the consent matches by hash (RT-GATE-3) and the tool executes. ## What it means Declining one tool call is not a reason to fail the whole run that asked for it. The model that made the call is told the call did not happen and carries on from there — the run reaches its own completion regardless of what the model does with that answer. ## Example An agent's consumer node calls a gated tool, and the call is declined: ```json title="What the consumer receives when the call is declined" verdict="completed" { "called": "log_tool", "success": false } ``` ### Related rules - Names: RT-GATE-3, RT-GATE-4, RT-GATE-6 - Referenced by: RT-GATE-4, RT-GATE-6 ## RT-GATE-6 — A gate pause leaves no phantom failure in the tool trail *RT-GATE (Part II) · level: extended · profiles: runtime · added in 1.0* A pause is not a failure, and the record of a tool call must not say otherwise; a mislabelled attempt either holds a finished run open or trips the run's unhandled-failure check. ### The rule > **Normative.** This is the rule. > > 1. An interrupt escaping a tool invocation records the attempt as interrupted, not failed. > > 2. When the call is resumed it records itself as a new attempt and closes the superseded interrupted one as cancelled, because an attempt left interrupted would hold a finished run paused. > > 3. A failed tool call is recorded as a handled failure by construction (it is delivered to the consumer as a model-recoverable result), so it must not count towards the run's unhandled failures (ERR-9). > > 4. One pause raises exactly one question, however many times the tool plane observes the same interrupt. ## What it means A paused tool call is not a failure sitting in the trail waiting to be relabelled. It is recorded as interrupted from the start, and resuming it does not simply flip that same record to a finished state: the interrupted attempt is closed out as cancelled, superseded by a new attempt of its own that carries the finished status. Two records, not one repurposed — and neither of them is ever a failure, so neither counts toward the run's unhandled failures. ## Example The tool's own record while the run sits paused on the gate: ```json title="The tool job while the run is paused on the gate" verdict="interrupted" { "status": "interrupted", "output": {} } ``` The same node's two job records once the call is resumed: ```json title="The same node's job records once resumed" verdict="reconciled" ["cancelled", "completed"] ``` ### Related rules - Names: ERR-9, RT-GATE-5 - Referenced by: RT-GATE-5 ## RT-GATE-7 — Whether a node asks is a governance decision, resolved in order *RT-GATE (Part II) · level: core · profiles: runtime · added in 1.0* Requiring confirmation is a decision an administrator makes about a node type, not a property of the code that runs it. The executor's own declaration is only the fail-safe used when governance has not spoken. ### The rule > **Normative.** This is the rule. > > 1. A node's effective confirmation requirement resolves governance-first, in this order: an allowed dynamic escalation whose runtime input is truthy asks (RT-GATE-9); otherwise an allowed instance-level author choice (carried in the reserved confirmation configuration key) takes its value; otherwise the node type's stored policy, `ask` or `skip`; otherwise, with no policy stored, the requirement derives from whether the node's executor declares that it has side effects. > > 2. That derivation is performed at gate time from the executor itself and must never be baked into stored configuration, so an executor that adopts the declaration later re-gates existing configuration automatically. > > 3. A node type that has never stored a governance mapping resolves to the defaults: authors may waive and may require, with no dynamic surface. > > 4. A stored mapping is taken literally: an emptied list of allowed controls is a revocation, not a fallback to the defaults. ## What it means A stored mapping is never read as a hint. Where an admin has unchecked every control a node type's governance offers, that emptied list is the answer — revocation, not an instruction to fall back on the wider set a node type gets when nobody has stored anything at all. The two look alike (both are short, both restrict what an author can do) but only one of them is "nobody decided yet." ## Example A node type that has never stored a confirmation mapping gets the full default reach: ```json title="A node type with no stored mapping" verdict="default" { "authorControls": ["waive", "require"], "dynamicControls": [] } ``` A node type whose admin stored a mapping with every control unchecked keeps exactly that, not the default: ```json title="A node type whose admin unchecked every control" verdict="revoked" { "authorControls": [], "dynamicControls": [] } ``` ### Related rules - Names: RT-GATE-1, RT-GATE-8, RT-GATE-9, RT-GATE-11, RT-GATE-15 - Referenced by: RT-GATE-1, RT-GATE-8, RT-GATE-9, RT-GATE-11, RT-GATE-14, RT-GATE-15 ## RT-GATE-8 — Governance can revoke a waiver already stored *RT-GATE (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* A waiver was legal when the author stored it. Whether it is still permitted is asked at the moment the gate would fire, against the settings that hold now. ### The rule > **Normative.** This is the rule. > > 1. Whether an author was allowed to make an instance-level confirmation choice is checked at gate time against the current governance settings, not at the time the choice was stored. > > 2. Withdrawing the waive control therefore re-gates every instance that had waived, and a stored choice whose control is no longer granted is ignored. > > 3. The configuration surface offered to an author must offer exactly the granted controls plus the option to defer to the policy; where no control is granted no such field is offered at all, so a value that is not allowed has no route through which to arrive, and one already stored is inert. ### Related rules - Names: RT-GATE-7 - Referenced by: RT-GATE-7, RT-GATE-9, RT-GATE-14 ## RT-GATE-9 — Dynamic escalation can add an approval but never remove one *RT-GATE (Part II) · level: extended · profiles: runtime · added in 1.0* Data flowing into a node (including arguments a model filled) may raise the bar for that execution. It may never lower it. ### The rule > **Normative.** This is the rule. > > 1. Where governance grants the dynamic escalation control, the node type declares a reserved confirmation input port, hidden by default, and a truthy value delivered to it gates that execution. > > 2. A falsy value does not participate: upstream data can add an approval requirement and must never remove one. > > 3. A value whose truthiness cannot be determined escalates; over-asking is the fail-safe direction for a value crossing a port. > > 4. Enforcement is at the gate, not at authoring time: an undeclared port is wireable regardless, so identical wiring on a node type without the grant delivers a value the runtime ignores. > > 5. The escalation is not a resolved parameter and must not reach the node's parameters; it is bound into the consent arguments explicitly, so that a change of policy between two executions forces a fresh question (RT-GATE-3). ### Related rules - Names: RT-GATE-3, RT-GATE-7, RT-GATE-8 - Referenced by: RT-GATE-3, RT-GATE-7 ## RT-GATE-11 — A gated node whose executor cannot be resolved never executes *RT-GATE (Part II) · level: core · profiles: runtime · added in 1.0* Fail-closed here means error, not ask. Reading a missing executor as "no side effects" silently ungates the node; asking about a call that can never run trains operators to rubber-stamp. ### The rule > **Normative.** This is the rule. > > 1. Where a node's confirmation requirement must be derived from its executor and that executor cannot be resolved (no such executor is defined, or its implementation is unavailable), the node must not execute and must not raise a confirmation prompt. > > 2. The derivation fails, and the node fails as any node with an unresolvable executor does. > > 3. An implementation may elsewhere let callers probe an executor's declarations without raising, treating an unresolvable one as declaring nothing; that leniency must not be used at the gate, where it would read a missing executor as harmless. ### Related rules - Names: RT-GATE-1, RT-GATE-7 - Referenced by: RT-GATE-7 ## RT-GATE-12 — A resolved secret never persists in the gate prompt *RT-GATE (Part II) · level: core · profiles: runtime · added in 1.0* The prompt an operator reads is stored verbatim. A credential that was substituted into an argument must not be stored along with it, while the consent must still bind to the real call. ### The rule > **Normative.** This is the rule. > > 1. Where a secret reference was substituted into a parameter's value, that parameter is tracked by its top-level name and its whole value is replaced with `` in the prompt shown to the operator and persisted with the interrupt. > > 2. Tracking is by parameter rather than by matching the secret's text, because the substitution may be partial or nested inside a structured value. > > 3. The consent arguments keep the real values, so that consents for different secret values remain distinguishable, and only their digest is ever persisted (RT-GATE-3). > > 4. This covers secrets supplied by configuration only: values delivered by an edge or produced upstream are shown as they are, deliberately; the operator is approving what will be sent. ## What it means Redaction works by parameter name, not by searching the resolved value for the secret's own text. That is why a partial substitution (a token pasted into the middle of a longer string) and a nested one (a reference buried inside a structured value) are both replaced wholesale: the whole value disappears behind ``, not just the substring that came from the reference. Matching on the text itself would miss a secret concatenated, templated or re-encoded on its way into the parameter. The consent question does not use the redacted view. What is hashed to bind the operator's approval to this exact call is the real, resolved value, so a call made with one secret and the same call made with a different one are different questions — approving one never approves the other. This only concerns a secret reference resolved from configuration. A value that arrives already filled in — on a wire, or produced by an earlier step — is shown to the operator exactly as it is, on purpose: the operator is approving what will actually be sent, and a value the workflow itself produced is not something the gate has any reason to hide. ## Example A call resolves one parameter with a secret pasted into a longer string and another with the same secret nested inside a structured value; a third parameter carries no secret at all. ```json title="What the call resolved" verdict="resolved" { "url": "https://example.com/hook", "auth": "Bearer sk_live_do_not_leak", "headers": { "Authorization": "Bearer sk_live_do_not_leak" } } ``` ```json title="What the operator is shown" verdict="redacted" { "url": "https://example.com/hook", "auth": "", "headers": "" } ``` ### Related rules - Names: RT-GATE-1, RT-GATE-3 - Referenced by: RT-GATE-3 ## RT-GATE-13 — A gate question belongs to the initiator, and an ownerless one is findable *RT-GATE (Part II) · level: extended · profiles: runtime · added in 1.0* A question assigned to whoever happened to persist it (a background worker with no identity) matches nobody's inbox and is never answered. A run with no initiator at all needs somewhere for its question to land. ### The rule > **Normative.** This is the rule. > > 1. A gate interrupt is assigned to the run's initiator (the owner of the job, as stamped when the run was launched) and never to whichever identity happens to persist it. > > 2. A run with no initiator, such as one launched by a schedule, a webhook or an anonymous trigger, raises its question unassigned. > > 3. Unassigned gate questions are visible to holders of the authority to resolve any interrupt; this is the only relaxation of the inbox's scoping to the owning identity, and it covers unassigned gate questions only. > > 4. An assigned question stays scoped to its owner whatever authority the viewer holds (being able to resolve any interrupt grants resolution, not a merged inbox), and an unassigned question that is not a gate question stays invisible. > > 5. Where nobody attends an unassigned gate question it expires and the run is cancelled (RT-GATE-1, INT-18): fail closed, never silently approved. ### Related rules - Names: INT-18, RT-GATE-1 ## RT-GATE-14 — Confirmation governance is its own grant *RT-GATE (Part II) · level: core · profiles: runtime, storage-api · added in 1.0* Being able to rename a node type must not imply being able to disarm its gate. And an actor who cannot see the setting must not be able to change it by saving the form it is hidden from. ### The rule > **Normative.** This is the rule. > > 1. Confirmation governance is controlled by a dedicated administrative authority, separate from the authority to administer node types, and restricted. > > 2. Where an actor does not hold it, the governance settings are not shown, and a save by that actor must leave the stored governance mapping byte-identical: a setting that was never stored stays unstored, so the node type keeps deriving its requirement (RT-GATE-7). > > 3. The strength of the gate is exactly the strength of who holds this authority and who may edit workflows. ## What it means The authority to administer node types does not carry this one. An actor who can rename, recategorise or otherwise edit a node type still cannot touch its confirmation governance without the separate, dedicated authority — and without it, the governance controls are not merely disabled, they are not shown at all. The clause a careful reader would miss is what happens to the rest of the save. An actor without the authority can still save the node type for everything the form does show them; the governance mapping simply has to survive that save untouched, byte for byte, rather than being silently reset to whatever the hidden controls would have defaulted to. ## Example A node type's stored governance before either actor saves it: ```json title="The stored governance before a save" verdict="stored" { "policy": "ask", "author_controls": ["require"], "dynamic_controls": ["require"] } ``` An actor without the dedicated authority still saves the node type — the label changes, the governance does not: ```json title="What that save is still allowed to change" verdict="renamed" { "label": "Renamed Gated Type" } ``` The stored governance above is exactly what a second read of that node type returns afterward. ### Related rules - Names: RT-GATE-7, RT-GATE-8 ## RT-GATE-15 — A shipped side-effecting node type states its policy *RT-GATE (Part II) · level: extended · profiles: runtime, storage-api · added in 1.0* "Has side effects" and "an operator should approve this" are two different questions that happen to coincide for an outbound call and diverge for a memory write. Leaving a shipped node type undecided delegates a governance decision to a fail-safe. ### The rule > **Normative.** This is the rule. > > 1. Every node type an implementation ships whose executor declares that it has side effects must declare its confirmation policy explicitly, rather than leaving the requirement to be derived (RT-GATE-7). > > 2. The derivation reads "mutates persistent state" as "performs an action an operator should approve", which is not the same question. > > 3. A shipped declaration should be `ask` where the effect leaves the implementation's own boundary and `skip` where it does not. > > 4. Where an implementation delivers such declarations to sites that already exist, it must write a policy only where none is stored: an administrator's own choice, a narrowed list of allowed controls, and a value still awaiting migration must all survive untouched. ## What it means "Mutates persistent state" and "an operator should approve this" are two different questions, and a node type that ships without an answer is not defaulting to a neutral position — it is letting the gate's own fail-safe answer a governance question nobody actually decided. The two questions happen to agree for a call that leaves the implementation's own boundary, which is exactly why it is easy to assume they always agree. Once a shipped type has an answer, delivering it to a site that already exists is one-directional. The pass may fill in a policy that was never stored, but it must recognise every other shape as somebody's answer already: a stored policy, a narrowed list of controls with no policy yet, and an older tri-state waiting for a separate migration to translate all count, and none of them may be replaced by the shipped default. ## Example A confirmation mapping records a policy alongside which controls stay available. Left undecided, filling it in from what the type ships looks like this: ```json title="A node type's confirmation mapping, undecided" verdict="undecided" { "policy": null, "authorControls": ["waive", "require"], "dynamicControls": [] } ``` ```json title="The same mapping once the shipped policy is delivered" verdict="skip" { "policy": "skip", "authorControls": ["waive", "require"], "dynamicControls": [] } ``` Where a policy was already stored, the same delivery finds nothing undecided to fill in — even on a node type whose shipped default disagrees with it: ```json title="An administrator's own choice, on a type shipped with skip" verdict="untouched" { "policy": "ask", "authorControls": [], "dynamicControls": [] } ``` ### Related rules - Names: RT-GATE-1, RT-GATE-7 - Referenced by: RT-GATE-7 --- # RT-TOOL — TOOL (Part II) ## RT-TOOL-1 — A tool result carries artifacts alongside its data, never inside it *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* A tool can return something structured (a chart, a record, a file reference) that the model should not have to read as prose. It travels beside the result, not inside it. ### The rule > **Normative.** This is the rule. > > 1. A tool result is `{success, data, error}`. > > 2. A result whose call attached structured artifacts carries an additional `artifacts` key, present only when at least one artifact was attached, so an artifact-free result keeps the plain three-key shape unchanged. > > 3. Artifacts are attached additively, and are never folded into `data`: the two are disjoint. ## What it means An artifact-free result is indistinguishable from a plain three-key result — `artifacts` only appears once at least one artifact was attached. A caller that never expects artifacts can keep reading `{success, data, error}` and never notice the key exists. `data` and `artifacts` stay disjoint: an artifact is never folded into `data` alongside the prose-facing values, and attaching one never changes what `data` already holds. ## Example ```json title="A result with nothing attached" verdict="plain" { "success": true, "data": { "ok": true }, "error": null } ``` ```json title="The same result, once one artifact is attached" verdict="attached" { "success": true, "data": { "ok": true }, "error": null, "artifacts": [{ "type": "link", "payload": { "url": "https://example.com" } }] } ``` ### Related rules - Names: RT-TOOL-2 - Referenced by: RT-TOOL-2 ## RT-TOOL-2 — A tool node emits artifacts on a reserved key that never reaches the model *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A tool node's output may carry the reserved `artifacts` key, a list of `{type, payload}` maps. > > 2. It is a system channel, exempt from output-port exposure, and so survives whether or not the author exposed the port. > > 3. On a successful call those entries are lifted off the node's output and attached to the tool result: an entry with a string `type` and a map `payload` is kept, and any other entry is dropped without failing the call. > > 4. The reserved key is always removed from the result's `data`, so it never reaches the prose a tool-consuming node feeds the model. > > 5. A call that failed or was declined carries no artifacts. ## What it means The reserved key is not a strict channel: an author can put anything on it, and a malformed entry does not fail the call — it is silently dropped, and the well-formed entries beside it still get through. Only an entry with both a string `type` and a map `payload` survives; a bare string, an entry missing `type`, or a `payload` that is not a map is discarded on its own, without touching its neighbours. Whatever happens to the entries, the reserved key itself is always stripped from the node's output before that output becomes the result's `data` — even a node that emitted nothing valid on it still loses the key from `data`. ## Example A node's output carries four entries on the reserved key: a bare string, one missing its type, one whose payload is not a map, and one well-formed. ```json title="What the node's output carries on the reserved key" verdict="mixed" [ "not-an-array", { "type": "", "payload": { "a": 1 } }, { "type": "link", "payload": "not-an-array" }, { "type": "link", "payload": { "url": "https://ok.example.com" } } ] ``` ```json title="What the tool result's artifacts hold" verdict="lifted" [{ "type": "link", "payload": { "url": "https://ok.example.com" } }] ``` ### Related rules - Names: RT-TOOL-1, RT-TOOL-3 - Referenced by: RT-TOOL-1, RT-TOOL-3, RT-TOOL-6 ## RT-TOOL-3 — Artifacts leave a tool call on their own port, apart from the model's prose *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node that invokes tools surfaces every call's artifacts on a dedicated `tool_artifacts` output port, as a list of exactly `{type, payload, tool_call_id}` entries built key by key, so a tool can neither override the real `tool_call_id` nor smuggle keys of its own through. > > 2. That port is not exposed by default. > > 3. The prose fed back to the model is built from a result's `data` alone and never from its artifacts. > > 4. Delivery of a run's artifacts must not depend on whether the author exposed the port. ## What it means The prose the model reads back is built from `data` alone — an artifact never leaks into it, even as a stray mention of a URL or a name that also appears in the artifact's payload. The two travel on entirely separate paths: prose in the tool message, artifacts on `tool_artifacts`, paired to the call that produced them by `tool_call_id` so a node invoking several tools at once can still tell whose artifact is whose. That port carries the artifacts whether or not the author exposed it — delivery does not wait on an exposure decision that governs only what a person sees in an editor. ## Example One tool call returns a result whose `data` is a short confirmation string and whose artifact is a link the confirmation never mentions. ```json title="What the tool result carries" verdict="produced" { "data": { "result": "Created node 5 (draft)." }, "artifacts": [{ "type": "link", "payload": { "url": "https://example.com/node/5" } }] } ``` ```json title="What lands on the node's tool_artifacts port" verdict="surfaced" [{ "type": "link", "payload": { "url": "https://example.com/node/5" }, "tool_call_id": "c1" }] ``` The prose fed back to the model stays `"Created node 5 (draft)."` — nothing about the link appears in it. ### Related rules - Names: RT-TOOL-2, RT-TOOL-4 - Referenced by: RT-TOOL-2, RT-TOOL-4 ## RT-TOOL-4 — A turn's artifacts are persisted on its message and reported on its result *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The artifacts a run collected are persisted on the last assistant message that run wrote, once per run. > > 2. A turn's result aggregates the persisted artifacts of every assistant message in the turn, so they survive a reload, and the turn API reports the same list. > > 3. On the wire a message carries them as a top-level `toolArtifacts` field rather than as raw message metadata. ## What it means Artifacts are not a run-scoped, in-memory extra that a reload would lose: they land on the assistant message itself, so reading the turn back later still returns them. A caller reading the wire form never sees the storage key they were kept under — they arrive as their own top-level field, not nested inside the message's general metadata, so a client does not have to know the internal name to find them. ## Example ```json title="An artifact a tool call produced during the turn" verdict="produced" { "type": "link", "payload": { "url": "https://example.com/report" }, "tool_call_id": "call_1" } ``` ```json title="The assistant message as the door reports it" verdict="reported" { "toolArtifacts": [ { "type": "link", "payload": { "url": "https://example.com/report" }, "tool_call_id": "call_1" } ], "metadata": {} } ``` The message's `metadata` never carries the artifacts under their storage key — a client that only reads `toolArtifacts` sees everything there is to see. ### Related rules - Names: RT-TOOL-3, RT-TOOL-7 - Referenced by: RT-TOOL-3, RT-TOOL-7 ## RT-TOOL-6 — Artifacts are bounded once, at the point they are collected *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An implementation bounds the artifacts one run may collect and enforces that bound at the single point of collection; nothing downstream re-checks what one run collected. > > 2. It must accept a payload of at least 512 KB once JSON-encoded, at least 50 artifacts in a run, and at least 2 MB of encoded payload across a run in aggregate. > > 3. An artifact whose payload does not encode, or which exceeds a bound, is dropped with a warning naming the tool, the run, the artifact's type and the call; the run is not failed, and the retained list carries no placeholder for what was dropped. > > 4. A drop is per artifact, so a later, smaller artifact that fits the remaining budget is still kept. > > 5. An artifact with an empty `type` is refused outright. > > 6. A payload is untrusted tool and model output: a consumer must treat every value in it as plain text and escape it on output. ## What it means Once a run's collection has accepted or dropped an artifact at the point it was collected, nothing downstream checks it again — a consumer reading the retained list is not re-validating what it receives. A drop never leaves a gap for the entry it removed: the list a consumer sees is exactly the artifacts that survived, in the order they arrived, with no placeholder marking where one was dropped. The drop is decided per artifact, not once for the whole run: an artifact that crosses a bound does not disqualify a later, smaller one that still fits what remains of the budget. ## Example A run's aggregate budget is already mostly spent by earlier artifacts; the next one would cross it and is dropped, but a small one after that still fits what is left: ```json title="The artifact collected after the drop" verdict="kept" { "type": "citation", "payload": { "ref": "kept" }, "tool_call_id": "call_last" } ``` Nothing in the retained list marks where the dropped artifact would have sat. ### Related rules - Names: RT-TOOL-2, RT-TOOL-7 - Referenced by: RT-TOOL-7 ## RT-TOOL-7 — A pause does not destroy artifacts, and does not deliver them twice *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Artifacts produced before a run pauses are held durably against the turn that produced them rather than discarded, and are never exposed on the wire while they are held. > > 2. The next write-back for the same execution takes them, prepends them to whatever the resumed run collected, and delivers the merged list on the resumed turn's assistant message. > > 3. Taking is destructive, so a delivered artifact is never delivered a second time, and a resume that pauses again holds everything for the next one. > > 4. A write-back that fails after taking them puts them back before it reports the failure, so a failed write postpones delivery rather than destroying it. > > 5. The held list is itself bounded; where it would exceed the bound, the newest entries are dropped with a warning. ## What it means A pause is the hard case: a tool already ran and produced an artifact before the run stopped to ask a question, and there is no assistant message yet to carry it. The artifact is held rather than lost, but it stays off the wire while it waits — a caller polling the paused turn never sees it. Only the resume's write-back takes it, and taking it is destructive: once delivered on the resumed turn's assistant message, the same artifact is never handed out again, even if the run pauses a second time. ## Example ```json title="What is held against the paused turn" verdict="held" { "type": "link", "payload": { "url": "https://example.com/report" }, "tool_call_id": "call_1" } ``` Answering the question resumes the run and delivers it exactly once, on the resumed turn's assistant message; asking the same question again finds nothing left to deliver. ### Related rules - Names: RT-TOOL-4, RT-TOOL-6 - Referenced by: RT-TOOL-4, RT-TOOL-6 ## RT-TOOL-8 — A model's tool arguments are normalized against the tool's own schema *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* Models hand back arguments that are nearly right: a JSON array as a string, a value escaped twice. Normalization repairs exactly the cases the tool's own schema can vouch for, and leaves the rest for validation to report honestly. ### The rule > **Normative.** This is the rule. > > 1. Before a tool is invoked, the arguments a model supplied are reconciled with the tool's declared input schema. > > 2. A string argument is decoded as JSON only where all three hold: the schema declares that parameter `array` or `object`, the string parses as JSON, and the parsed shape matches what was declared: a list for `array`, a map for `object`. > > 3. A parameter declared `string` is never decoded, and a parsed value of the wrong shape is never substituted; an argument that fails any of the three is passed through unmodified. > > 4. Independently, every string leaf of an argument has its HTML character references decoded, repeated until the value stops changing so that a doubly-escaped value resolves fully rather than one level short; an implementation may bound the number of passes. > > 5. Keys are never modified, and text containing no character reference is returned unchanged. ## What it means The two repairs are independent and run on different things. JSON-string decoding looks only at whether the tool's own schema declares that parameter `array` or `object`, and only fires when the parsed result actually has that shape — a string that parses as JSON but produces the wrong shape (an object where the schema declares an array) is left exactly as sent, never coerced or substituted. Entity decoding, by contrast, runs on every string leaf regardless of what the schema declares, repeating until the value stops changing, so a doubly-escaped value resolves fully rather than stopping one level short. Keys are never touched by either repair. ## Example A title argument arrives HTML-escaped twice over: ```json title="A doubly-escaped title argument" verdict="sent" { "title": "Ember &amp; Oak" } ``` ```json title="What reaches the tool" verdict="decoded" { "title": "Ember & Oak" } ``` An object literal sent for a parameter the schema declares `array` is left untouched rather than substituted into the wrong shape: ```json title="An object literal where the schema declares an array" verdict="sent" { "ops": "{\"op\":\"add\"}" } ``` ### References **Normative** — incorporated into this rule: - HTML Standard, Named character references (https://html.spec.whatwg.org/multipage/named-characters.html) — The set of named references a string argument is decoded against. ## RT-TOOL-9 — A tool call executes at most once per run *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* A tool call has side effects. One node can be reached twice in a run (by a fan-in, or by a re-ask after a human approved a gated call), and the same batch of calls arrives with it. ### The rule > **Normative.** This is the rule. > > 1. A run records each tool call it has executed, keyed by the call identifier, and does not invoke a call whose identifier it has already recorded. > > 2. The repeat is reported on a `skipped` output, and the stored result of its first execution (the tool-role message and whether it succeeded) is re-emitted among the run's tool messages and results and counted in the batch's outcome. > > 3. Re-emitting rather than dropping is required: a batch that paused midway never delivered the already-executed call's message downstream, so dropping the repeat would leave a declared call identifier unanswered and invite the model to retry it under a fresh, unguardable one. > > 4. A call is recorded only once it has returned a result, success or a recoverable error, and never before it is invoked, so a call that interrupts the node for human approval is re-asked and executed once approved, rather than looking already-executed and being silently dropped. > > 5. A call that carries no identifier cannot be guarded and always runs. ### Related rules - Names: RT-TOOL-10 - Referenced by: RT-TOOL-10 ## RT-TOOL-10 — A tool-calling pass reports whether it did any work *RT-TOOL (Part II) · level: extended · profiles: runtime · added in 1.0* An all-repeat pass returns a full list of tool messages and a successful outcome while having invoked nothing, so an agent loop gated on "messages non-empty" re-enters forever. This is the port that answers the question directly. ### The rule > **Normative.** This is the rule. > > 1. A node that invokes tools reports `executed_any`, a boolean, and `executed`, the call identifiers behind it, the complement of the `skipped` list. > > 2. `executed_any` is true when at least one call was handled for the first time in this run, which includes a call naming a tool that is not wired: nothing was invoked, but the recoverable "not an available tool" result is new and the model must re-plan against it. > > 3. An empty batch reports false. > > 4. The two lists partition only the calls that reach the guard: a call with no identifier always runs and appears in neither while setting `executed_any`, and a malformed call (one that is not a map, or whose name is blank or not a scalar) is dropped before the guard, appears in neither, and leaves `executed_any` false. > > 5. `executed_any` is exposed by default; `executed` is not. ### Related rules - Names: RT-TOOL-9 - Referenced by: RT-TOOL-9 ## RT-TOOL-5 — Artifact collection is opened and released per run *RT-TOOL (Part II) · level: optional · profiles: runtime · added in 1.0* Collecting artifacts means holding payloads in memory, so the run that opens collection is the run that ends it, on every way out. ### The rule > **Normative.** This is the rule. > > 1. An implementation need not collect tool artifacts at all. > > 2. Where it does: an artifact is retained only while collection is open for the run it belongs to, and an artifact arriving for a run whose collection was never opened is dropped. > > 3. Draining what has been collected does not end collection — a later tool call in the same run still collects — and only releasing it does; an artifact arriving after release is dropped. > > 4. Collection is opened and released on the same path, so a run that returns, pauses or fails releases it either way and cannot retain artifacts beyond its own life. > > 5. Whether artifacts are collected must not depend on which entry point launched the run. ## What it means Collection is opt-in per run, not automatic: a tool call that produces an artifact while nobody has opened collection for its run does not queue up waiting for someone to ask — it is simply not kept. Nothing later can recover it, and opening collection afterwards does not reach back for it. Draining what has been collected so far is not the same as ending collection: a run can drain, then have a later tool call in the same run still add to what it collects. Only release ends collection outright. ## Example A tool call produces an artifact for a run that never opened collection: ```json title="What the tool call produced" verdict="produced" { "type": "citation", "payload": { "ref": "a" }, "tool_call_id": "call_1" } ``` ```json title="What the run's collection holds" verdict="dropped" [] ``` --- # RT-SG — SG (Part II) ## SG-1 — State merges field by field, and a state value is never mutated *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A state update is merged into the current state field by field: `messages` appends, `data` and `metadata` merge key by key, and every other field is replaced. > > 2. A field the update omits keeps its current value. > > 3. The merge yields a new state; the state it was applied to is not modified. ## What it means Only two fields get anything like a deep merge, and even those merge one level, not recursively without limit: `messages` appends, `data` and `metadata` merge key by key. Every other field — a loop's current node, its iteration count — is replaced outright by whatever the update carries, never combined with what was there. A field the update leaves out is not reset to a default; it keeps whatever the current state already holds. The merge never touches the state it started from. Applying an update produces a new state; the state it was applied to still reads exactly as it did before, so anything else still holding a reference to it sees no change. ## Example A state carries `{"existing": "value"}` under `data` and an empty `currentNodeId`, and receives this update: ```json title="The update applied to it" {"data": {"new": "data"}, "currentNodeId": "node_2"} ``` ```json title="What the merge yields" verdict="merged" {"data": {"existing": "value", "new": "data"}, "currentNodeId": "node_2"} ``` `data` comes out holding both keys; `currentNodeId` comes out holding only the update's value. The state the update was applied to still reads `currentNodeId` as `""` — the merge used it without changing it. ### Related rules - Names: SG-3 - Referenced by: SG-2, SG-3 ## SG-2 — A node sees run state only where it asks for it, and none of it is persisted *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Three separate guarantees. > > 2. The runtime input handed to a node carries the reserved internal names `__state__`, `__messages__` and `__data__`, plus `__iterator__` and `__current_item__` whenever the state carries an iterator, and `__interrupt_id__` when the node is being resumed; a node sees one of these only where its own parameter schema declares it, and a declared internal parameter always accepts its runtime value. > > 3. Separately, the live state is handed only to a node type that declares itself state-aware, and never to one that does not. > > 4. And the input recorded against the node's execution has every reserved internal name removed, so no state or interrupt internal is ever persisted, even though the node received it. ### Related rules - Names: SG-1 ## SG-3 — A node's state update is applied through the reducers once it completes *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node's `state_update` output is applied to the run state through the same field-by-field reducers as any other update, once the node has completed. > > 2. It is a control output and is never removed by output exposure, whatever the author exposed. ### Related rules - Names: SG-1, DATA-8 - Referenced by: SG-1 ## SG-4 — A ForEach node initializes its iterator once and completes on an empty list *RT-SG (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A ForEach node initializes its iterator on the round where the state carries none, and requires items to iterate. > > 2. A value that is not a list is wrapped as a single item, and the items are re-indexed so that iteration is positional. > > 3. An empty list completes the node immediately rather than entering the loop. > > 4. On completion the node emits its defined output set, including the results collected across the rounds it ran. ## What it means An empty list is not a loop that runs zero times — it is completion on the first round, with the node's whole defined output set produced immediately, current item included as absent rather than left unset. A value that is not a list at all is not refused either: it is wrapped as the one item a single round iterates, re-indexed the same way any list would be, so a round that receives a bare scalar behaves exactly like a round over a one-item list. ## Example ```json title="A ForEach launched with no items" {"items": []} ``` ```json title="What it emits on that round" verdict="completed" {"loop_active": false, "current_item": null, "total_count": 0, "collected_results": []} ``` ```json title="A ForEach launched with one scalar, not a list" {"items": "scalar_value"} ``` ```json title="What it emits on that round" verdict="wrapped" {"current_item": "scalar_value", "current_index": 0, "total_count": 1, "is_first": true, "is_last": true} ``` ### Related rules - Names: SG-5, SG-7 - Referenced by: SG-5 ## SG-5 — loop_back is a value on a ForEach node and a bare signal everywhere else *RT-SG (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. On a ForEach node the reserved `loop_back` input carries the round's item result, standing in for an explicit `item_result` input wherever that is absent. > > 2. On every other node type the reserved `loop_back` input is a re-entry signal only: the value is delivered on the port and read by nobody, since a node with no iteration state has nothing to fold it into. > > 3. A node type that wants the value declares its own port for it, which is exactly what suppresses the reserved injection. ## What it means `loop_back` is not one signal with two names. On a ForEach node it carries data: whatever arrives on it stands in for the round's `item_result` when that port is not otherwise filled, so a loop body that only wires its result back to `loop_back` still folds it in. On every other node type the same port carries nothing readable — a value delivered there is read by nobody, because a node with no iteration state has nothing to fold a result into. A node type that wants the value declares its own port under that name, and that declaration is exactly what stops the reserved, dataless injection. ## Example A ForEach node mid-loop, resumed with a value on `loop_back` instead of on `item_result`: ```json title="The re-entry input" {"loop_back": "loopback_result"} ``` ```json title="What the next round reports" verdict="advanced" {"current_item": "b"} ``` The loop advances exactly as it would have if the same value had arrived on `item_result` — `loop_back` supplied the round's result, not a re-entry signal with no payload. ### Related rules - Names: SG-4 - Referenced by: SG-4 ## SG-6 — Routing is decided per edge and dispatched per target *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > Whether to follow an edge is decided edge by edge, but dispatch is per target node: several followed edges arriving at one target produce a single dispatch of that target, not one per edge. ### Related rules - Names: SG-9 - Referenced by: SG-9 ## SG-7 — A loop's budget counts rounds of the loop, not executions of a node *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Re-entry over a loopback edge happens only while the edge's branch is active and the loop still has budget: either its iterator has more items, or the number of rounds the loop has run is below the configured maximum. > > 2. That bound is per loop, keyed by the loop's head, and counts rounds rather than executions of any one node in the body. > > 3. The two coincide only on a body where every node runs every round; on a body with a conditional arm, a node that runs on some rounds only lags behind, and counting its executions would let the loop keep re-entering after the author's budget was spent. > > 4. The round counters are restored when a paused run resumes, so the bound holds across a pause. ## What it means The budget belongs to the loop, not to any node inside it. A loop with a conditional arm can have a node that only runs on some rounds; counting that node's own executions would under-count the rounds actually spent, and the loop would keep re-entering after the budget the author set was gone. The round count is kept per loop, identified by the loop's head, precisely so a lagging node inside it cannot buy the loop extra rounds. That count has to survive a pause. A run paused mid-loop and resumed later does not start the loop's counter over: the round each loop had reached is recovered, so the same budget that would have applied without the pause still applies after it. ## Example A loop paused mid-run has left five jobs behind for its one loop head, each carrying the round it was stamped with — one carrying no stamp: ```json title="The paused run's jobs, each carrying the round it ran under" [ {"id": 11, "metadata": {"loops": {"reason": 2}}}, {"id": 1, "metadata": {"loops": {"reason": 0}}}, {"id": 12, "metadata": {"loops": {"reason": 3}}}, {"id": 2, "metadata": {"loops": {"reason": 0}}}, {"id": 3, "metadata": {}} ] ``` ```json title="What the resumed run restores the loop's round count to" verdict="restored" {"reason": 3} ``` The job with no stamp at all contributes nothing and resets nothing. ### Related rules - Names: SG-16, SG-17, SG-14 - Referenced by: ORC-10, SG-4, SG-10, SG-14 ## SG-8 — A node executed more than once keys its results by occurrence *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A node's first execution in a run is reported under its bare node identifier; each later execution is reported under `{nodeId}:{n}`, where `n` is that node's zero-based count of prior executions in the run, and the payload of a stop raised by the node uses the same key. > > 2. Partial results reported for an interrupted run use an equivalent scheme. > > 3. Each completed execution is stamped with a higher execution order than the one before it, so a port fed by several sources resolves to the newest execution of a source node: inside a loop, the current round's. ## What it means Only the first execution of a node keeps its bare identifier. Every later execution — a body re-entering a loop — is reported under `{nodeId}:{n}`, counting from zero, so the second execution is `:1` and the third `:2`, not `:2` and `:3`. A reader who assumes the suffix counts the execution itself, rather than how many came before it, will look for the wrong key. The same ordering decides what a port sees when several sources feed it: the execution stamped with the highest order wins, which inside a loop means a node reading from an upstream source always gets that source's most recent pass, never a stale one left over from an earlier iteration. ## Example A node that runs three times in one run reports its results under these keys, first pass bare: ```json title="Three completed executions of one node" verdict="keyed" ["loop_node", "loop_node:1", "loop_node:2"] ``` ### Related rules - Names: DATA-2 - Referenced by: DATA-2 ## SG-9 — An edge condition never decides routing *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A condition stored on an edge never gates dispatch: not on any edge, and not on a loopback edge, the one place dispatch genuinely decides. > > 2. The edge is stored, the edge is followed, and a warning is reported for it once per edge each time its source's outgoing edges are resolved. ## What it means A condition stored on an edge looks like it should gate whether that edge is followed, and it does not — not on an ordinary edge, and not on a loopback edge either, which is the one place dispatch actually does decide whether to re-enter. The edge is stored unchanged and always followed; the condition is reduced to a warning, so a workflow carrying one keeps its shape and keeps working, but the author is told the expression is not consulted. The warning is emitted each time the source's outgoing edges are resolved, not once per edge overall. A source visited more than once — a loop body — warns again on every pass. ## Example An edge storing a condition that would, if it still gated, suppress it: ```json title="An edge with a stored condition" verdict="stored" { "id": "e_conditioned", "source": "src", "target": "sink", "data": { "condition": "false" } } ``` ```json title="What the target receives anyway" verdict="delivered" { "message": "payload" } ``` ### Related rules - Names: SG-6 - Referenced by: SG-6 ## SG-10 — A loopback driven from the trigger port belongs to no branch *RT-SG (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > Where a loopback edge's source is the reserved `trigger` port, the re-entry it drives is attributed to no branch. ## What it means A loopback edge's branch is read off the port it leaves its source by, and `trigger` is a control signal, not a named branch — leaving by it extracts to no branch at all. Where a source declares branches and gates re-entry by which one fired, a loopback edge driven from `trigger` sits outside that gate: the re-entry it drives happens regardless of which branch, if any, the source reports as active. ## Example A loopback edge leaves its source by the reserved trigger port rather than by a branch port such as the one whose handle ends in `False`. ```json title="The source handle of a loopback edge leaving the trigger port" "gw.1-output-trigger" ``` ```json title="The branch name extracted from it" verdict="unbranched" "" ``` An empty branch name is what attributes a loopback edge to no branch; the same extraction on a handle ending in `False` yields `False`. ### Related rules - Names: SG-7 ## SG-11 — Every node completion writes a checkpoint, and the run writes a last one *RT-SG (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A run writes a checkpoint as each node completes, each chained to the checkpoint before it, and a final checkpoint named `workflow_end` when the run ends. > > 2. An implementation offers at least an ephemeral checkpoint store, which lives no longer than the request that created it, and a durable one, which outlives it. > > 3. A run that asks for a checkpoint store the implementation does not know is refused. ### Related rules - Names: SG-12 - Referenced by: SG-12 ## SG-12 — Where a run's starting state comes from *RT-SG (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A run's starting state is resolved in a fixed order: an explicit initial snapshot; else the checkpoint the caller named (an identifier that resolves to nothing is a warning and the run starts fresh, while a checkpoint of a run that has already ended is refused); else the latest state for the caller's thread; else a fresh state. > > 2. Seeding from a thread whose last run ended keeps what the thread accumulated (its messages, data, metadata and thread identifier), and clears everything the finished run owned: its outcome, so the new run reports its own, and its execution position, including the iterator, the iteration count and the current node, so the new run starts its loops from the beginning. > > 3. Seeding from a run that is paused rather than ended restores it untouched, which is what keeps a pause taken mid-loop resumable. ## What it means A run's starting state is not one thing: seeding a new turn from a thread whose last run ended keeps what the thread itself accumulated and clears everything the finished run owned. The two halves pull in different directions, and it is easy to keep the wrong one. Kept: the messages, the data, the metadata, the thread identifier — what a conversation carries forward. Cleared: the outcome, so the new turn reports its own rather than inheriting a finished one's, and the execution position — the iteration count, the current node, the iterator — so the new turn's loops start from the beginning rather than resuming mid-iteration inside a turn that never asked to resume. Seeding from a run that is paused rather than ended is not this path at all: a pause is restored untouched, execution position included, which is what lets a pause taken mid-loop actually resume there. ## Example A finished run's state, cancelled, with one turn's worth of accumulated conversation and execution position: ```json title="A cancelled run's state" verdict="cancelled" { "status": "cancelled", "isComplete": true, "data": { "k": "v" }, "metadata": { "m": 1 }, "threadId": "thread_1", "currentNodeId": "node_a", "iterationCount": 3 } ``` ```json title="What seeds the next turn on that thread" verdict="cleared" { "status": null, "isComplete": false, "error": null, "data": { "k": "v" }, "metadata": { "m": 1 }, "threadId": "thread_1", "currentNodeId": "", "iterationCount": 0, "iterator": null } ``` ### Related rules - Names: SG-11, SG-13 - Referenced by: INT-13, SG-11, SG-13 ## SG-13 — A run records the configuration it resolved and resumes on it *RT-SG (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A run records the execution configuration it resolved, including the thread it runs on and the checkpoint store it uses. > > 2. A later resume or deferred launch of that run rebuilds its configuration from that record, so an edit to the workflow cannot change the configuration of a run already in flight. > > 3. Only where no such record exists does configuration fall back to the workflow's own settings, and then to defaults, bounded by the maximum number of iterations the caller allowed. ### Related rules - Names: SG-12, SG-17 - Referenced by: SG-12 ## SG-14 — Exceeding the iteration budget ends the run, it does not pause it *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A state update that would push the run's iteration count strictly past the configured maximum ends the run. > > 2. The budget's own last iteration is allowed; only exceeding it ends the run. > > 3. The run then reports status `max_iterations_exceeded` with no results, together with the configured maximum, the count reached, and the node that reached it. > > 4. This is a terminal verdict and not a pause: no resume handle is offered, and the status is never `paused`. ## What it means Reaching the configured maximum is allowed; only going past it ends the run. A caller who sets the budget expecting the last permitted iteration to still happen is right — the guard only trips on the update that would push the count strictly beyond the maximum. Where it does trip, the run does not pause. Nothing about the verdict looks like the pause a run takes to wait on an interrupt or a deferred step: no resume handle is offered, and the status reported is never `paused`. The response carries no partial results, only the terminal status together with the maximum that was configured, the count actually reached, and which node's update reached it. ## Example A run configured with a maximum of 5, whose current node pushes the count to 999: ```json title="What the run reports" verdict="terminal" { "status": "max_iterations_exceeded", "results": [], "metadata": { "max_iterations": 5, "current_iterations": 999, "node_id": "loop_node" } } ``` ### Related rules - Names: SG-7 - Referenced by: ORC-10, SG-7 ## SG-15 — A run reports the identifier of the run it created *RT-SG (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The execution identifier a run reports is the identifier of the run record the engine created, and it replaces any identifier the caller supplied. > > 2. That identifier is the one a client polls for the run's progress, and the progress it returns is keyed by the node identifiers the author drew. > > 3. An engine that reported a synthetic identifier of its own, or echoed back the caller's, would leave every poll unanswered and freeze the client at idle. ## SG-16 — What a loop is (its body, and that it is keyed by its head) *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* A loopback edge on a canvas draws a cycle, but the loop is the set of nodes that cycle actually turns. Nearly everything about rounds, budgets and staleness rests on this definition. ### The rule > **Normative.** This is the rule. > > 1. For a loopback edge running from tail S back to head H, the loop's body is `({H} ∪ descendants(H)) ∩ ({S} ∪ ancestors(S))`, computed over the forward graph alone: every edge type excluded from execution is excluded here too. > > 2. The 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, and not two loops. > > 3. Membership is topological, not observed: a node on a conditional arm inside the body is in the loop even on rounds it does not run, while a node downstream of the tail but not upstream of it, and a dead end that cannot reach the tail, are both outside it, since neither can influence a later round. > > 4. A loopback edge whose tail is not downstream of its head closes no cycle and yields a body containing only the head, rather than being discarded. ## What it means A loopback edge is drawn from one node back to an earlier one, but the loop it closes is not "everything the loopback edge touches" — it is the intersection of what the head can still reach and what can still reach the tail, over the forward graph alone. An edge type excluded from execution (a tool wire, an agent-result wire, the loopback edge itself) is excluded from that computation too, so a tool consumer downstream of the loop never pulls the loop's body wider than it is. Two clauses are easy to get backwards. First, a loop is keyed by its head, not by the loopback edge that closes it: two tails re-entering the same node are two continuations of one loop with one round counter, not two loops each counting its own rounds. Second, a loopback edge whose tail cannot reach its head closes no cycle at all — but that is not treated as if no loop existed; it yields a body containing only the head. Membership is topological, not observed. A node on a conditional arm inside the body is in the loop on every round, including the rounds it does not run, because what decides membership is whether the topology lets it influence a later round — not whether this particular round happened to reach it. ## Example A gateway's two arms both loop back into the same head: ```json title="Two tails re-entering one head" [ {"source": "head", "target": "gateway"}, {"source": "gateway", "target": "tail_a"}, {"source": "gateway", "target": "tail_b"}, {"source": "tail_a", "target": "head"}, {"source": "tail_b", "target": "head"} ] ``` ```json title="The loop the two tails resolve to" verdict="unioned" {"head": ["gateway", "head", "tail_a", "tail_b"]} ``` One loop, one body — not two loops sharing a head. A loopback edge whose tail cannot reach its head behaves differently again: ```json title="A loopback edge whose tail cannot reach the head" [ {"source": "a", "target": "b"}, {"source": "elsewhere", "target": "head"} ] ``` ```json title="The body it resolves to" verdict="head-only" ["head"] ``` The loop still exists — it is not discarded — its body is simply the head alone, with nothing between it and itself to re-run. ### Related rules - Names: SG-17, SG-18 - Referenced by: ORC-10, SG-7, SG-17, SG-18 ## SG-17 — A loop's extent is fixed at launch and every execution carries its rounds *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A run computes its loops once, when it starts, and works from that result for the rest of its life: an author editing the workflow cannot redraw the body of a loop that is already several rounds deep. > > 2. Alongside the bodies, the run records for each body node the set of nodes that can reach it over the forward graph, the node itself included. > > 3. That set is not restricted to the loop's body, because a node outside a loop can legitimately be an ancestor of one inside it, and a restricted set would report that nothing can still produce a value while such an input was still on its way. > > 4. Every execution of every node carries a stamp of the rounds it belongs to, one entry per loop, outermost loop first, including an empty stamp for a node in no loop, and including round 0, so that an absent stamp cannot mean "outside every loop" and "inside one at round 0" at the same time. > > 5. When a paused run resumes, its round counters are rebuilt by replaying those stamps in creation order, newest winning; never by taking the highest round recorded for a loop, which is the high-water mark of some earlier outer round and would spend an inner loop's budget before the current outer round had begun. ## What it means A run's loops are computed once, at launch, and the run works from that result for the rest of its life. An author who edits the workflow mid-run cannot redraw the body of a loop that is already several rounds deep. Alongside each body, the run also records — for every node in it — the set of nodes that can still reach it over the forward graph, the node itself included. That set is deliberately not bounded by the loop's body: a node upstream of the loop can be an ancestor of a node inside it, and if the recorded set stopped at the body's edge, a liveness check reading it would conclude nothing could still produce a value while that outside input was still on its way. Every execution of every node carries a stamp of the rounds it belongs to, one entry per loop, outermost first — including an empty stamp for a node in no loop, and including round 0 explicitly, so an absent entry can never be read as either "outside every loop" or "inside one at round 0". When a paused run resumes, its round counters come back by replaying those stamps in the order the executions were created, the newest stamp for each loop winning. Taking the highest round ever recorded for a loop instead is the intuitive-looking shortcut, and it is wrong: a loop nested inside another gets reset every time the outer round advances (SG-18), so the highest figure on record can belong to an earlier outer round entirely, and resuming from it spends an inner loop's budget before the current outer round has even begun. ## Example A loop fed from outside itself: `load_corpus` is upstream of the loop but never inside it. ```json title="A loop with an out-of-loop node feeding into it" [ {"source": "head", "target": "fact_check"}, {"source": "load_corpus", "target": "fact_check"}, {"source": "fact_check", "target": "tail"}, {"source": "tail", "target": "head"} ] ``` ```json title="What can still reach fact_check" verdict="unrestricted" ["fact_check", "head", "load_corpus"] ``` A run pauses after its inner loop has spent five rounds under the outer loop's round 0, the outer round then advances and resets the inner one, and the inner loop is one round into outer round 1 when the pause happens. Six executions, oldest first, each carrying the rounds it ran under: ```json title="Six executions' round stamps, oldest first" [ {"outer_head": 0, "inner_head": 0}, {"outer_head": 0, "inner_head": 4}, {"outer_head": 0, "inner_head": 5}, {"outer_head": 1}, {"outer_head": 1, "inner_head": 0}, {"outer_head": 1, "inner_head": 1} ] ``` ```json title="What the resumed run restores the rounds to" verdict="restored" {"outer_head": 1, "inner_head": 1} ``` Taking the highest inner figure on record would restore the inner loop to 5 — the budget it spent under an outer round that no longer applies — and the loop would stop rounds early for the rest of the run. ### Related rules - Names: SG-16, SG-18, SG-19, SG-20 - Referenced by: SG-7, SG-13, SG-16, SG-18, SG-19, SG-20 ## SG-18 — Nested loops keep separate rounds, tangled ones are merged *RT-SG (Part II) · level: extended · profiles: runtime, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Where one loop's body is a strict subset of another's, the two stay distinct and each carries its own round, ordered outermost first. > > 2. Advancing an outer loop's round resets every loop nested inside it to round 0; without that an inner loop would spend its budget once for the whole run rather than once per outer round, and inner rounds would not be comparable across outer rounds at all. > > 3. Loops that share nodes without one containing the other (including two heads with identical bodies) admit no consistent assignment of rounds to the shared nodes, and are merged into a single loop over the union of their bodies, keyed by the largest body with a lexicographic tie-break. > > 4. Merging rather than refusing is deliberate: a coarser loop is still sound, because it never claims two nodes are in different rounds where the topology cannot say so. > > 5. Both this merge and a loopback edge that closes no cycle are reported to the author as static diagnostics on the workflow rather than as run-time warnings, since each is a property of the graph and addresses whoever drew the edges. ## What it means Two loops can relate to each other in three ways, and only one of them leaves both loops standing as drawn. Where one loop's body is a strict subset of another's, the two stay distinct, each with its own round — reported outermost first wherever a node reports which loops it belongs to. Advancing the outer loop's round resets every loop nested inside it back to round 0; without that reset an inner loop would spend its whole budget once for the entire run, and a round number on the inner loop would mean nothing across different passes of the outer one. Where two loops share nodes without one containing the other — including two heads whose bodies turn out identical — there is no consistent way to assign a round to the shared nodes, and the engine does not refuse the workflow over it. It merges the two into one coarser loop over the union of their bodies, keyed by whichever head has the larger body, tied lexicographically. The merge is sound rather than a compromise: a coarser loop never claims two nodes are in different rounds where the topology cannot say so. Both a merge like this and a loopback edge that closes no cycle are surfaced to the author as static diagnostics on the workflow, not as run-time warnings — each is a property of the graph itself, not of any one run of it. ## Example An inner loop nested inside an outer one: ```json title="An inner loop nested inside an outer one" [ {"source": "outer_head", "target": "inner_head"}, {"source": "inner_head", "target": "inner_tail"}, {"source": "inner_tail", "target": "outer_tail"}, {"source": "inner_tail", "target": "inner_head"}, {"source": "outer_tail", "target": "outer_head"} ] ``` ```json title="The two loops the graph resolves to" verdict="distinct" {"inner_head": ["inner_head", "inner_tail"], "outer_head": ["inner_head", "inner_tail", "outer_head", "outer_tail"]} ``` The shared node `inner_tail` reports its loops outermost first: `outer_head` before `inner_head`. Two loops that overlap without either containing the other resolve differently: ```json title="Two loops sharing one node, neither containing the other" [ {"source": "head_a", "target": "shared"}, {"source": "head_b", "target": "shared"}, {"source": "shared", "target": "tail_a"}, {"source": "shared", "target": "tail_b"}, {"source": "tail_a", "target": "head_a"}, {"source": "tail_b", "target": "head_b"} ] ``` ```json title="What the two merge into" verdict="merged" {"head_a": ["head_a", "head_b", "shared", "tail_a", "tail_b"]} ``` Both bodies are the same size, so the lexicographic tie-break decides: `head_a` sorts first and survives as the merged loop's key. ### Related rules - Names: SG-16, SG-17, SG-19 - Referenced by: SG-16, SG-17, SG-19 ## SG-19 — A source from an earlier round does not satisfy an edge *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* Inside a loop, a completed node is not necessarily a current one. Without this, a consumer on round N happily reads the value its source produced on round N-1. ### The rule > **Normative.** This is the rule. > > 1. A completed source does not satisfy a trigger, error or data edge when the source and the consumer share a loop and the source's round on that loop is behind the consumer's. > > 2. Behindness is decided by intersecting the two round stamps' loops and comparing lexicographically, outermost loop first: the first shared loop on which the two differ settles it, and nothing nested inside it can overturn that. > > 3. Where the two share no loop the source is never behind: a source outside every loop the consumer is in will never fire again, its value is the current one forever, and gating it would hang the consumer on a round that cannot arrive. > > 4. The verdict differs by edge class: a gated trigger or error edge leaves the consumer waiting, to be skipped when the run ends, while a gated data port must be terminated. ## What it means Inside a loop, a completed source is not automatically a current one. A consumer on round two that reads a source's value from round one is reading last round's answer as if it were this round's, and the rule exists to stop exactly that: a completed source stops satisfying a trigger, error or data edge once the source and the consumer share a loop and the source is behind the consumer on it. Behindness is not "behind on any shared loop" — it is decided by intersecting the two round stamps and comparing them lexicographically, outermost loop first. The first shared loop where the two differ settles the question, and nothing nested inside that loop can overturn the verdict, even if the inner numbers alone would look like the source is lagging. Where the two share no loop at all, the source is never behind. A source outside every loop its consumer is in will never fire again on its own terms, so its value is the current one forever — gating it on a round that cannot arrive would hang the consumer permanently. The consequence differs by edge class. A gated trigger or error edge simply leaves the consumer waiting, to be swept up and skipped once the run ends. A gated data port cannot be left waiting the same way; it has to be terminated (SG-20). ## Example ```json title="A source stamped behind its consumer on their shared loop" verdict="stale" {"source": {"loop_a": 0}, "consumer": {"loop_a": 1}} ``` ```json title="A source outside every loop its consumer is in" verdict="current" {"source": {}, "consumer": {"loop_a": 3}} ``` ```json title="A source ahead on the outer loop but behind on the inner" verdict="current" {"source": {"outer": 2, "inner": 0}, "consumer": {"outer": 1, "inner": 5}} ``` ```json title="A source equal on the outer loop but behind on the inner" verdict="stale" {"source": {"outer": 1, "inner": 0}, "consumer": {"outer": 1, "inner": 2}} ``` The outermost shared loop always decides first: ahead on the outer loop outweighs behind on the inner one, and only once the outer rounds match does the inner comparison get a say. ### Related rules - Names: SG-17, SG-18, SG-20, DATA-4 - Referenced by: ERR-10, BR-6, DATA-4, SG-17, SG-18, SG-20 ## SG-20 — A data port no live producer can still fill fails its consumer *RT-SG (Part II) · level: core · profiles: runtime · added in 1.0* The round barrier on its own turns a gateway that routes away from an in-loop source into a silent hang. This is the clause that makes it terminate, and the restrictions are what keep it from failing workflows that were doing nothing wrong. ### The rule > **Normative.** This is the rule. > > 1. A data port held back by the round comparison stops waiting once no job still to run, pending or running can reach the source node over the forward graph, with the source counted as able to reach itself. > > 2. A live loop head, being an ancestor of everything in its own body, therefore holds the port open across a re-entry, and so does an ancestor outside the loop still on its way. > > 3. A job whose own round is behind the consumer's does not count as live, or a job left waiting for a branch the run never took would hold the port open for the rest of the run. > > 4. Four restrictions bound the verdict. > > 5. It applies to data edges only, and only on a consumer with no trigger and no error edge. > > 6. Every source on the port must share a loop with the consumer: one out-of-loop source keeps the port fillable. > > 7. At least one edge on the port must actually have been held back by the round comparison; a source that was simply never dispatched is an ordinary skipped branch and none of this rule's business, which is what makes this a termination clause for the round comparison rather than a general unfillable-port rule. > > 8. And the run must carry the reachability sets the test needs. > > 9. The verdict is that the consumer fails, and it is a node failure like any other: with an incoming error edge the failure is routed, so an author catches an unfillable port exactly where they catch anything else, and without one it fails the run. > > 10. The failure names the port, the source, and the consumer's round on the innermost loop the two share; it names the gateway that routed away only where that is determinable from the source's own incoming branch edges, and otherwise says only that no value for this round can still be produced. ## What it means The round barrier in SG-19 stops a stale source from satisfying an edge, but on its own that turns a gateway that routes away from an in-loop source into a silent hang: the consumer waits for a round that will never come. This rule is what ends the wait. A data port held back by the round comparison stops waiting once no execution still to run, pending or in progress can reach the source over the forward graph — the source counted as able to reach itself. A live loop head, being an ancestor of everything in its own body, holds the port open across a re-entry on that basis alone, and so does an out-of-loop ancestor still on its way; an execution whose own round is already behind the consumer's does not count as live, or a branch the run never took would hold the port open forever. Four restrictions keep this from over-firing. It applies to data edges only, and only to a consumer with no trigger and no error edge. Every source on the port must share a loop with the consumer — one out-of-loop source is enough to keep the port fillable regardless of the rest. At least one edge on the port must actually have been held back by the round comparison; a source that was simply never dispatched is an ordinary skipped branch, not this rule's business. And the run must carry the reachability sets the check depends on. Where it fires, the port's consumer fails exactly like any other node failure — routed down an error edge if the author wired one, failing the run if not. The failure names the port, the source, and the consumer's round on the innermost loop the two share; it names the gateway that routed away only where that is determinable from the source's own incoming branch edges, and otherwise says only that no value for this round can still be produced. ## Example A gateway heading the loop routes only to `write` on round one, so `fact_check` — the only in-loop source of `assembler`'s `citations` port — is never redispatched, and nothing still running can reach it. ```json title="What the round-one failure reports" verdict="failed" "port 'citations' cannot be satisfied in round 1 — its only in-loop source 'fact_check' was skipped by gateway 'planner'" ``` ### Related rules - Names: SG-17, SG-19, DATA-4 - Referenced by: BR-6, DATA-4, SG-17, SG-19 --- # RT-PIPE — PIPE (Part II) ## PIPE-1 — Run status is read from persisted state, on two routes and one envelope *RT-PIPE (Part II) · level: core · profiles: runtime · added in 1.0* The live picture of a run is whatever has been persisted for it, not what some in-memory tracker happens to remember. A poll therefore answers the same value to every caller, including one served by a process that has just started. ### The rule > **Normative.** This is the rule. > > 1. A run's status is published by two reads: the full pipeline document and a lightweight status document. > > 2. Both answer the standard `{success: true, data}` envelope, publish the persisted lifecycle value verbatim as `data.status`, and answer `404` with `{success: false, error}` for a run that does not exist and `403` for one the caller may not view. > > 3. The full document adds `node_statuses`, `jobs`, `job_status_summary` and `execution_data` alongside `id`, `name`, `description`, `createdAt`, `lastExecuted`, `executionCount` and `timestamp`; the lightweight document carries `id`, `status`, `createdAt`, `lastExecuted`, `pendingInterrupt` and `pausedReason` and must not carry the jobs payload. > > 4. Both read persisted state only, so a status written outside the request path is what the next poll returns. ### Related rules - Referenced by: PIPE-9 ## PIPE-2 — The job status summary has one shape on every path *RT-PIPE (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A run's `job_status_summary` is `total` followed by one integer counter per defined job status, in a fixed order, with no status omitted. > > 2. The same key set and order is published when the jobs are read successfully, when the run cannot be found, and when the read fails, the latter two all-zero. > > 3. The job API's own `status_summary` publishes that same shape. ### Related rules - Names: PIPE-3 - Referenced by: PIPE-3 ## PIPE-3 — Status vocabulary is guarded in the summary and raw in the node counts *RT-PIPE (Part II) · level: extended · profiles: runtime · added in 1.0* Two counters in the same payload count the same jobs on different terms, and a consumer that reads one as if it were the other draws the wrong conclusion. ### The rule > **Normative.** This is the rule. > > 1. In `job_status_summary` and the job API's `status_summary`, `total` counts every job, but a bucket is incremented only for a persisted value that is a defined job status. > > 2. The buckets may therefore sum to less than `total`, and a value outside the vocabulary must never appear as a key there. > > 3. Each read reports the distinct unrecognised values once. > > 4. The per-node `node_statuses[*].status_counts` is deliberately not guarded: it counts persisted values as they are, so a value outside the vocabulary does appear both as a key there and as `node_statuses[*].status`. ### Related rules - Names: PIPE-2, PIPE-4 - Referenced by: PIPE-2, PIPE-4 ## PIPE-4 — Node statuses are keyed by workflow node id and collapse every iteration *RT-PIPE (Part II) · level: core · profiles: runtime · added in 1.0* This is what an editor looks a badge up by, so the key has to be the id the canvas holds. A node that ran many times still has one entry, with the per-iteration picture inside it. ### The rule > **Normative.** This is the rule. > > 1. `node_statuses` is keyed by the workflow node id (the id the stored workflow gives the node), never by a job identifier, a node-type identifier or an iteration-suffixed variant. > > 2. Iterations of a node produced by a loop carry the plain node id, so every iteration collapses onto one entry. > > 3. Only nodes that produced work get a key; a node excluded from execution has no entry and reads as idle. > > 4. Within a collapsed entry, `status` is the status of the newest job in the group, with the later job in run order winning a tie; `last_executed`, `execution_time` and `execution_time_us` come from the most recent job that actually started, and are null when none did; `error` comes from that started job, otherwise from the newest; `executions` counts only jobs that started; and `status_counts` counts every job in the group. > > 5. A job entry's `node_id` carries the same ids. ### Related rules - Names: PIPE-3, PIPE-7 - Referenced by: PIPE-3, PIPE-7 ## PIPE-5 — A run's jobs are the ones the run itself names *RT-PIPE (Part II) · level: extended · profiles: runtime · added in 1.0* Every surface that publishes jobs answers from the run's own list of them, so the full document, the job list and the job status summary can never disagree about which jobs a run has. ### The rule > **Normative.** This is the rule. > > 1. A run holds the reference to each job as that job is created, and that reference is the only source from which jobs are resolved: the full run document, the run's job list and the run's job status summary must all publish the same set. > > 2. A job must not be attributed to a run by scanning jobs for a stamp, because nothing is required to write one. > > 3. Where a single job is read outside the context of a run, its reported `pipeline_id` is the most recent run that references it among those the caller may view; candidates are ordered so that two identical requests answer identically, and a candidate the caller may not view is walked past rather than returned as null, since seeing a job is not authority to learn which run it belonged to. > > 4. The walk is bounded, and null means no candidate is viewable. > > 5. More than one referencing run is a broken invariant and is reported, naming the job and every candidate, rather than resolved by an arbitrary pick. > > 6. Each run-scoped surface additionally filters its jobs by per-job view authority: authority over a run never implies authority over its jobs. ### Related rules - Names: PIPE-7 - Referenced by: PIPE-6, PIPE-7 ## PIPE-6 — Authorization is decided before the handler, and travels with its cacheability *RT-PIPE (Part II) · level: core · profiles: runtime · added in 1.0* A response cached for one principal must never be served to another. That holds only if the authorization decision and the cache metadata describing what it depended on stay together. ### The rule > **Normative.** This is the rule. > > 1. Authorization for a run's job surfaces is decided before the handler runs, and the decision's cache metadata travels with it, so a response cached for one principal cannot be served to another. > > 2. The decision is the entity's own authorization result, forwarded unchanged rather than reduced to a boolean; a handler must not re-implement a subset of the model, because doing so both locks out principals the surface admits and ignores extension points the model honours. > > 3. Authority over a job is not authority over a run: a run the caller may not view is refused before anything about it (its jobs, its summary, its label) is assembled. > > 4. Absence is answered before denial, so a request for an identifier that does not exist is `404` even where a caller holding the surface's authority could not have viewed it; this is a decided trade, and identifiers must therefore carry no information beyond their existence. ### Related rules - Names: PIPE-5, SNAP-2 - Referenced by: SNAP-2 ## PIPE-7 — One job entry shape, published identically by every surface *RT-PIPE (Part II) · level: extended · profiles: runtime · added in 1.0* Three surfaces publish a job. They publish one key set in one order, because two copies of a formatter had already drifted apart once. ### The rule > **Normative.** This is the rule. > > 1. Every surface that publishes a job entry emits exactly the keys `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`, in that order: the single-job read, a run's job list and the jobs inside a run's full document. > > 2. `pipeline_id` is a string or null; a stored value that is neither an integer nor a string is discarded rather than published under a key documented as a string, and the discard is reported, because only something outside the implementation writes a non-scalar there. > > 3. `execution_time_us` prefers the precise duration recorded for the job and otherwise derives it from the start and completion stamps at second granularity, and is null for a job that never completed; the same value must be published for a job by `node_statuses` and by the job entry, computed once, so the two payloads read side by side can never disagree numerically. ### Related rules - Names: PIPE-4, PIPE-5 - Referenced by: PIPE-4, PIPE-5 ## PIPE-8 — Published execution context is filtered, not forwarded *RT-PIPE (Part II) · level: extended · profiles: runtime · added in 1.0* Whatever an engine parks on a run would otherwise become public the moment it is written, and after one release it cannot be withdrawn without breaking consumers. ### The rule > **Normative.** This is the rule. > > 1. The `execution_data.context` a run publishes is a filtered projection of the execution context, not the stored context forwarded verbatim, and the filter is a single point through which everything published passes. > > 2. Engine-internal analysis artefacts (a graph-analysis snapshot such as a loop membership map is the case in point) are removed whole rather than by selected sub-keys, so their internal shape is not published and may change without notice. ## PIPE-9 — A timestamp is never fabricated *RT-PIPE (Part II) · level: extended · profiles: runtime · added in 1.0* A run that has not finished has no finish time, and a missing creation time is missing. Publishing "now" in place of either makes a consumer believe something that did not happen. ### The rule > **Normative.** This is the rule. > > 1. `lastExecuted` is the run's completion stamp, or null where the run has not completed, on the full document and the lightweight one alike. > > 2. `createdAt` is the run's creation stamp, or null where none is recorded, on both documents equally. > > 3. No timestamp is substituted for a missing one. ### Why Recorded under OPEN-19. ### Related rules - Names: PIPE-1 --- # RT-PLAY — PLAY (Part II) ## PLAY-1 — Playground sessions and messages are addressed and published by UUID *RT-PLAY (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Every playground route parameter naming a session or a message is a UUID, and every `id` published (a session row's, a message row's, and a message row's `sessionId`) is that UUID, never an internal record identifier. > > 2. The single exception is the session list's `ids` filter, which takes a comma-separated list of internal identifiers, dropping values that are not positive integers: it is a narrowing of what the caller can already see, not a client-facing identifier. > > 3. So that one door accepts internal identifiers inbound and answers UUIDs outbound, deliberately. ### Related rules - Names: PLAY-5 - Referenced by: PLAY-2, PLAY-5 ## PLAY-2 — Creating a session answers the same row the list publishes *RT-PLAY (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* A client can insert a creation response straight into its list without re-fetching, which is what makes one shared row shape load-bearing rather than tidy. ### The rule > **Normative.** This is the rule. > > 1. Creating a session answers `201` and reading one answers `200`, both under `{success, data}`, and in both cases `data` is exactly the row the session list publishes in its `data[]`: `id`, `workflowId`, `name`, `status`, `createdAt`, `updatedAt`, `metadata`, `executions`, `owner`, in that order. > > 2. `owner` is exactly `{id, name}`. > > 3. `createdAt` and `updatedAt` are ISO 8601 strings, never numeric timestamps. > > 4. `executions` is an empty list, not null, for a session that has never run. ### Related rules - Names: PLAY-1 ## PLAY-3 — The message poll has its own envelope, with the flags at the top level *RT-PLAY (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* The three flags beside the data are what a polling client reads to decide whether to fetch again and whether the turn is over. Folding them into the shared pagination block would break every such client. ### The rule > **Normative.** This is the rule. > > 1. The message poll answers `{success, data, hasMore, hasOlder, sessionStatus}` in that order, with no pagination block and no `has_more` key: the three flags are siblings of `data`, not nested. > > 2. `hasMore` reports page fullness (whether the page returned as many messages as were asked for) and is an inference. > > 3. `hasOlder` is authoritative: it reports whether messages older than the page exist, so a client scrolling back never pays a speculative empty fetch at an exact page boundary. > > 4. `sessionStatus` rides along so a poller needs no second request to learn the turn has finished. > > 5. The forward and backward cursors are honoured only when they are strings of digits; a cursor that is not is ignored, not refused. ### Related rules - Names: PLAY-4 - Referenced by: PLAY-4 ## PLAY-4 — One message row, three doors, base keys always present *RT-PLAY (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. The message list, the single-message read and the send acknowledgement publish the same message row. > > 2. Its base keys (`id`, `sessionId`, `role`, `content`, `timestamp`, `status`, `sequenceNumber`, `nodeId`, `metadata`, in that order) are always present. > > 3. The lineage and presentation keys `hierarchy`, `tags`, `display`, `toolArtifacts`, `parentMessageId`, `executionId`, `rootPipelineId` and `parentPipelineId` are appended only when the message carries them, and are absent otherwise rather than present and null. > > 4. The single-message read resolves lineage on the same terms as the list, so its row is not a lesser one. > > 5. `timestamp` is an ISO 8601 string. > > 6. The lightweight message status read is a different, four-key document (`id`, `status`, `sequenceNumber`, `timestamp`) and is not this row. ### Related rules - Names: PLAY-3 - Referenced by: INT-22, PLAY-3 ## PLAY-5 — The session list is ownership-scoped, and an emptied filter means none *RT-PLAY (Part II) · level: extended · profiles: runtime, editor-client · added in 1.0* The failure mode this rules out is an explicit request for no sessions answered with every sibling session in the workflow. ### The rule > **Normative.** This is the rule. > > 1. A caller without authority to view any session sees only sessions they own: the ownership condition binds the list and its total count alike, on the default path and on the filtered path, so the `ids` filter can only narrow what the caller could already see. > > 2. Where the filter is present but every value in it parses away, the list answers an empty page with a total of zero; it must not fall through to the unfiltered list. ### Related rules - Names: PLAY-1 - Referenced by: PLAY-1 --- # RT-SNAP — SNAP (Part II) ## SNAP-1 — The snapshot API carries two casings, and acceptance is the lenient half *RT-SNAP (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* Envelope keys are snake_case and the snapshot document inside them is camelCase, so `execution_id` and `executionId` are published by the same read one nesting level apart. Both are read by consumers; neither may be normalised toward the other. ### The rule > **Normative.** This is the rule. > > 1. The snapshot API's envelope-level keys 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. > > 2. The snapshot document published under `data.snapshot` is the snapshot's own camelCase shape: `workflowId`, `executionId`, `nodeStates`, `initialInput`, `iterationCount`, `threadId`, `createdAt`, `updatedAt`. > > 3. The save door accepts either spelling in its body and normalises; a read always answers camelCase. > > 4. `node_count` is the number of node states in the snapshot, not a count taken from the raw stored payload. ### Related rules - Names: SNAP-2 - Referenced by: SNAP-2 ## SNAP-2 — Snapshot access is decided on the snapshot, and absence answered first *RT-SNAP (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. Reading or deleting a snapshot loads it by execution id and asks it for view or delete authority: a snapshot that does not exist is `404`, one the caller may not reach is `403`, and absence is answered before denial. > > 2. A read that finds the record but no readable payload answers `404` as well, never a partial document. > > 3. The list door does not ask per record: it narrows the query by ownership, so a snapshot belonging to another principal is absent from the list rather than a denial on it. ### Related rules - Names: SNAP-1, PIPE-6 - Referenced by: PIPE-6, SNAP-1 --- # RT-META — META (Part II) ## META-1 — The category list is the standard envelope, and its name is a machine name *RT-META (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* This is the list that fills an editor's node sidebar. A consumer that treats `name` as human-readable renders a machine name to an author. ### The rule > **Normative.** This is the rule. > > 1. The category list answers the standard `{success, data}` envelope; a cacheable response carries byte-identical envelope keys to a non-cacheable one, and a door must not hand-build a body to avoid it. > > 2. `data` is a list of rows whose keys are `name`, `label`, `icon`, `color`, `description`, in that order, every value a string. > > 3. `name` is the category's identifier, not a display name, and there is no separate `id` key. > > 4. Only enabled categories are published: a disabled one is absent rather than flagged, so a consumer cannot distinguish it from a deleted one. ### Related rules - Referenced by: META-3, META-9 ## META-3 — A failed read answers a fixed message and reports the real one *RT-META (Part II) · level: extended · profiles: runtime, editor-client · added in 1.0* The category door was the counter-example: it returned whatever the storage layer said and logged nothing, so a class name or a failed query was published to any caller who could read the list. ### The rule > **Normative.** This is the rule. > > 1. Where a read fails, the response body carries a fixed message naming what failed and nothing more; the underlying error is reported through the implementation's own error channel, never published to the caller. > > 2. This binds cacheable responses exactly as it binds every other kind: an error shape is not exempt because it is served from a different response path. ### Related rules - Names: META-1 ## META-4 — The workflow schema door publishes a bare document *RT-META (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* A workflow's declared input and output ports, read by an editor before it can draw the workflow. It is not wrapped in the standard envelope, and its snake_case is as much a contract as the playground's camelCase. ### The rule > **Normative.** This is the rule. > > 1. A successful read of a workflow's schema answers exactly `{schema_version, parameter_schema, output_schema}` in that order, snake_case, with no `success` key and no `data` wrapper. > > 2. `parameter_schema` and `output_schema` are the stored snapshot verbatim, and where a workflow declares no ports they are JSON `null` (not an empty object, and not omitted), so a consumer can distinguish "no ports" from "key missing". ### Related rules - Names: META-5, META-6 - Referenced by: META-5, META-6, META-9, STORE-8 ## META-5 — An unknown workflow's schema is a bare error document *RT-META (Part II) · level: extended · profiles: runtime, editor-client · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A schema read for a workflow that does not exist answers `404` with the body `{"error": "Workflow not found."}`: one key, no `success` key, and the trailing full stop part of the literal. > > 2. Absence is answered before anything else is read, so an unknown identifier never reaches the schema-version or entity-tag logic. ### Related rules - Names: META-4 - Referenced by: META-4 ## META-6 — The entity tag carries the body variant, not just the schema version *RT-META (Part II) · level: core · profiles: runtime, editor-client · added in 1.0* A missing variant in the tag was a live cross-repo cache bug: a client holding the plain body asked for the annotated one, was told it was unchanged, and went on serving a document with every title and description missing. ### The rule > **Normative.** This is the rule. > > 1. The entity tag for a workflow schema is the quoted schema version for the plain document, and the quoted schema version plus a variant marker for the annotated one. > > 2. Any request argument that changes the body must join the tag the same way: two documents that differ must never share a tag, whatever else varies on them. ### Related rules - Names: META-4, META-7, META-8 - Referenced by: META-4, META-7, META-8, STORE-8 ## META-7 — A conditional schema request is exact string equality and nothing more *RT-META (Part II) · level: extended · profiles: runtime, editor-client · added in 1.0* A partial wildcard implementation would report "unchanged" to a client that holds no copy at all, which is worse than answering unconditionally. ### The rule > **Normative.** This is the rule. > > 1. `If-None-Match` on a workflow schema read is compared for exact string equality against the tag of the variant being requested. > > 2. A match answers `304` carrying the entity tag and the body `{}`, and does so before the schema itself is assembled. > > 3. A mismatch answers the full `200`. > > 4. The wildcard `*`, a comma-separated list of tags and weak comparison are not implemented: a caller sending any of them receives a correct but unconditional `200`. > > 5. Each variant answers its own conditional request, so carrying the variant in the tag costs no variant a `304`. ### Related rules - Names: META-6 - Referenced by: META-6 ## META-8 — The two schema variants are cached on deliberately different terms *RT-META (Part II) · level: extended · profiles: runtime, editor-client · added in 1.0* The annotated document reattaches translated text from live plugins, so a shared copy would serve one interface language's annotations to a reader in another. ### The rule > **Normative.** This is the rule. > > 1. The plain workflow schema document is publicly cacheable for at most 60 seconds. > > 2. The annotated document is served `no-cache`. > > 3. A schema response varies by the argument selecting the variant on every path, including the one that adds nothing else, and varies by interface language only when annotated. > > 4. A response is invalidated when the workflow it describes changes, and by nothing broader. ### Related rules - Names: META-6 - Referenced by: META-6 ## META-9 — The editor metadata doors are read-only and gated before the handler *RT-META (Part II) · level: extended · profiles: runtime, editor-client · added in 1.0* Neither door has a handler-side check to fall back on, so what the surface declares is the whole access contract. ### The rule > **Normative.** This is the rule. > > 1. The category door and the workflow schema door accept `GET` and no other method, and each requires a named authorization decided before the handler runs: neither carries a handler-side check as a backstop. > > 2. The set of methods and the named authorization are part of the contract (widening either is a visible change), and a named authorization the implementation does not define is a defect, not a locked door. ### Related rules - Names: META-1, META-4 --- # RT-OCX — OCX (Part II) ## OCX-1 — External invocation builds flat initial data *RT-OCX (Part II) · level: core · profiles: runtime · added in 1.0* The payload has to arrive where the trigger's advertised output schema says it is. Keying it by node id put it somewhere only an expression naming that node could reach. ### The rule > **Normative.** This is the rule. > > 1. The initial data for an externally invoked run is flat: the extracted trigger data at the top level, plus the identifiers of the trigger configuration and the trigger node, the same shape every other trigger kind builds. > > 2. Since a trigger emits its whole initial data as the node's `data` output, the caller's payload is reachable at `data.payload`, which is what the trigger's declared output schema advertises. > > 3. The initial data must not be keyed by node id. ### Related rules - Names: OCX-3 - Referenced by: OCX-3, OCX-6 ## OCX-2 — External results are retrieved by polling, and only by polling *RT-OCX (Part II) · level: core · profiles: runtime · added in 1.0* A contract of omission: the connector dispatches nothing outbound. Widening it means taking on delivery, timeouts, retry and de-duplication, which belong elsewhere. ### The rule > **Normative.** This is the rule. > > 1. Results of an externally invoked run are retrieved by the caller polling. > > 2. The connector dispatches no outbound request and subscribes to no runtime execution event. > > 3. A run that leaves no persisted record is invisible to a poller, so an execution engine that produces one is refused for external invocation: the configured choice is reported and the run is carried out on a pollable engine instead, rather than silently producing a run no caller can ever observe. ### Related rules - Names: OCX-8 - Referenced by: OCX-8 ## OCX-3 — Polls are scoped by a positive marker, defined once *RT-OCX (Part II) · level: core · profiles: runtime · added in 1.0* Scoping by the presence of a trigger configuration served cron, entity and form runs (output data included) to any external platform that asked. ### The rule > **Normative.** This is the rule. > > 1. A poll answers only runs that were externally invoked, identified by a positive source marker carried in the run's initial data. > > 2. Scoping must not be inferred from the presence of a trigger configuration identifier, which every trigger kind sets. > > 3. The marker has exactly one definition, shared by the code that writes it and the code that reads it, so producer and consumer cannot drift apart, a drift that manifests as a poll returning nothing forever, with no error. > > 4. The marker travels in the run's initial data, not in the options that configure the orchestrator, which never reach it. ### Related rules - Names: OCX-1, OCX-9 - Referenced by: OCX-1, OCX-9 ## OCX-4 — External invocation honours the configured pipeline identity and mode *RT-OCX (Part II) · level: extended · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. An externally invoked run resolves its pipeline identifier and pipeline mode from the trigger's orchestrator settings, exactly as every other trigger kind does. > > 2. It must not construct an identifier of its own, which silently discards reuse and singleton modes. ### Related rules - Names: OCX-7 - Referenced by: OCX-7 ## OCX-6 — The connector validates nothing it publishes *RT-OCX (Part II) · level: extended · profiles: runtime · added in 1.0* The published payload schema is metadata for the calling platform to render and enforce. Enforcement belongs to that platform and to the workflow, not to the adapter in between. ### The rule > **Normative.** This is the rule. > > 1. The declared payload schema and required fields an externally invoked trigger publishes are declarative metadata for the calling platform; a payload that violates them is passed through unmodified. > > 2. Only top-level schema properties become typed fields in the published service description; nested structure rides inside the payload as an object, and recursive translation of a schema into fields is deliberately not attempted. ### Related rules - Names: OCX-1 ## OCX-7 — One execution identifier correlates the invocation with every poll *RT-OCX (Part II) · level: core · profiles: runtime · added in 1.0* This single identifier is what makes an asynchronous round trip work through a synchronous invocation signature. ### The rule > **Normative.** This is the rule. > > 1. An external invocation answers immediately with exactly one of three statuses: `completed` for a finished run, `interrupted` for one paused awaiting an interrupt whose identifier the response carries, or `queued`. > > 2. In all three cases the `execution_id` it answers is the persisted run's identifier, the same value a poll response reports as `pipeline_id`. > > 3. An engine that answers a synthetic request identifier instead has its persisted identifier republished here, so the correlation identifier never varies by engine. ### Related rules - Names: OCX-4, OCX-8 - Referenced by: OCX-4 ## OCX-8 — A poll reports every terminal run, cancellation included *RT-OCX (Part II) · level: core · profiles: runtime · added in 1.0* ### The rule > **Normative.** This is the rule. > > 1. A poll reports runs in any terminal status, not only successful and failed ones (a cancelled run is terminal and is reported), so a polling caller always reaches an end state. > > 2. A run that is genuinely paused appears in no poll until it resumes or reaches a terminal status. > > 3. A backlog may need more than one poll to drain, and nothing is dropped on the way. ### Related rules - Names: OCX-2, OCX-9 - Referenced by: OCX-2, OCX-7, OCX-9 ## OCX-9 — Polls page, and paging never strands the caller *RT-OCX (Part II) · level: extended · profiles: runtime · added in 1.0* A first poll used to select every terminal run the installation had ever produced and load the lot, which is an out-of-memory failure rather than a slow query. ### The rule > **Normative.** This is the rule. > > 1. Every poll is bounded to a page: a first poll, or one whose cursor is empty or unparseable, costs no more than any other. > > 2. Paging is only sound if the caller can always walk to the end, so two things bind. > > 3. A page ordered by completion time is extended to cover every run sharing the last row's completion value, because completion times are not unique and a caller advancing its cursor past a split group would skip the remainder for good; the ordering is made total by a unique tiebreak so the held-back group is a clean suffix. > > 4. And a poll must not answer empty while rows remain: where scoping (OCX-3) is applied after the page is read, a page can filter down to nothing, and an empty answer reads to the caller as "nothing new" and parks its cursor forever, so paging continues until something is emitted or the result set is exhausted. ### Related rules - Names: OCX-3, OCX-8 - Referenced by: OCX-3, OCX-8 ## OCX-5 — A trigger's condition set is a closed vocabulary *RT-OCX (Part II) · level: extended · profiles: runtime · added in 1.0* A connector with its own data to carry has one place to put it, so a reader can tell a declared condition from a connector's private key. ### The rule > **Normative.** This is the rule. > > 1. A trigger's conditions are a closed vocabulary: the condition keys this specification declares, and no others alongside them. > > 2. Event-type-specific data a connector needs to carry is written under the designated extension key, whose contents this specification does not constrain. > > 3. A connector writing its own keys beside the declared ones is writing an invalid condition set. --- # RT-NET — NET (Part II) ## NET-1 — An outbound URL is checked before the request, and the check is pinned to it *RT-NET (Part II) · level: core · profiles: runtime · added in 1.0* Validating a name and then dialling the name is not a check: a hostile resolver answers publicly for the check and privately for the request. ### The rule > **Normative.** This is the rule. > > 1. Before a node makes a request to a URL derived from workflow input, the URL is validated: only `http` and `https` are allowed, the host is resolved (over both IPv4 and IPv6), and any address in a private or reserved range is refused. > > 2. A refusal is a node configuration error, which takes the error edge rather than failing the run. > > 3. The validated address is what the request must then be made to: the connection is pinned to the address that was checked, honouring an explicit port. > > 4. Every node that dials a caller-supplied URL performs this check; a second implementation of it is a second thing to forget to fix. ### Related rules - Names: NET-2, NET-3 - Referenced by: NET-2, NET-3 ## NET-2 — Every redirect hop is re-validated before it is taken *RT-NET (Part II) · level: core · profiles: runtime · added in 1.0* Pinning binds the original host only. Without this, a public host answering a redirect to a link-local metadata address walks straight past a guard that has already reported success. ### The rule > **Normative.** This is the rule. > > 1. Each redirect target is validated on the same terms as the original URL, before the hop is taken, and a hop resolving into a private or reserved range is refused. > > 2. Hops are re-validated, not refused: a redirect from one public host to another (a shortener, a canonical-host bounce, an upgrade to HTTPS) is still followed, so this is no change for workflows that are not being attacked. > > 3. The residual window is stated rather than papered over: a hop's host is resolved for the check and resolved again for the request, so per-hop rebinding remains possible where the original request's pinning excludes it. ### Related rules - Names: NET-1 - Referenced by: INT-20, NET-1, NET-3 ## NET-3 — The redirect posture is stated, not inherited *RT-NET (Part II) · level: extended · profiles: runtime · added in 1.0* These restate what most HTTP clients already default to, deliberately: the posture is a decision, not whatever the client happens to ship. ### The rule > **Normative.** This is the rule. > > 1. Outbound requests follow at most 5 redirects. > > 2. A redirect of a POST degrades to GET rather than preserving the method. > > 3. The originating URL and its query string are never sent to the next host as a referrer. > > 4. Hops are restricted to `http` and `https`, so the scheme check cannot be sidestepped mid-chain. > > 5. Where a node is configured to permit internal requests, the per-hop check is dropped exactly as the initial check is (a node allowed to talk to the internal network may also be redirected within it), but the hop limit binds either way. ### Related rules - Names: NET-1, NET-2 - Referenced by: NET-1 --- # RT-MD — MD (Part II) ## MD-1 — Markdown source can never reach the reader as markup *RT-MD (Part II) · level: extended · profiles: runtime · added in 1.0* A markdown-to-HTML conversion usually runs over text a language model wrote, so the encoding is a safety boundary rather than a formatting nicety. Encoding the whole input once, before any block or inline pattern is applied, is the arrangement that makes the promise hold on every arm: escaping at each emission point has been shown to miss the blocks a converter rebuilds from its buffer, and to miss any block whose first character is already a tag. ### The rule > **Normative.** This is the rule. > > 1. Every text node a markdown-to-HTML conversion emits must be HTML-encoded: prose, list items, blockquote lines, headings and code alike. > > 2. Markup present in the markdown source must never reach the reader as markup, including where a block opens with a tag. ### Related rules - Referenced by: MD-2 ## MD-2 — Encoded exactly once, and quoted where a quote would end an attribute *RT-MD (Part II) · level: extended · profiles: runtime · added in 1.0* The counterpart to encoding the whole input up front: nothing downstream may encode a second time, and the few places a value is interpolated into an attribute need the quote characters that the text path deliberately leaves alone. ### The rule > **Normative.** This is the rule. > > 1. Text is encoded exactly once. > > 2. A sequence the conversion itself wrote must not be encoded again: a `<` in the markdown source appears in the output as `<` and never as `&lt;`. > > 3. A text node may keep literal quote characters, which are harmless there. > > 4. A value interpolated into an attribute (a link target, an image source, an image alternative text) must have its quote characters escaped, so the value cannot end its own attribute and open an event handler. > > 5. A URL carrying a dangerous scheme must be stripped rather than emitted. ### Related rules - Names: MD-1 --- # RT-CRON — CRON (Part II) ## CRON-1 — A cleared schedule means not scheduled, and every reader says so *RT-CRON (Part II) · level: extended · profiles: runtime · added in 1.0* A schedule is invisible until it fires or fails to, so the component that decides a trigger is due and the one that reports when it will next run must never describe the same stored value differently. Blank-means-inactive is the reading every comparable scheduler uses. ### The rule > **Normative.** This is the rule. > > 1. A trigger whose cron expression is the empty string is not scheduled: it must not fire, and a report of when it will next run must say it has none. > > 2. Empty means exactly empty: an expression that merely looks unfilled, such as `0`, is a malformed expression and must be reported as invalid rather than as unset, so an operator is never told to fill in a field that is already filled in. > > 3. Whichever component decides that a trigger is due and whichever reports its next run must reach the same conclusion about the same stored schedule. ### Related rules - Names: CRON-2 - Referenced by: CRON-2 ## CRON-2 — Every reason a schedule has no next run carries a stable code *RT-CRON (Part II) · level: extended · profiles: runtime · added in 1.0* The report of a trigger's next run is the only view of a schedule an operator has. A report that cannot distinguish "fine, nothing due" from "will never fire" is the diagnostic being absent exactly when it is needed. ### The rule > **Normative.** This is the rule. > > 1. An absent next run, reported on its own, means only that nothing is currently due, the answer a perfectly healthy schedule gives between fires. > > 2. Every other reason a schedule yields no next run must be reported with a stable code: `no_expression` where the expression is cleared, `invalid_expression` where it is malformed, or well-formed but unsatisfiable. > > 3. Severity must not be flattened: an unusable timezone does not stop a trigger, since the implementation substitutes UTC, so such a schedule still reports a real next run alongside the code `invalid_timezone`; an expression problem, being the more consequential of the two, takes its place. > > 4. A broken stored schedule is not a broken request: the report is answered with 200 and carries the diagnosis. ### Related rules - Names: CRON-1 - Referenced by: CRON-1 --- # RT-TRIG — TRIG (Part II) ## TRIG-1 — Overlap is judged against the workflow's own unfinished runs *RT-TRIG (Part II) · level: extended · profiles: runtime · added in 1.0* The overlap decision is made unattended, and a wrong answer is either a duplicated production run or a silently dropped one. The four policies are four genuinely different behaviours, and the buffer is a one-slot mailbox rather than a queue. ### The rule > **Normative.** This is the rule. > > 1. Overlap is decided against runs of the same workflow that have not reached a terminal state: pending, running or paused. > > 2. A terminal run, and a run of any other workflow, are both ignored; with none active, every policy proceeds with the caller's trigger data unchanged. > > 3. With one active, Skip blocks the firing; Buffer defers it and blocks; Cancel cancels every active run, announcing each cancellation, and proceeds; Terminate first cancels every job those runs still have outstanding, so no worker can pick one up after the run is gone, and then does what Cancel does. > > 4. The buffer holds at most one deferred firing per trigger: a second firing while one is buffered is dropped, never stacked and never overwritten. > > 5. A buffered firing is released at the next firing once the run that blocked it is terminal or gone, and on release its own payload replaces the current firing's; a buffer whose blocking run can no longer be identified is released rather than left stranded. > > 6. A policy an implementation does not recognise must be treated as Skip, the only fail-safe direction, since every alternative destroys a running workflow. ## TRIG-2 — Jitter is rolled once and never re-rolled *RT-TRIG (Part II) · level: extended · profiles: runtime · added in 1.0* Spreading trigger load must not become a way for a trigger never to fire. The delay is drawn on the pass that finds the trigger due and then held to, however many passes follow. ### The rule > **Normative.** This is the rule. > > 1. Where a trigger's firing is spread by a random delay, a maximum of zero or less means the delay is off: the firing is not held and no state is recorded; it is not a zero-length window. > > 2. Otherwise the bounds are clamped before use, so a negative minimum becomes zero and a minimum above the maximum is used as both bounds. > > 3. On the first pass at which the trigger is due, one delay is drawn uniformly at random within the bounds, the resulting fire time is recorded, and the firing is held. > > 4. Every later pass reads the recorded fire time rather than drawing again (re-drawing on each pass would let a trigger be deferred indefinitely), and the firing is released as soon as the current time reaches that fire time, inclusive, discarding the record so the next due firing draws afresh. > > 5. The record is scoped to a single trigger, so one trigger's window never holds another back. ## TRIG-3 — A skip is recorded, but it is not an execution *RT-TRIG (Part II) · level: extended · profiles: runtime · added in 1.0* "When did this last actually run" is the question a trigger's record exists to answer, and it has to survive any number of skips in a row. ### The rule > **Normative.** This is the rule. > > 1. A trigger keeps, per trigger, the time it last ran, the number of times it has run, and a history. > > 2. Recording an execution advances all three. > > 3. Recording a skip appends a history entry carrying the reason for the skip and leaves the last-run time and the run count untouched. > > 4. The history is newest-first and holds at most the ten most recent entries from either writer, so an unattended trigger cannot grow its record without bound. > > 5. Stored state that is not in the shape expected is read as absent (no last run, a count of zero, an empty history) rather than failing the trigger. --- # RT-ST — ST (Part II) ## ST-1 — The status surface answered inside a success envelope *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. It answered with a `{success, data}` envelope on success and `{success: false, error}` on failure, alongside the HTTP status code. *This rule is deprecated. It is kept so it stays citable.* ## ST-2 — An execution was reported by id and status *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. It reported an execution as `{execution_id, status}`, answered 404 for an execution it held no record of, and 500 where the reporting itself failed. *This rule is deprecated. It is kept so it stays citable.* ## ST-3 — Node statuses passed through, and an untracked run was empty rather than absent *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. It passed the recorded node statuses through unchanged, and answered a request for an execution it held no record of with an empty map rather than a 404. *This rule is deprecated. It is kept so it stays citable.* ## ST-4 — The detail view carried derived run metrics *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. Its detail view carried metrics (total, completed, failed and pending node counts and a total execution time), each derived from the node records it held. *This rule is deprecated. It is kept so it stays citable.* ## ST-5 — An execution was reported with a fixed field set *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. An execution was reported with the fields status, workflow_id, node_count, start_time, end_time, total_execution_time and error, spelled in snake_case. *This rule is deprecated. It is kept so it stays citable.* ## ST-6 — A node was reported with a fixed field set *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. A node was reported with the fields status, node_id, node_type, start_time, end_time, execution_time, error and output. *This rule is deprecated. It is kept so it stays citable.* ## ST-7 — The status vocabulary was closed *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. Its status vocabulary was initialized, idle, running, completed, failed and interrupted. > > 3. The vocabulary itself outlived the surface as the status set carried on runtime status broadcasts, there joined by skipped. *This rule is deprecated. It is kept so it stays citable.* ## ST-8 — An update naming an unknown run registered it rather than dropping it *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. An update naming an execution or a node it held no record of registered that execution or node instead of discarding the update, so a run started outside the reporting request could still be reported. *This rule is deprecated. It is kept so it stays citable.* ## ST-9 — Each transition carried a payload defined for it *RT-ST (Part II) · level: extended · profiles: runtime · added in 1.0 · posture: deprecated* ### The rule > **Normative.** This is the rule. > > 1. Retired with the polled status surface. > > 2. A transition to running carried the node type and start time, to completed the execution time and output size, to failed the error, and to interrupted the interrupt id and type. > > 3. These payloads outlived the surface, still riding the runtime status broadcasts. *This rule is deprecated. It is kept so it stays citable.*