{
  "specification": "FlowDrop Workflow Specification",
  "version": "1.0-draft",
  "source": "https://flowdrop.io/spec",
  "licence": "CC-BY-4.0",
  "generated": "from rules/*.yml — do not edit, and do not treat as the source of truth",
  "counts": {
    "rules": 399,
    "families": 30,
    "reserved": 22
  },
  "families": [
    {
      "name": "GR-STORE",
      "label": "STORE",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-store",
      "rules": [
        "STORE-1",
        "STORE-2",
        "STORE-3",
        "STORE-4",
        "STORE-5",
        "STORE-7",
        "STORE-9",
        "STORE-10",
        "STORE-11",
        "STORE-12",
        "STORE-13",
        "STORE-14",
        "STORE-6",
        "STORE-8",
        "STORE-15"
      ]
    },
    {
      "name": "GR-API",
      "label": "API",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-api",
      "rules": [
        "API-1",
        "API-2",
        "API-3",
        "API-4",
        "API-5",
        "API-6",
        "API-7",
        "API-8"
      ]
    },
    {
      "name": "GR-VAL",
      "label": "VAL",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-val",
      "rules": [
        "R5.a",
        "R5.b",
        "R5.c",
        "R1.a",
        "R1.b",
        "R1.c",
        "R6.b",
        "R6.c",
        "R6.d",
        "R6.e",
        "R6.f",
        "R6.g",
        "R6.h",
        "R6.i",
        "R6.j",
        "R6.l",
        "R6.k",
        "R8.a",
        "R8.b",
        "R8.c",
        "R7.a",
        "R7.b",
        "R7.c",
        "R7.d",
        "R7.e/f",
        "R4.a",
        "R4.b",
        "R4.c",
        "R4.d",
        "R4.e",
        "W-T",
        "VAL-LAUNCH",
        "R2",
        "R3",
        "R9",
        "R10",
        "R11",
        "R12",
        "R13",
        "R6.a"
      ]
    },
    {
      "name": "GR-EDGE",
      "label": "EDGE",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-edge",
      "rules": [
        "EDGE-1",
        "EDGE-2",
        "EDGE-3",
        "EDGE-4",
        "EDGE-5",
        "EDGE-6",
        "EDGE-7",
        "EDGE-8",
        "EDGE-9"
      ]
    },
    {
      "name": "GR-SCHEMA",
      "label": "SCHEMA",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-schema",
      "rules": [
        "SCH-1",
        "SCH-2",
        "SCH-3",
        "SCH-4",
        "SCH-5",
        "SCH-6",
        "SCH-7",
        "SCH-8",
        "SCH-9",
        "SCH-10",
        "SCH-10.a",
        "SCH-11",
        "SCH-12",
        "SCH-13",
        "SCH-14",
        "SCH-15",
        "SCH-16",
        "SCH-17",
        "SCH-18",
        "SCH-19",
        "SCH-20",
        "SCH-21",
        "SCH-22",
        "SCH-23",
        "SCH-25",
        "SCH-26",
        "SCH-27",
        "SCH-28",
        "SCH-29",
        "SCH-31",
        "SCH-32",
        "SCH-33",
        "SCH-34",
        "SCH-35",
        "SCH-36",
        "SCH-37",
        "SCH-38",
        "SCH-38.a",
        "SCH-39",
        "SCH-40",
        "SCH-41",
        "SCH-42",
        "SCH-43",
        "SCH-44",
        "SCH-46",
        "SCH-24",
        "SCH-45"
      ]
    },
    {
      "name": "GR-CFG",
      "label": "CFG",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-cfg",
      "rules": [
        "CFG-1",
        "CFG-2",
        "CFG-3",
        "CFG-4",
        "CFG-5",
        "CFG-6",
        "CFG-7",
        "CFG-8",
        "CFG-9",
        "CFG-10",
        "CFG-11",
        "CFG-12",
        "CFG-13",
        "CFG-14",
        "CFG-15",
        "CFG-16",
        "CFG-17",
        "CFG-18"
      ]
    },
    {
      "name": "GR-EXPO",
      "label": "EXPO",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-expo",
      "rules": [
        "EXPO-1",
        "EXPO-2",
        "EXPO-3",
        "EXPO-4",
        "EXPO-5",
        "EXPO-6",
        "EXPO-7",
        "EXPO-8",
        "EXPO-10",
        "EXPO-11",
        "EXPO-12",
        "EXPO-13",
        "EXPO-14",
        "EXPO-15",
        "EXPO-17",
        "EXPO-16"
      ]
    },
    {
      "name": "GR-DYN",
      "label": "DYN",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-dyn",
      "rules": [
        "DYN-1",
        "DYN-2",
        "DYN-3",
        "DYN-4",
        "DYN-5",
        "DYN-6",
        "DYN-7"
      ]
    },
    {
      "name": "GR-MEM",
      "label": "MEM",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-mem",
      "rules": [
        "MEM-1",
        "MEM-2",
        "MEM-3",
        "MEM-4",
        "MEM-5",
        "MEM-6",
        "MEM-7",
        "MEM-8",
        "MEM-10",
        "MEM-11",
        "MEM-12",
        "MEM-13",
        "MEM-14",
        "MEM-16",
        "MEM-9",
        "MEM-15"
      ]
    },
    {
      "name": "GR-MAN",
      "label": "MAN",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-man",
      "rules": [
        "MAN-1",
        "MAN-2",
        "MAN-3",
        "MAN-5",
        "MAN-6",
        "MAN-7",
        "MAN-8",
        "MAN-9",
        "MAN-10",
        "MAN-11",
        "MAN-12",
        "MAN-13",
        "MAN-14",
        "MAN-15",
        "MAN-16",
        "MAN-17",
        "MAN-18",
        "MAN-19",
        "MAN-20",
        "MAN-21"
      ]
    },
    {
      "name": "GR-LANG",
      "label": "LANG",
      "part": "I",
      "url": "https://flowdrop.io/spec/rules/gr-lang",
      "rules": [
        "LANG-1",
        "LANG-2",
        "LANG-3",
        "LANG-4",
        "LANG-6",
        "LANG-7",
        "LANG-8",
        "LANG-9",
        "LANG-10",
        "LANG-11",
        "LANG-12",
        "LANG-13",
        "LANG-14",
        "LANG-15",
        "LANG-16",
        "LANG-17",
        "LANG-18",
        "LANG-19",
        "LANG-21",
        "LANG-22",
        "LANG-23",
        "LANG-24",
        "LANG-25",
        "LANG-26",
        "LANG-27",
        "LANG-28",
        "LANG-29",
        "LANG-20"
      ]
    },
    {
      "name": "RT-CMP",
      "label": "CMP",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-cmp",
      "rules": [
        "CMP-1",
        "CMP-2",
        "CMP-3",
        "CMP-4",
        "CMP-5",
        "CMP-6",
        "CMP-7",
        "CMP-8",
        "CMP-9",
        "CMP-10",
        "CMP-11"
      ]
    },
    {
      "name": "RT-ERR",
      "label": "ERR",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-err",
      "rules": [
        "ERR-1",
        "ERR-2",
        "ERR-3",
        "ERR-4",
        "ERR-5",
        "ERR-6",
        "ERR-7",
        "ERR-8",
        "ERR-9",
        "ERR-10",
        "ERR-11",
        "ERR-12",
        "ERR-13"
      ]
    },
    {
      "name": "RT-ORC",
      "label": "ORC",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-orc",
      "rules": [
        "ORC-1",
        "ORC-2",
        "ORC-3",
        "ORC-4",
        "ORC-5",
        "ORC-7",
        "ORC-8",
        "ORC-9",
        "ORC-10",
        "ORC-11",
        "ORC-12",
        "ORC-13",
        "INT-11",
        "INT-12",
        "ORC-14",
        "ORC-15"
      ]
    },
    {
      "name": "RT-BR",
      "label": "BR",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-br",
      "rules": [
        "BR-1",
        "BR-2",
        "BR-3",
        "BR-4",
        "BR-5",
        "BR-6",
        "BR-7"
      ]
    },
    {
      "name": "RT-DATA",
      "label": "DATA",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-data",
      "rules": [
        "DATA-1",
        "DATA-2",
        "DATA-3",
        "DATA-4",
        "DATA-5",
        "DATA-6",
        "DATA-7",
        "DATA-8",
        "DATA-9",
        "DATA-10",
        "DATA-11",
        "DATA-12"
      ]
    },
    {
      "name": "RT-INT",
      "label": "INT",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-int",
      "rules": [
        "INT-1",
        "INT-2",
        "INT-3",
        "INT-4",
        "INT-5",
        "INT-6",
        "INT-7",
        "INT-8",
        "INT-9",
        "INT-10",
        "INT-13",
        "INT-14",
        "INT-15",
        "INT-16",
        "INT-17",
        "INT-18",
        "INT-19",
        "INT-20",
        "INT-22",
        "INT-23"
      ]
    },
    {
      "name": "RT-GATE",
      "label": "GATE",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-gate",
      "rules": [
        "RT-GATE-1",
        "RT-GATE-2",
        "RT-GATE-3",
        "RT-GATE-4",
        "RT-GATE-5",
        "RT-GATE-6",
        "RT-GATE-7",
        "RT-GATE-8",
        "RT-GATE-9",
        "RT-GATE-11",
        "RT-GATE-12",
        "RT-GATE-13",
        "RT-GATE-14",
        "RT-GATE-15"
      ]
    },
    {
      "name": "RT-TOOL",
      "label": "TOOL",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-tool",
      "rules": [
        "RT-TOOL-1",
        "RT-TOOL-2",
        "RT-TOOL-3",
        "RT-TOOL-4",
        "RT-TOOL-6",
        "RT-TOOL-7",
        "RT-TOOL-8",
        "RT-TOOL-9",
        "RT-TOOL-10",
        "RT-TOOL-5"
      ]
    },
    {
      "name": "RT-SG",
      "label": "SG",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-sg",
      "rules": [
        "SG-1",
        "SG-2",
        "SG-3",
        "SG-4",
        "SG-5",
        "SG-6",
        "SG-7",
        "SG-8",
        "SG-9",
        "SG-10",
        "SG-11",
        "SG-12",
        "SG-13",
        "SG-14",
        "SG-15",
        "SG-16",
        "SG-17",
        "SG-18",
        "SG-19",
        "SG-20"
      ]
    },
    {
      "name": "RT-PIPE",
      "label": "PIPE",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-pipe",
      "rules": [
        "PIPE-1",
        "PIPE-2",
        "PIPE-3",
        "PIPE-4",
        "PIPE-5",
        "PIPE-6",
        "PIPE-7",
        "PIPE-8",
        "PIPE-9"
      ]
    },
    {
      "name": "RT-PLAY",
      "label": "PLAY",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-play",
      "rules": [
        "PLAY-1",
        "PLAY-2",
        "PLAY-3",
        "PLAY-4",
        "PLAY-5"
      ]
    },
    {
      "name": "RT-SNAP",
      "label": "SNAP",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-snap",
      "rules": [
        "SNAP-1",
        "SNAP-2"
      ]
    },
    {
      "name": "RT-META",
      "label": "META",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-meta",
      "rules": [
        "META-1",
        "META-3",
        "META-4",
        "META-5",
        "META-6",
        "META-7",
        "META-8",
        "META-9"
      ]
    },
    {
      "name": "RT-OCX",
      "label": "OCX",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-ocx",
      "rules": [
        "OCX-1",
        "OCX-2",
        "OCX-3",
        "OCX-4",
        "OCX-6",
        "OCX-7",
        "OCX-8",
        "OCX-9",
        "OCX-5"
      ]
    },
    {
      "name": "RT-NET",
      "label": "NET",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-net",
      "rules": [
        "NET-1",
        "NET-2",
        "NET-3"
      ]
    },
    {
      "name": "RT-MD",
      "label": "MD",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-md",
      "rules": [
        "MD-1",
        "MD-2"
      ]
    },
    {
      "name": "RT-CRON",
      "label": "CRON",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-cron",
      "rules": [
        "CRON-1",
        "CRON-2"
      ]
    },
    {
      "name": "RT-TRIG",
      "label": "TRIG",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-trig",
      "rules": [
        "TRIG-1",
        "TRIG-2",
        "TRIG-3"
      ]
    },
    {
      "name": "RT-ST",
      "label": "ST",
      "part": "II",
      "url": "https://flowdrop.io/spec/rules/rt-st",
      "rules": [
        "ST-1",
        "ST-2",
        "ST-3",
        "ST-4",
        "ST-5",
        "ST-6",
        "ST-7",
        "ST-8",
        "ST-9"
      ]
    }
  ],
  "rules": [
    {
      "id": "API-1",
      "family": "GR-API",
      "part": "I",
      "title": "Every JSON door applies the same body gate, and reports its refusals",
      "summary": "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.",
      "normative": "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. A refusal from that gate reaches the caller as the 400 it is; an implementation must not report it as a server error. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-1",
        "API-7"
      ],
      "backlinks": [
        "STORE-1",
        "INT-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-api/api-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-api/api-1.md"
    },
    {
      "id": "API-2",
      "family": "GR-API",
      "part": "I",
      "title": "The turn door and the launch door judge inputs identically",
      "normative": "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. The same body earns the same verdict at both doors. A turn must not accept, by merging raw caller input into the run's initial data, anything the launch door would refuse. A turn whose inputs are refused is refused with 400.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-13",
        "MAN-15"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-api/api-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-api/api-2.md"
    },
    {
      "id": "API-3",
      "family": "GR-API",
      "part": "I",
      "title": "A session with no workflow is a conflict, not a server error",
      "normative": "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. The response carries a generic message; the detail that identifies the session goes to the log and never into the response body.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "API-7",
        "API-8"
      ],
      "backlinks": [
        "API-7",
        "API-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-api/api-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-api/api-3.md"
    },
    {
      "id": "API-4",
      "family": "GR-API",
      "part": "I",
      "title": "Node configuration could be checked ahead of save",
      "normative": "A node's configuration could be checked ahead of save, by submitting the configuration on its own to a per-node-type validation endpoint. 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.",
      "posture": "deprecated",
      "level": "optional",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-api/api-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-api/api-4.md"
    },
    {
      "id": "API-5",
      "family": "GR-API",
      "part": "I",
      "title": "The paginated envelope is a third envelope with four fixed keys",
      "summary": "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.",
      "normative": "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. `has_more` is page arithmetic, `(offset + limit) < total`, and not a second query. 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. A door must not build a pagination block of its own.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "API-6"
      ],
      "backlinks": [
        "API-6"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-api/api-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-api/api-5.md"
    },
    {
      "id": "API-6",
      "family": "GR-API",
      "part": "I",
      "title": "Paging is clamped silently, and the clamped values are what is reported",
      "normative": "A paginated door caps `limit` at 100 and floors `offset` at 0. Out-of-range paging is corrected, never refused: there is no 400 for it. 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. `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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "API-5"
      ],
      "backlinks": [
        "API-5",
        "STORE-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-api/api-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-api/api-6.md"
    },
    {
      "id": "API-7",
      "family": "GR-API",
      "part": "I",
      "title": "A generic failure is a last resort, never a design",
      "summary": "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\".",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "API-3",
        "API-8"
      ],
      "backlinks": [
        "API-1",
        "API-3",
        "API-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-api/api-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-api/api-7.md"
    },
    {
      "id": "API-8",
      "family": "GR-API",
      "part": "I",
      "title": "Every refusal carries a stable machine-readable code",
      "summary": "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.",
      "normative": "Every refusal an API door emits carries a stable, machine-readable `error_code` alongside the human-readable `error` string. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-18"
      ],
      "related": [
        "STORE-5",
        "API-3",
        "API-7"
      ],
      "backlinks": [
        "STORE-5",
        "API-3",
        "API-7",
        "INT-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-api/api-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-api/api-8.md"
    },
    {
      "id": "BR-1",
      "family": "RT-BR",
      "part": "II",
      "title": "An edge is gated only when the source actually made a branch decision",
      "summary": "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.",
      "normative": "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). 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. In every other case the edge is followed.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "BR-2",
        "BR-3",
        "BR-5"
      ],
      "backlinks": [
        "BR-2",
        "BR-3",
        "BR-5",
        "BR-6",
        "BR-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-br/br-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-br/br-1.md"
    },
    {
      "id": "BR-2",
      "family": "RT-BR",
      "part": "II",
      "title": "The three cases in which a branch decision never gates an edge",
      "summary": "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.",
      "normative": "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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "BR-1",
        "BR-4"
      ],
      "backlinks": [
        "BR-1",
        "BR-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-br/br-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-br/br-2.md"
    },
    {
      "id": "BR-3",
      "family": "RT-BR",
      "part": "II",
      "title": "An active-branch list is a list of names, trimmed and lower-cased",
      "normative": "A source's active-branch list is a list of strings. A bare string is one branch name taken verbatim and is never split on commas or any other separator. Entries that are not strings are dropped. Surviving entries are trimmed of surrounding whitespace and lower-cased, which is the form BR-1 compares a port name against.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "BR-1"
      ],
      "backlinks": [
        "BR-1",
        "BR-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-br/br-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-br/br-3.md"
    },
    {
      "id": "BR-4",
      "family": "RT-BR",
      "part": "II",
      "title": "An outcome matching no branch is loud, never a silent gate-off",
      "summary": "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.",
      "normative": "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. 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. Two strings compare without regard to case, and anything else must be equal in both type and value. 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).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-15"
      ],
      "related": [
        "BR-2",
        "BR-3"
      ],
      "backlinks": [
        "BR-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-br/br-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-br/br-4.md"
    },
    {
      "id": "BR-5",
      "family": "RT-BR",
      "part": "II",
      "title": "Branch gating applies to trigger and data edges alike",
      "normative": "Branch gating applies to an edge regardless of the kind of port it arrives at. A trigger edge and a data edge leaving the same gated port are both gated, on the same test.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "BR-1"
      ],
      "backlinks": [
        "BR-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-br/br-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-br/br-5.md"
    },
    {
      "id": "BR-6",
      "family": "RT-BR",
      "part": "II",
      "title": "A stale source does not satisfy an edge",
      "summary": "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.",
      "normative": "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. 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. An edge whose source is gated by either test is unsatisfied, and a consumer therefore never runs on a mixture of rounds.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "BR-1",
        "BR-7",
        "DATA-4",
        "SG-19",
        "SG-20"
      ],
      "backlinks": [
        "BR-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-br/br-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-br/br-6.md"
    },
    {
      "id": "BR-7",
      "family": "RT-BR",
      "part": "II",
      "title": "A node whose triggers are all unsatisfied is skipped, not executed",
      "summary": "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.",
      "normative": "A node with at least one incoming trigger edge and no satisfied trigger is skipped and must not be executed. 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. A node with no incoming trigger edge always executes, whatever its data sources did. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "BR-1",
        "BR-6"
      ],
      "backlinks": [
        "BR-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-br/br-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-br/br-7.md"
    },
    {
      "id": "CFG-1",
      "family": "GR-CFG",
      "part": "I",
      "title": "An unrecorded gate flag is off",
      "summary": "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.",
      "normative": "A parameter's `connectable`, `configurable` and `required` gate flags default to false. Where a node type records no value for one of them, the parameter is treated as not connectable, not configurable, or not required accordingly.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-2"
      ],
      "backlinks": [
        "CFG-2",
        "CFG-18"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-1.md"
    },
    {
      "id": "CFG-10",
      "family": "GR-CFG",
      "part": "I",
      "title": "A resolved value is validated against the parameter schema",
      "normative": "A resolved value that is not null is validated against the parameter's schema. Any violation fails the node execution with a validation error carrying the name of the constraint that failed. `type` and `enum` are checked first, and either one failing ends the check there. A value that resolves to null is not validated at all; whether a null is acceptable is decided by `required` alone (CFG-9).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-9",
        "CFG-11",
        "CFG-12"
      ],
      "backlinks": [
        "CFG-9",
        "CFG-11",
        "CFG-12"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-10",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-10.md"
    },
    {
      "id": "CFG-11",
      "family": "GR-CFG",
      "part": "I",
      "title": "Type checking matches exactly and never coerces",
      "normative": "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. A type name the implementation does not recognise passes rather than fails, so an unknown type never blocks a node from running.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-10"
      ],
      "backlinks": [
        "CFG-10",
        "CFG-12"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-11",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-11.md"
    },
    {
      "id": "CFG-12",
      "family": "GR-CFG",
      "part": "I",
      "title": "An unrecognised format passes rather than fails",
      "normative": "A `format` an implementation recognises is enforced: a value that does not match it fails validation (CFG-10). A `format` name the implementation does not recognise passes, so a schema written against a richer vocabulary never blocks a node from running.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-10",
        "CFG-11"
      ],
      "backlinks": [
        "CFG-10"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-12",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-12.md"
    },
    {
      "id": "CFG-13",
      "family": "GR-CFG",
      "part": "I",
      "title": "A processor sees its declared parameters and nothing else",
      "summary": "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.",
      "normative": "The resolved parameter set contains exactly the keys the processor's parameter schema declares, plus the node's declared dynamic input names (CFG-15). A configuration key matching no declared parameter is not resolved and never reaches the processor.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-8",
        "CFG-14",
        "CFG-15"
      ],
      "backlinks": [
        "CFG-8",
        "CFG-14",
        "CFG-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-13",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-13.md"
    },
    {
      "id": "CFG-14",
      "family": "GR-CFG",
      "part": "I",
      "title": "Every declared parameter is present, null when unresolved",
      "normative": "Every parameter the processor's schema declares is present in the resolved parameter set. One that resolves to no value is present with the value null; it is never absent.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-13"
      ],
      "backlinks": [
        "CFG-13",
        "CFG-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-14",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-14.md"
    },
    {
      "id": "CFG-15",
      "family": "GR-CFG",
      "part": "I",
      "title": "A dynamic input is forwarded verbatim",
      "summary": "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.",
      "normative": "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. A dynamic input's declared type is therefore descriptive only and is never enforced. 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. An empty name is skipped. Dynamic definitions are read from the node's resolved `dynamicInputs` value, falling back to the raw configuration where none resolved.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-13",
        "CFG-14"
      ],
      "backlinks": [
        "CFG-13",
        "CFG-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-15",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-15.md"
    },
    {
      "id": "CFG-16",
      "family": "GR-CFG",
      "part": "I",
      "title": "The unified input port is decomposed before resolution",
      "summary": "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.",
      "normative": "Where the reserved `input` port carries a value, it is decomposed before any parameter is resolved. 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. Otherwise, only keys that name a connectable parameter, an internal parameter, or a declared dynamic input are kept, and the rest are discarded. A value wired individually to a port then overwrites the same key taken from the bundle.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-4",
        "CFG-15",
        "CFG-17"
      ],
      "backlinks": [
        "CFG-17"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-16",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-16.md"
    },
    {
      "id": "CFG-17",
      "family": "GR-CFG",
      "part": "I",
      "title": "A hidden port cannot be filled through the bundle",
      "summary": "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.",
      "normative": "Decomposition of the unified `input` port (CFG-16) admits a key on the strength of `connectable` alone. 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). Bundling a value must never fill a port that could not be wired directly.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-4",
        "CFG-16",
        "EXPO-10"
      ],
      "backlinks": [
        "CFG-16",
        "EXPO-10"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-17",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-17.md"
    },
    {
      "id": "CFG-18",
      "family": "GR-CFG",
      "part": "I",
      "title": "Published config defaults cover configurable parameters only",
      "normative": "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. No entry is published for a parameter that is not configurable, or whose effective default is null.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-1",
        "CFG-3"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-18",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-18.md"
    },
    {
      "id": "CFG-2",
      "family": "GR-CFG",
      "part": "I",
      "title": "An unrecorded default-exposure flag means exposed",
      "summary": "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.",
      "normative": "A port's default exposure defaults to true. Where a node type records no `exposedByDefault` value for a port, the port is exposed by default. This polarity is the opposite of the gate flags (CFG-1) and is deliberate.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-1",
        "EXPO-5",
        "EXPO-8"
      ],
      "backlinks": [
        "CFG-1",
        "EXPO-2",
        "EXPO-5",
        "EXPO-7",
        "EXPO-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-2.md"
    },
    {
      "id": "CFG-3",
      "family": "GR-CFG",
      "part": "I",
      "title": "A parameter's effective default comes from the node type, then the schema",
      "normative": "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. A node-type default recorded as null is not distinguished from one that was never recorded, so it does not suppress the schema default.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-6",
        "CFG-7"
      ],
      "backlinks": [
        "CFG-6",
        "CFG-18"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-3.md"
    },
    {
      "id": "CFG-4",
      "family": "GR-CFG",
      "part": "I",
      "title": "Priority 1, a value delivered on a wire",
      "summary": "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.",
      "normative": "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. A key present with a null value counts as supplied (CFG-7). A parameter that fails this test falls through to priority 2 (CFG-5).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-5",
        "CFG-6",
        "CFG-7",
        "EXPO-10"
      ],
      "backlinks": [
        "CFG-5",
        "CFG-6",
        "CFG-7",
        "CFG-8",
        "CFG-16",
        "CFG-17",
        "EXPO-10",
        "DATA-7"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-4.md"
    },
    {
      "id": "CFG-5",
      "family": "GR-CFG",
      "part": "I",
      "title": "Priority 2, the author's saved config",
      "normative": "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. A key present with a null value counts as supplied (CFG-7). A parameter that fails this test falls through to priority 3 (CFG-6).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-4",
        "CFG-6",
        "CFG-7"
      ],
      "backlinks": [
        "CFG-4",
        "CFG-6",
        "CFG-7",
        "CFG-8",
        "EXPO-10",
        "DATA-7"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-5.md"
    },
    {
      "id": "CFG-6",
      "family": "GR-CFG",
      "part": "I",
      "title": "Priority 3, the effective default, unconditionally",
      "normative": "Where neither priority 1 (CFG-4) nor priority 2 (CFG-5) applies, a parameter takes its effective default (CFG-3). This fallback is unconditional: no gate flag suppresses it.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-3",
        "CFG-4",
        "CFG-5"
      ],
      "backlinks": [
        "CFG-3",
        "CFG-4",
        "CFG-5",
        "CFG-7",
        "CFG-9",
        "EXPO-10",
        "DATA-7"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-6.md"
    },
    {
      "id": "CFG-7",
      "family": "GR-CFG",
      "part": "I",
      "title": "An explicit null at a higher priority wins",
      "summary": "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.",
      "normative": "At every priority, a value counts as supplied when a key of the parameter's name is present, whatever that key's value. 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. This holds identically however a workflow is executed.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-4",
        "CFG-5",
        "CFG-6"
      ],
      "backlinks": [
        "CFG-3",
        "CFG-4",
        "CFG-5",
        "DATA-7"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-7.md"
    },
    {
      "id": "CFG-8",
      "family": "GR-CFG",
      "part": "I",
      "title": "Internal parameters take runtime values only",
      "summary": "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.",
      "normative": "A parameter whose name begins `__` is internal. 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. 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. A setting that also needs an author-facing surface must not use the prefix.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-4",
        "CFG-5",
        "CFG-13",
        "EXPO-10"
      ],
      "backlinks": [
        "CFG-13",
        "EXPO-10"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-8.md"
    },
    {
      "id": "CFG-9",
      "family": "GR-CFG",
      "part": "I",
      "title": "A required parameter that resolves to null fails the node",
      "normative": "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. The check runs after the whole chain has been walked and before the resolved value would be validated (CFG-10).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-6",
        "CFG-10"
      ],
      "backlinks": [
        "CFG-10"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-cfg/cfg-9",
      "markdown": "https://flowdrop.io/spec/rules/gr-cfg/cfg-9.md"
    },
    {
      "id": "CMP-1",
      "family": "RT-CMP",
      "part": "II",
      "title": "Compilation is one operation with one failure surface",
      "summary": "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.",
      "normative": "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. The stages run in this order: structure preconditions, dependency graph, cycle detection, tool wiring, execution graph, node mappings. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-3",
        "CMP-5",
        "CMP-6",
        "CMP-9",
        "CMP-11"
      ],
      "backlinks": [
        "CMP-3",
        "CMP-5",
        "CMP-11"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-1.md"
    },
    {
      "id": "CMP-10",
      "family": "RT-CMP",
      "part": "II",
      "title": "A node with tools wired to it must be able to receive them",
      "normative": "A node that has tools wired to it must be of a type that accepts tools. Wiring a tool to a node type that cannot consume tools refuses compilation.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-9"
      ],
      "backlinks": [
        "CMP-9"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-10",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-10.md"
    },
    {
      "id": "CMP-11",
      "family": "RT-CMP",
      "part": "II",
      "title": "The compiled plan keeps each node's node type identity",
      "normative": "For each node it will execute, the compiled plan records both the node type the node declares and the processor selected to run it. The runtime resolves a node's definition by node type identity.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-1"
      ],
      "backlinks": [
        "CMP-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-11",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-11.md"
    },
    {
      "id": "CMP-2",
      "family": "RT-CMP",
      "part": "II",
      "title": "Compilation always re-enriches node metadata from the live node types",
      "summary": "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.",
      "normative": "Compilation re-enriches every node's metadata from the live node type definitions before planning the run. This holds at every entry point into compilation: no caller can compile a workflow whose stored node metadata is taken on trust. The re-enriched workflow is normalized before it is compiled.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-19"
      ],
      "related": [
        "SCH-29"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-2.md"
    },
    {
      "id": "CMP-3",
      "family": "RT-CMP",
      "part": "II",
      "title": "Structure preconditions are checked before anything is planned",
      "summary": "The cheapest checks run first, so a workflow that cannot possibly execute is rejected before any graph work happens.",
      "normative": "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. The first failure refuses compilation, and the failure names which precondition failed and, where the failure is a node's, which node.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-1"
      ],
      "backlinks": [
        "CMP-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-3.md"
    },
    {
      "id": "CMP-4",
      "family": "RT-CMP",
      "part": "II",
      "title": "An edge's type is derived, never declared",
      "normative": "The type of an edge is derived from the handles it connects. An edge does not declare its own type, and a declared type on an edge is not authoritative.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EDGE-5"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-4.md"
    },
    {
      "id": "CMP-5",
      "family": "RT-CMP",
      "part": "II",
      "title": "Only tool, loopback and agent-result cycles are legal",
      "summary": "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.",
      "normative": "Cycle detection ignores tool-availability, loopback and agent-result edges. A cycle formed only from those edge types is legal. Any other cycle refuses compilation. The graph that remains once the ignored edge types are removed is therefore acyclic, and rules that depend on that acyclicity may assume it.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-1",
        "CMP-6"
      ],
      "backlinks": [
        "CMP-1",
        "CMP-6",
        "ORC-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-5.md"
    },
    {
      "id": "CMP-6",
      "family": "RT-CMP",
      "part": "II",
      "title": "Which nodes reach the execution graph",
      "summary": "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.",
      "normative": "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. A node with no incoming edges is included, and so is a node with no edges at all. The exclusions are applied in that precedence.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-5"
      ],
      "backlinks": [
        "CMP-1",
        "CMP-5"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-6.md"
    },
    {
      "id": "CMP-7",
      "family": "RT-CMP",
      "part": "II",
      "title": "Trigger dependencies displace data dependencies for ordering",
      "normative": "Where a node has both trigger dependencies and data dependencies, its execution dependencies are its trigger dependencies alone. Data dependencies order a node only when it has no trigger dependency.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-8"
      ],
      "backlinks": [
        "CMP-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-7.md"
    },
    {
      "id": "CMP-8",
      "family": "RT-CMP",
      "part": "II",
      "title": "Execution order is a topological order, and no more than that",
      "summary": "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.",
      "normative": "The execution order is a topological order of the execution dependencies: a node never precedes a node it depends on. The relative order of nodes with no dependency relation between them is not specified, and an author must not rely on it.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-7"
      ],
      "backlinks": [
        "CMP-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-8.md"
    },
    {
      "id": "CMP-9",
      "family": "RT-CMP",
      "part": "II",
      "title": "Tool names are unique per consumer, checked at compile time",
      "normative": "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. A collision refuses compilation. Passthrough tools are exempt from this check and are checked at the consumer instead.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CMP-10"
      ],
      "backlinks": [
        "CMP-1",
        "CMP-10"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cmp/cmp-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-cmp/cmp-9.md"
    },
    {
      "id": "CRON-1",
      "family": "RT-CRON",
      "part": "II",
      "title": "A cleared schedule means not scheduled, and every reader says so",
      "summary": "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.",
      "normative": "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. 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. Whichever component decides that a trigger is due and whichever reports its next run must reach the same conclusion about the same stored schedule.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CRON-2"
      ],
      "backlinks": [
        "CRON-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cron/cron-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-cron/cron-1.md"
    },
    {
      "id": "CRON-2",
      "family": "RT-CRON",
      "part": "II",
      "title": "Every reason a schedule has no next run carries a stable code",
      "summary": "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.",
      "normative": "An absent next run, reported on its own, means only that nothing is currently due, the answer a perfectly healthy schedule gives between fires. 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. 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. A broken stored schedule is not a broken request: the report is answered with 200 and carries the diagnosis.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CRON-1"
      ],
      "backlinks": [
        "CRON-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-cron/cron-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-cron/cron-2.md"
    },
    {
      "id": "DATA-1",
      "family": "RT-DATA",
      "part": "II",
      "title": "An edge delivers one named output port to one named input port",
      "normative": "An edge delivers a value from a named output port on its source node to a named input port on its target node. 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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-6",
        "DATA-7"
      ],
      "backlinks": [
        "DATA-4",
        "DATA-6",
        "DATA-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-1.md"
    },
    {
      "id": "DATA-10",
      "family": "RT-DATA",
      "part": "II",
      "title": "A tool edge binds a tool, it does not deliver data",
      "normative": "An edge on the reserved `tool_availability` port makes the source's tools available to its target as tool bindings. It is not a data delivery and fills no input port. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-5",
        "DATA-12"
      ],
      "backlinks": [
        "DATA-5",
        "DATA-11",
        "DATA-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-10",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-10.md"
    },
    {
      "id": "DATA-11",
      "family": "RT-DATA",
      "part": "II",
      "title": "A tool's model-facing schema hides the parameters the workflow already fixed",
      "normative": "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. The model is asked only for the parameters that remain open.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-10"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-11",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-11.md"
    },
    {
      "id": "DATA-12",
      "family": "RT-DATA",
      "part": "II",
      "title": "Tools reach only a node that declares itself a tool consumer",
      "normative": "Tools are handed to a node only where the node type declares that it consumes them. 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. Should such a workflow run regardless, the node receives no tools. A node re-entered on a later round of a loop receives its tools on every round.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-10"
      ],
      "backlinks": [
        "DATA-10"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-12",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-12.md"
    },
    {
      "id": "DATA-2",
      "family": "RT-DATA",
      "part": "II",
      "title": "Several sources on one port resolve to a single latest value",
      "summary": "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.",
      "normative": "A port fed by several edges receives exactly one value, never a list of them. 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. A collision between sources on one port is reported as a warning and never fails the run.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-8"
      ],
      "backlinks": [
        "SG-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-2.md"
    },
    {
      "id": "DATA-3",
      "family": "RT-DATA",
      "part": "II",
      "title": "A trigger edge carries no data",
      "normative": "A trigger edge conveys only that its source completed. It delivers no value, and no input port on its target is ever filled by one.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-3.md"
    },
    {
      "id": "DATA-4",
      "family": "RT-DATA",
      "part": "II",
      "title": "When a node is ready to run",
      "summary": "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.",
      "normative": "A node runs once its incoming edges are satisfied. 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. 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. That is an OR within a port and an AND across ports.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-1",
        "DATA-5",
        "SG-19",
        "SG-20"
      ],
      "backlinks": [
        "BR-6",
        "DATA-5",
        "SG-19",
        "SG-20"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-4.md"
    },
    {
      "id": "DATA-5",
      "family": "RT-DATA",
      "part": "II",
      "title": "Loopback and tool edges create no execution dependency",
      "normative": "An edge into the reserved `loop_back` input port, and an edge that wires a tool to a consumer, create no execution dependency. Neither is considered when the graph is checked for circular dependencies, and neither is considered when a node's readiness is evaluated. A node whose only incoming edges are of those two kinds is therefore ready at once. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-4",
        "DATA-10"
      ],
      "backlinks": [
        "DATA-4",
        "DATA-10"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-5.md"
    },
    {
      "id": "DATA-6",
      "family": "RT-DATA",
      "part": "II",
      "title": "Initial data fills a node's ports only where no edge did",
      "normative": "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`. An entry keyed for another node, or one that is not a map of port names to values, is ignored.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-1"
      ],
      "backlinks": [
        "DATA-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-6.md"
    },
    {
      "id": "DATA-7",
      "family": "RT-DATA",
      "part": "II",
      "title": "Inside a node, a wire outranks configuration outranks the default",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-4",
        "CFG-5",
        "CFG-6",
        "CFG-7",
        "DATA-1"
      ],
      "backlinks": [
        "DATA-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-7.md"
    },
    {
      "id": "DATA-8",
      "family": "RT-DATA",
      "part": "II",
      "title": "Unexposed outputs are stripped where they are produced",
      "normative": "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. A hidden port's value therefore cannot reappear downstream.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-9",
        "EXPO-11",
        "EXPO-12",
        "EXPO-13",
        "EXPO-14"
      ],
      "backlinks": [
        "DATA-9",
        "SG-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-8.md"
    },
    {
      "id": "DATA-9",
      "family": "RT-DATA",
      "part": "II",
      "title": "The unified output port carries exposed outputs only",
      "normative": "The unified `output` port composes a node's exposed outputs and nothing else. Keys prefixed with `_` and the reserved `trigger` key are excluded from it. An output key the node type does not configure counts as exposed and is kept.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-8"
      ],
      "backlinks": [
        "DATA-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-data/data-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-data/data-9.md"
    },
    {
      "id": "DYN-1",
      "family": "GR-DYN",
      "part": "I",
      "title": "Dynamic ports are opt-in, and a node that does not opt in has none",
      "summary": "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.",
      "normative": "A node type opts a node in to dynamic ports. 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. A node that has not opted in has no dynamic ports.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-dyn/dyn-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-dyn/dyn-1.md"
    },
    {
      "id": "DYN-2",
      "family": "GR-DYN",
      "part": "I",
      "title": "A dynamic port declares a name, a label and a data type",
      "normative": "Every dynamic port definition carries a `name`, a `label` and a `dataType`. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-dyn/dyn-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-dyn/dyn-2.md"
    },
    {
      "id": "DYN-3",
      "family": "GR-DYN",
      "part": "I",
      "title": "Dynamic port names are constrained and unique across the node",
      "normative": "A dynamic port name begins with an ASCII letter and continues with ASCII letters, digits or underscores. 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. A definition that breaks any of these is refused.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-dyn/dyn-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-dyn/dyn-3.md"
    },
    {
      "id": "DYN-4",
      "family": "GR-DYN",
      "part": "I",
      "title": "An unconnected dynamic input resolves to null, with its key present",
      "normative": "A dynamic input port with nothing wired into it resolves to null, and its key is present in the node's resolved inputs. Nothing arriving on the wire is not the same as the port being absent.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-dyn/dyn-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-dyn/dyn-4.md"
    },
    {
      "id": "DYN-5",
      "family": "GR-DYN",
      "part": "I",
      "title": "A declared parameter wins a name collision with a dynamic port",
      "normative": "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. An author cannot shadow a declared parameter by adding a dynamic port of the same name.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-dyn/dyn-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-dyn/dyn-5.md"
    },
    {
      "id": "DYN-6",
      "family": "GR-DYN",
      "part": "I",
      "title": "Dynamic ports carry no exposure state and are not addressable from outside",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-dyn/dyn-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-dyn/dyn-6.md"
    },
    {
      "id": "DYN-7",
      "family": "GR-DYN",
      "part": "I",
      "title": "Dynamic port definitions are configuration, never a wireable input port",
      "normative": "The reserved parameter holding a node's dynamic port definitions is never a wireable input port. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-dyn/dyn-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-dyn/dyn-7.md"
    },
    {
      "id": "EDGE-1",
      "family": "GR-EDGE",
      "part": "I",
      "title": "A handle encodes the node, the direction and the port",
      "summary": "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.",
      "normative": "A port handle is spelled `{nodeId}-{input|output}-{portName}`. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EDGE-3",
        "EDGE-4"
      ],
      "backlinks": [
        "EDGE-3",
        "EDGE-4",
        "EDGE-6"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-1.md"
    },
    {
      "id": "EDGE-2",
      "family": "GR-EDGE",
      "part": "I",
      "title": "No edge key is structurally required",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-2.md"
    },
    {
      "id": "EDGE-3",
      "family": "GR-EDGE",
      "part": "I",
      "title": "A trigger edge is one whose target port is the trigger port",
      "normative": "An edge is a trigger edge when, and only when, its target handle ends with `-input-trigger`. Nothing else marks a wire as a trigger.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EDGE-1"
      ],
      "backlinks": [
        "EDGE-1"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-3.md"
    },
    {
      "id": "EDGE-4",
      "family": "GR-EDGE",
      "part": "I",
      "title": "A loopback edge targets the port named loop_back",
      "normative": "An edge is a loopback edge when, and only when, its target handle ends with `-input-loop_back`. The reserved port name is `loop_back`: a handle ending with `-input-loopback` is an ordinary edge.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EDGE-1"
      ],
      "backlinks": [
        "EDGE-1"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-4.md"
    },
    {
      "id": "EDGE-5",
      "family": "GR-EDGE",
      "part": "I",
      "title": "A declared edge type wins over the handle, if it is recognised",
      "summary": "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.",
      "normative": "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`. 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. A declaration that is absent, empty, or not a string falls back to the handle. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-17"
      ],
      "related": [
        "EDGE-7"
      ],
      "backlinks": [
        "EDGE-7",
        "CMP-4"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-5.md"
    },
    {
      "id": "EDGE-6",
      "family": "GR-EDGE",
      "part": "I",
      "title": "An error edge routes a failure instead of raising it",
      "normative": "An edge whose source handle ends with `-output-error` is an error edge. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EDGE-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-6.md"
    },
    {
      "id": "EDGE-7",
      "family": "GR-EDGE",
      "part": "I",
      "title": "A tool-only node is excluded from the execution graph",
      "normative": "A node is tool-only when it has at least one outgoing edge and every one of its outgoing edges is a tool edge. A node with no outgoing edges is not tool-only, and neither is one with any non-tool outgoing edge. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EDGE-5"
      ],
      "backlinks": [
        "EDGE-5"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-7.md"
    },
    {
      "id": "EDGE-8",
      "family": "GR-EDGE",
      "part": "I",
      "title": "A condition on an edge is tolerated, warned about, and ignored",
      "normative": "A condition on an edge is a removed feature. A stored `edge.data.condition` is tolerated and round-trips unchanged, and it gates nothing: the edge always routes. 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. An absent or empty condition is silent.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-8.md"
    },
    {
      "id": "EDGE-9",
      "family": "GR-EDGE",
      "part": "I",
      "title": "Two edge vocabularies, and they never mix",
      "summary": "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.",
      "normative": "An edge has two forms, each with its own key names. In transport, the form an editor reads and writes, the keys are `source`, `target`, `sourceHandle` and `targetHandle`. In the execution record they are `source_handle`, `target_handle`, `is_trigger`, `is_loopback`, `is_tool`, `branch_name` and `edge_id`. Neither vocabulary may carry a key belonging to the other.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-edge/edge-9",
      "markdown": "https://flowdrop.io/spec/rules/gr-edge/edge-9.md"
    },
    {
      "id": "ERR-1",
      "family": "RT-ERR",
      "part": "II",
      "title": "A node-level failure becomes an error output, not a thrown failure",
      "summary": "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.",
      "normative": "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. The node's lifecycle is reported as completed, not failed; it finished, and the error verdict rides its output.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-3",
        "ERR-7"
      ],
      "backlinks": [
        "ERR-3",
        "ERR-4",
        "ERR-5",
        "ERR-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-1.md"
    },
    {
      "id": "ERR-10",
      "family": "RT-ERR",
      "part": "II",
      "title": "A routed failure activates error edges only, and only when it is current",
      "summary": "Two independent staleness rules guard the handler, because a loop can both re-run a node and route a failure from an earlier round.",
      "normative": "A node whose failure was routed satisfies only its error edges. Error edges are evaluated before trigger and data edges and combine with OR semantics: one satisfied error edge activates the handler. Success-path successors are never made ready by a routed failure. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-7",
        "SG-19"
      ],
      "backlinks": [
        "ERR-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-10",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-10.md"
    },
    {
      "id": "ERR-11",
      "family": "RT-ERR",
      "part": "II",
      "title": "Retry is opt-in, in place, and immediate",
      "summary": "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.",
      "normative": "A node retries only where its configured maximum retry count is greater than zero; the default is zero. Only a retryable error output retries. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-2"
      ],
      "backlinks": [
        "ERR-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-11",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-11.md"
    },
    {
      "id": "ERR-12",
      "family": "RT-ERR",
      "part": "II",
      "title": "Direct synchronous execution has no error-handling divergence",
      "normative": "Withdrawn in favour of ERR-13. 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.",
      "posture": "deprecated",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-13"
      ],
      "backlinks": [],
      "supersededBy": "ERR-13",
      "url": "https://flowdrop.io/spec/rules/rt-err/err-12",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-12.md"
    },
    {
      "id": "ERR-13",
      "family": "RT-ERR",
      "part": "II",
      "title": "Every execution strategy routes errors identically",
      "summary": "A workflow that handles its own failures must mean the same thing whichever engine runs it, including the direct synchronous one.",
      "normative": "All execution strategies route error outputs the same way. 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. No strategy may substitute its own error-handling behaviour.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-1"
      ],
      "related": [
        "ERR-7",
        "ERR-8"
      ],
      "backlinks": [
        "ERR-7",
        "ERR-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-13",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-13.md"
    },
    {
      "id": "ERR-2",
      "family": "RT-ERR",
      "part": "II",
      "title": "Retryability is marked by presence, not by a boolean",
      "summary": "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.",
      "normative": "An error output that came from a retryable failure carries `error_retryable` set to `true`. An error output from a non-retryable failure omits the key entirely rather than setting it to `false`. Retry applies only where the value is literally `true`.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-11"
      ],
      "backlinks": [
        "ERR-6",
        "ERR-11"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-2.md"
    },
    {
      "id": "ERR-3",
      "family": "RT-ERR",
      "part": "II",
      "title": "A failure that is not node-level fails the run",
      "normative": "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. The failure reported to the caller preserves the original cause.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-1",
        "ERR-8"
      ],
      "backlinks": [
        "ERR-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-3.md"
    },
    {
      "id": "ERR-4",
      "family": "RT-ERR",
      "part": "II",
      "title": "An interrupt propagates unchanged",
      "summary": "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.",
      "normative": "A node that interrupts sets its status to interrupted, the interrupt is announced, and the interrupt itself propagates unchanged to the caller. It is never converted into an error output and never takes an error edge.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-1",
        "ORC-13"
      ],
      "backlinks": [
        "ORC-13"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-4.md"
    },
    {
      "id": "ERR-5",
      "family": "RT-ERR",
      "part": "II",
      "title": "A node may stop the run successfully",
      "summary": "Stopping is a deliberate early finish, not a failure. Everything the run had not yet done is abandoned, and the run reports success.",
      "normative": "A node may stop the whole run. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-1",
        "ORC-11"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-5.md"
    },
    {
      "id": "ERR-6",
      "family": "RT-ERR",
      "part": "II",
      "title": "A node's output must be serializable throughout",
      "summary": "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.",
      "normative": "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. The check covers the node's full output, including values its type does not expose. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-19"
      ],
      "related": [
        "ERR-1",
        "ERR-2"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-6.md"
    },
    {
      "id": "ERR-7",
      "family": "RT-ERR",
      "part": "II",
      "title": "An error edge replaces the node's output with an error envelope",
      "summary": "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.",
      "normative": "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. The envelope carries an additional `details` key only where the failure supplied structured detail.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-8",
        "ERR-9",
        "ERR-10",
        "ERR-13"
      ],
      "backlinks": [
        "ERR-1",
        "ERR-8",
        "ERR-9",
        "ERR-10",
        "ERR-13",
        "RT-GATE-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-7.md"
    },
    {
      "id": "ERR-8",
      "family": "RT-ERR",
      "part": "II",
      "title": "An error with nowhere to go fails the run",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-7",
        "ERR-9"
      ],
      "backlinks": [
        "ERR-3",
        "ERR-7",
        "ERR-9",
        "ERR-13",
        "RT-GATE-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-8.md"
    },
    {
      "id": "ERR-9",
      "family": "RT-ERR",
      "part": "II",
      "title": "A run whose every failure was handled completes",
      "summary": "The verdict is computed from unhandled failures, not from failures. One unrouted failure fails the run no matter how many routed ones accompany it.",
      "normative": "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. A routed failure is still announced on the ordinary \"unit of work finished\" channel, so subscribers see it and discriminate on its status. A failed tool invocation always counts as handled: a tool failure is delivered to its consumer as a recoverable result.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-7",
        "ERR-8"
      ],
      "backlinks": [
        "ERR-7",
        "ERR-8",
        "ORC-11",
        "RT-GATE-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-err/err-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-err/err-9.md"
    },
    {
      "id": "EXPO-1",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Exposure resolves the same way everywhere it is consulted",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-2",
        "EXPO-3"
      ],
      "backlinks": [
        "EXPO-2"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-1.md"
    },
    {
      "id": "EXPO-10",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Hiding an input port changes precedence, it does not unset the parameter",
      "summary": "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.",
      "normative": "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). Such a parameter never takes its value from the runtime inputs, and is never left null merely because its port is hidden. Internal parameters sit outside this gate and always accept a runtime value (CFG-8).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-4",
        "CFG-5",
        "CFG-6",
        "CFG-8",
        "CFG-17"
      ],
      "backlinks": [
        "CFG-4",
        "CFG-8",
        "CFG-17",
        "EXPO-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-10",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-10.md"
    },
    {
      "id": "EXPO-11",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Output stripping covers declared output ports only",
      "summary": "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.",
      "normative": "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. 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. A hidden declared output must be stripped whatever else the result contains, including where the processor declares no output ports at all.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-12",
        "EXPO-13",
        "EXPO-14"
      ],
      "backlinks": [
        "EXPO-12",
        "EXPO-13",
        "EXPO-14",
        "DATA-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-11",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-11.md"
    },
    {
      "id": "EXPO-12",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Control outputs are never stripped",
      "normative": "The reserved control outputs `active_branches` and `state_update` are never removed from a node's result by exposure stripping. They are the engine's own channel, not ports an author shows or hides, and branching and state merging depend on them arriving intact.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-11"
      ],
      "backlinks": [
        "EXPO-11",
        "EXPO-14",
        "DATA-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-12",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-12.md"
    },
    {
      "id": "EXPO-13",
      "family": "GR-EXPO",
      "part": "I",
      "title": "A node type can hide an output outright",
      "normative": "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. Otherwise the instance's own override decides, then the node type's default exposure, and a port with none of these resolves as exposed.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-2",
        "EXPO-11"
      ],
      "backlinks": [
        "EXPO-11",
        "DATA-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-13",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-13.md"
    },
    {
      "id": "EXPO-14",
      "family": "GR-EXPO",
      "part": "I",
      "title": "A hidden output's value never reaches anything downstream",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-11",
        "EXPO-12"
      ],
      "backlinks": [
        "EXPO-11",
        "DATA-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-14",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-14.md"
    },
    {
      "id": "EXPO-15",
      "family": "GR-EXPO",
      "part": "I",
      "title": "A model may fill only visible, unwired parameters",
      "normative": "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. A hidden parameter, a non-connectable one, and one a wire already supplies are all outside the model's reach.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-2",
        "EXPO-10"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-15",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-15.md"
    },
    {
      "id": "EXPO-16",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Seeding a port's default-exposure decision",
      "summary": "A suggestion fills a decision nobody has made yet, and only for a port that is actually there.",
      "normative": "Where an authoring surface presents a port's default-exposure decision, a value already stored for that port decides it. 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. Exposure values are never recorded for a port the node type does not declare.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-6",
        "EXPO-8"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-16",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-16.md"
    },
    {
      "id": "EXPO-17",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Port display order is cosmetic",
      "normative": "A port's declared display order is presentational metadata for whatever draws the node. No execution, resolution, validation or exposure decision may depend on it, and reordering ports must not change what a workflow does.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-17",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-17.md"
    },
    {
      "id": "EXPO-2",
      "family": "GR-EXPO",
      "part": "I",
      "title": "An instance override decides exposure; otherwise the default does",
      "normative": "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. Where there is no such entry, or the entry carries no `exposed` key, the port's default exposure decides (`exposedByDefault`, CFG-2).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-2",
        "EXPO-1",
        "EXPO-3",
        "EXPO-4"
      ],
      "backlinks": [
        "EXPO-1",
        "EXPO-3",
        "EXPO-4",
        "EXPO-13",
        "EXPO-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-2.md"
    },
    {
      "id": "EXPO-3",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Overrides are scoped by direction",
      "normative": "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. An input and an output of the same name are separate ports, and an override on one never applies to the other.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-2"
      ],
      "backlinks": [
        "EXPO-1",
        "EXPO-2"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-3.md"
    },
    {
      "id": "EXPO-4",
      "family": "GR-EXPO",
      "part": "I",
      "title": "A node nobody has touched stores no overrides",
      "normative": "A node instance on which no port has been shown or hidden stores no `ports` map at all. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-2",
        "EXPO-8"
      ],
      "backlinks": [
        "EXPO-2",
        "EXPO-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-4.md"
    },
    {
      "id": "EXPO-5",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Only an exact false suppresses default exposure",
      "summary": "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.",
      "normative": "A processor's `x-exposed-by-default` schema extension suppresses a port's default exposure only when its value is exactly the boolean `false`. 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).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-2",
        "EXPO-6",
        "EXPO-7"
      ],
      "backlinks": [
        "CFG-2",
        "EXPO-6",
        "EXPO-7"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-5.md"
    },
    {
      "id": "EXPO-6",
      "family": "GR-EXPO",
      "part": "I",
      "title": "A processor's exposure suggestion is authoring input only",
      "summary": "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.",
      "normative": "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. It must never be consulted when a workflow runs, and no execution-time decision may depend on it. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "EXPO-5",
        "EXPO-7"
      ],
      "backlinks": [
        "EXPO-5",
        "EXPO-7",
        "EXPO-8",
        "EXPO-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-6.md"
    },
    {
      "id": "EXPO-7",
      "family": "GR-EXPO",
      "part": "I",
      "title": "Published schemas carry the flag only where the default is hidden",
      "normative": "Where a node type's stored default exposure for a port is false, the schema it publishes carries `x-exposed-by-default: false`. Where it is anything else, the key is absent from the published schema rather than present and true. 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).",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-2",
        "EXPO-5",
        "EXPO-6"
      ],
      "backlinks": [
        "EXPO-5",
        "EXPO-6"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-7.md"
    },
    {
      "id": "EXPO-8",
      "family": "GR-EXPO",
      "part": "I",
      "title": "A port with no stored decision is exposed, whatever the processor suggested",
      "summary": "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.",
      "normative": "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. A processor's suggestion reaches a node type only through the authoring-time derivation of EXPO-6.",
      "posture": "descriptive",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "CFG-2",
        "EXPO-4",
        "EXPO-6"
      ],
      "backlinks": [
        "CFG-2",
        "EXPO-4",
        "EXPO-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-expo/expo-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-expo/expo-8.md"
    },
    {
      "id": "INT-1",
      "family": "RT-INT",
      "part": "II",
      "title": "An interrupt pauses the run and reports itself in full",
      "summary": "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.",
      "normative": "When a node raises an interrupt, its job is marked interrupted and carries the interrupt's identifier, and the run is paused. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-2",
        "INT-3",
        "INT-22"
      ],
      "backlinks": [
        "INT-2",
        "INT-3",
        "INT-22"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-1.md"
    },
    {
      "id": "INT-10",
      "family": "RT-INT",
      "part": "II",
      "title": "A queued run seeded from a snapshot performs only the remaining work",
      "summary": "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.",
      "normative": "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. A snapshot node with no matching job is skipped and the discrepancy recorded in the log. The number seeded is reported in the response results and metadata.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-6",
        "INT-15"
      ],
      "backlinks": [
        "INT-15"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-10",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-10.md"
    },
    {
      "id": "INT-11",
      "family": "RT-ORC",
      "part": "II",
      "title": "A terminal run refuses re-entry; re-running makes a new run",
      "summary": "This is what makes cancellation durable: no requeue or rerun can resurrect a dead run and fire its side-effecting nodes again.",
      "normative": "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. Re-running always creates a new run, seeded with the source run's input and recording which run it is a re-run of.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-11",
        "ORC-15"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-orc/int-11",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/int-11.md"
    },
    {
      "id": "INT-12",
      "family": "RT-ORC",
      "part": "II",
      "title": "Only a budget pause resumes itself",
      "summary": "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.",
      "normative": "A paused run auto-resumes only where the pause was caused by a budget: the execution-time budget or the scheduler-pass budget. A pause with no reason recorded is a pause awaiting a person, and holds until an explicit resume. A worker that picks up paused runs resumes and re-queues budget pauses only, and leaves a reason-less pause paused. Asynchronous execution in chunks depends on this: it works by pausing on budget, auto-resuming and re-queueing.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-10",
        "ORC-11",
        "ORC-12"
      ],
      "backlinks": [
        "ORC-10",
        "ORC-11",
        "ORC-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/int-12",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/int-12.md"
    },
    {
      "id": "INT-13",
      "family": "RT-INT",
      "part": "II",
      "title": "A checkpoint round-trip preserves cancellation",
      "summary": "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.",
      "normative": "Execution state carries its terminal outcome explicitly (completed, failed or cancelled, and unset while the run is live), and a checkpoint persists it. A snapshot's status is the persisted outcome, never a re-derivation from whether the run finished, which cannot express cancellation. A cancel signal marks the state cancelled and writes the final checkpoint. 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).",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-6",
        "INT-14",
        "SG-12"
      ],
      "backlinks": [
        "INT-6",
        "INT-14"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-13",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-13.md"
    },
    {
      "id": "INT-14",
      "family": "RT-INT",
      "part": "II",
      "title": "A terminal snapshot is not resumable",
      "summary": "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.",
      "normative": "A snapshot whose status is terminal (completed, failed or cancelled) must not be resumed. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-10"
      ],
      "related": [
        "INT-13",
        "INT-15"
      ],
      "backlinks": [
        "INT-8",
        "INT-13",
        "INT-15"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-14",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-14.md"
    },
    {
      "id": "INT-15",
      "family": "RT-INT",
      "part": "II",
      "title": "An engine that cannot use a snapshot refuses it, never restarts",
      "summary": "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.",
      "normative": "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. An engine with no snapshot-seeding mechanism therefore refuses any snapshot attached to a run, before any run or job exists. No snapshot attached remains a plain fresh start.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-10"
      ],
      "related": [
        "INT-10",
        "INT-14"
      ],
      "backlinks": [
        "INT-10",
        "INT-14"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-15",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-15.md"
    },
    {
      "id": "INT-16",
      "family": "RT-INT",
      "part": "II",
      "title": "A refused signal says why in a code a client can act on",
      "summary": "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.",
      "normative": "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`). A client classifies a refusal by that code; the message wording is not a contract and an implementation may change it. A refusal answers with the error envelope (API-1). A newly created cancel or pause signal answers `202`; a resume that resolved a pending pause answers `200`. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-18"
      ],
      "related": [
        "API-1",
        "API-8",
        "INT-5"
      ],
      "backlinks": [
        "INT-17"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-16",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-16.md"
    },
    {
      "id": "INT-17",
      "family": "RT-INT",
      "part": "II",
      "title": "A terminal outcome reaps the signals it never observed",
      "summary": "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.",
      "normative": "When a run reaches a terminal outcome, inward signals still pending against it are cancelled, after the outcome's own handling has completed. 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. A stateless run has nothing targeting it and is skipped. A failure while reaping is recorded and never raised; the outcome is already recorded and hygiene must not corrupt it.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-5",
        "INT-16"
      ],
      "backlinks": [
        "INT-5"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-17",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-17.md"
    },
    {
      "id": "INT-18",
      "family": "RT-INT",
      "part": "II",
      "title": "An expired outward interrupt ends the run it was holding open",
      "summary": "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.",
      "normative": "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). Two exclusions bind. 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. A run that has already recorded an outcome keeps it: a late expiry never overwrites it and never announces a second time. A failure while ending one run is recorded and never raised, so a sweep is not aborted mid-backlog. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-20",
        "INT-23",
        "ORC-15",
        "RT-GATE-1"
      ],
      "backlinks": [
        "INT-20",
        "INT-23",
        "RT-GATE-1",
        "RT-GATE-13"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-18",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-18.md"
    },
    {
      "id": "INT-19",
      "family": "RT-INT",
      "part": "II",
      "title": "A machine answers an outbound wait through its own route",
      "summary": "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.",
      "normative": "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. 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. 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. 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`. 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. An unknown identifier answers `404`; an interrupt that is not pending, or has expired, answers `409`, so a replayed callback never resolves twice. A request omitting `value` is refused `400` before the interrupt is looked up, so a malformed request reveals nothing about which identifiers exist.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-20",
        "INT-22"
      ],
      "backlinks": [
        "INT-20",
        "INT-22"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-19",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-19.md"
    },
    {
      "id": "INT-2",
      "family": "RT-INT",
      "part": "II",
      "title": "The interrupt a run reports is the persisted one",
      "normative": "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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-1"
      ],
      "backlinks": [
        "INT-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-2.md"
    },
    {
      "id": "INT-20",
      "family": "RT-INT",
      "part": "II",
      "title": "Call-and-wait creates the interrupt before it makes the call",
      "summary": "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.",
      "normative": "An outbound call-and-wait creates its interrupt first, then makes the remote call, then pauses. 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). 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. 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. 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. 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. 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. Resumption passes the callback body through verbatim; only the workflow's author knows the remote's contract.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-18",
        "INT-19",
        "INT-23",
        "NET-2"
      ],
      "backlinks": [
        "INT-18",
        "INT-19",
        "INT-23"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-20",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-20.md"
    },
    {
      "id": "INT-22",
      "family": "RT-INT",
      "part": "II",
      "title": "An interrupt is published as 24 keys, every one always present",
      "summary": "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.",
      "normative": "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`. `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. Every optional member is present and null rather than omitted. An endpoint listing interrupts answers a JSON array, and must still answer an array after access filtering has removed entries.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-1",
        "INT-19",
        "PLAY-4",
        "STORE-11"
      ],
      "backlinks": [
        "INT-1",
        "INT-19"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-22",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-22.md"
    },
    {
      "id": "INT-23",
      "family": "RT-INT",
      "part": "II",
      "title": "No expiry is a sentinel, and the two expiry paths differ on purpose",
      "summary": "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.",
      "normative": "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. An overdue interrupt is expired by two paths that differ in exactly one observable. A sweep expires the interrupt, records it, and announces the expiry, which is what ends the run holding it open (INT-18). 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-18",
        "INT-20"
      ],
      "backlinks": [
        "INT-18",
        "INT-20"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-23",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-23.md"
    },
    {
      "id": "INT-3",
      "family": "RT-INT",
      "part": "II",
      "title": "Resolving an interrupt resumes only a paused run",
      "summary": "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.",
      "normative": "Resolving an interrupt locates the job it interrupted by the job identifier stamped on the interrupt, and returns that job to pending unconditionally. 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. A run that is not paused is left strictly alone and must not be re-entered. A stateless run has nothing to resume, and resolution is a no-op for it.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-1",
        "INT-4",
        "ORC-13"
      ],
      "backlinks": [
        "INT-1",
        "INT-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-3.md"
    },
    {
      "id": "INT-4",
      "family": "RT-INT",
      "part": "II",
      "title": "A node resumes only when all four ownership conditions hold",
      "summary": "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.",
      "normative": "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. If any fails, the node is executed afresh rather than resumed: a safe re-ask.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-3"
      ],
      "backlinks": [
        "INT-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-4.md"
    },
    {
      "id": "INT-5",
      "family": "RT-INT",
      "part": "II",
      "title": "Cancel and pause signals are observed between job iterations",
      "summary": "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.",
      "normative": "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. 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). It then answers with status `cancelled` and metadata naming the engine, the signal and the reason. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-17",
        "ORC-15"
      ],
      "backlinks": [
        "INT-16",
        "INT-17"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-5.md"
    },
    {
      "id": "INT-6",
      "family": "RT-INT",
      "part": "II",
      "title": "What a run snapshot contains, and that it never fails the run",
      "summary": "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.",
      "normative": "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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-7",
        "INT-9",
        "INT-13"
      ],
      "backlinks": [
        "INT-7",
        "INT-9",
        "INT-10",
        "INT-13"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-6.md"
    },
    {
      "id": "INT-7",
      "family": "RT-INT",
      "part": "II",
      "title": "The workflow version is a structural digest",
      "summary": "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.",
      "normative": "A workflow's version is the first 16 hexadecimal characters of a SHA-256 digest over the workflow's structural data only. Presentational data (labels, canvas positions) must not change it.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-6",
        "INT-8"
      ],
      "backlinks": [
        "INT-6",
        "INT-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-7.md"
    },
    {
      "id": "INT-8",
      "family": "RT-INT",
      "part": "II",
      "title": "Snapshot validation reports named errors and non-fatal warnings",
      "summary": "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.",
      "normative": "Validating a snapshot against a workflow definition yields a result carrying named codes. 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. Warnings, which never make the result invalid: one per definition node missing from the snapshot, and one per completed injected node that has dependencies.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-7",
        "INT-14"
      ],
      "backlinks": [
        "INT-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-8.md"
    },
    {
      "id": "INT-9",
      "family": "RT-INT",
      "part": "II",
      "title": "One execution has at most one stored snapshot",
      "normative": "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. 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. Access to a snapshot is enforced at the API boundary and enforced once there, not repeated by the layer that stores them. A scheduled cleanup runs with no principal and deletes on the predicate above alone.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-6"
      ],
      "backlinks": [
        "INT-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-int/int-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-int/int-9.md"
    },
    {
      "id": "LANG-1",
      "family": "GR-LANG",
      "part": "I",
      "title": "Extraction engines query the context; transformation engines bind it",
      "summary": "The engines an author can choose split into two families, and the split decides what the surrounding data means to the expression they write.",
      "normative": "An extraction engine treats the evaluation context as data to be queried by a path. A transformation engine treats the same context as a map of variables the expression may reference by name.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "LANG-4"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-1.md"
    },
    {
      "id": "LANG-10",
      "family": "GR-LANG",
      "part": "I",
      "title": "Expression-language validation is a syntax check only",
      "normative": "Validating an expression-language expression checks its syntax, with every identifier the expression mentions treated as declared. A reference to a name that will not exist when the workflow runs therefore validates successfully and fails at run time.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-6"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-10",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-10.md"
    },
    {
      "id": "LANG-11",
      "family": "GR-LANG",
      "part": "I",
      "title": "Property-path validation accepts everything evaluation can resolve",
      "normative": "Validating a property path parses it with the same parser evaluation uses, after the same dot-to-bracket normalization. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-17"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-11",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-11.md"
    },
    {
      "id": "LANG-12",
      "family": "GR-LANG",
      "part": "I",
      "title": "A path that does not begin with `$` always passes JSONPath validation",
      "normative": "Under the jsonpath engine, validation checks a `$`-rooted query. A path that does not begin with `$` validates unconditionally.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-14"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-12",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-12.md"
    },
    {
      "id": "LANG-13",
      "family": "GR-LANG",
      "part": "I",
      "title": "Twig validation is a compile check only",
      "normative": "Validating a Twig template compiles it. A template that compiles but fails only while rendering validates successfully.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-7"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-13",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-13.md"
    },
    {
      "id": "LANG-14",
      "family": "GR-LANG",
      "part": "I",
      "title": "A leading `$` decides JSONPath from property path",
      "normative": "A path is a JSONPath query if and only if the string, once trimmed of surrounding whitespace, begins with `$`. Every other path is a property path.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "LANG-12",
        "LANG-23"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-14",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-14.md"
    },
    {
      "id": "LANG-15",
      "family": "GR-LANG",
      "part": "I",
      "title": "A single JSONPath match is unwrapped unless the path selects many",
      "summary": "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.",
      "normative": "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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "LANG-23"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-15",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-15.md"
    },
    {
      "id": "LANG-16",
      "family": "GR-LANG",
      "part": "I",
      "title": "A string context is parsed as JSON where it parses",
      "normative": "Where the evaluation context is a string, it is parsed as JSON and the parsed value is what the path queries. A string that is not valid JSON is used as-is. The string `null` parses to null, like any other JSON document.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-16",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-16.md"
    },
    {
      "id": "LANG-17",
      "family": "GR-LANG",
      "part": "I",
      "title": "Dot and bracket path segments are interchangeable everywhere",
      "normative": "A property path may be written with dot segments, bracket segments, or a mixture of the two. An implementation normalizes them to a single form before parsing, and every place it reads a property path resolves the two spellings identically.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "LANG-11"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-17",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-17.md"
    },
    {
      "id": "LANG-18",
      "family": "GR-LANG",
      "part": "I",
      "title": "A trigger mapping value can be escaped as a literal",
      "normative": "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. The escape applies to trigger mappings only.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-18",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-18.md"
    },
    {
      "id": "LANG-19",
      "family": "GR-LANG",
      "part": "I",
      "title": "Extracting all matches for a property path always yields a list",
      "normative": "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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-19",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-19.md"
    },
    {
      "id": "LANG-2",
      "family": "GR-LANG",
      "part": "I",
      "title": "A node's expression engine must be one the implementation provides",
      "normative": "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. A workflow naming an engine outside that set is refused when it is saved and again when the parameter is resolved. An unrecognised engine identifier that reaches evaluation by some other route fails before any expression is evaluated.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-2.md"
    },
    {
      "id": "LANG-20",
      "family": "GR-LANG",
      "part": "I",
      "title": "Testing whether a path exists",
      "summary": "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.",
      "normative": "Testing a path for existence reports whether that path can be read, and answers for every path. 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). The empty path consumes no segments and therefore denotes the evaluation context itself, which is always present, so the empty path reports presence.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-8"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-20",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-20.md"
    },
    {
      "id": "LANG-21",
      "family": "GR-LANG",
      "part": "I",
      "title": "A failed mapper expression fails the node and names the port",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-29"
      ],
      "backlinks": [
        "LANG-29"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-21",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-21.md"
    },
    {
      "id": "LANG-22",
      "family": "GR-LANG",
      "part": "I",
      "title": "An extraction error fails the node and never takes the default",
      "normative": "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. The configured default is not substituted on an error; it applies only where the expression evaluated cleanly and found nothing.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-24",
        "LANG-29"
      ],
      "backlinks": [
        "LANG-24",
        "LANG-29"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-22",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-22.md"
    },
    {
      "id": "LANG-23",
      "family": "GR-LANG",
      "part": "I",
      "title": "Only a `$`-rooted JSONPath query unwraps a list to its first match",
      "normative": "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. 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. The extractor's reported JSONPath flag reports the `$` prefix alone and is independent of the engine in force.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-14",
        "LANG-15"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-23",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-23.md"
    },
    {
      "id": "LANG-24",
      "family": "GR-LANG",
      "part": "I",
      "title": "A null extraction is a miss, not a failure",
      "summary": "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.",
      "normative": "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. Only null is a miss; false, zero and the empty string are delivered as found.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-8",
        "LANG-22"
      ],
      "backlinks": [
        "LANG-22",
        "LANG-29"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-24",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-24.md"
    },
    {
      "id": "LANG-25",
      "family": "GR-LANG",
      "part": "I",
      "title": "Shaper sentinels resolve before any engine is consulted",
      "normative": "A shaper source is checked for sentinels before its engine is consulted. `_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. A source that does reach the engine and fails there fails the node, and the failure names the source expression and the engine.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-29"
      ],
      "backlinks": [
        "LANG-29"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-25",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-25.md"
    },
    {
      "id": "LANG-26",
      "family": "GR-LANG",
      "part": "I",
      "title": "A prompt template that fails to render fails the node",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-7",
        "LANG-29"
      ],
      "backlinks": [
        "LANG-29"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-26",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-26.md"
    },
    {
      "id": "LANG-27",
      "family": "GR-LANG",
      "part": "I",
      "title": "A switch compares its value; it does not evaluate it",
      "summary": "Despite the parameter's name, a switch gateway runs no expression engine. The value is matched against each branch as it stands.",
      "normative": "A switch gateway does not evaluate its `expression`. 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. The first matching branch is the sole active branch, and its name is what the gateway reports as active. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-27",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-27.md"
    },
    {
      "id": "LANG-28",
      "family": "GR-LANG",
      "part": "I",
      "title": "A condition node's operators are a closed set, and case folding spares the pattern",
      "normative": "A condition node compares with exactly six operators (`equals`, `not_equals`, `contains`, `starts_with`, `ends_with` and `regex`) declared as an enumeration. 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. A pattern that cannot be compiled fails the node when it runs. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-19"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-28",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-28.md"
    },
    {
      "id": "LANG-29",
      "family": "GR-LANG",
      "part": "I",
      "title": "An expression error fails the node; an empty result does not",
      "summary": "One policy across every node that evaluates an author's expression, so a broken expression is visible and routable rather than silently absorbed.",
      "normative": "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. This holds uniformly across every node that evaluates an author's expression. An expression that evaluates cleanly and finds nothing is not an error, so an extractor's default-on-no-match is unaffected. Leniency toward genuine errors is only ever an explicit, opt-in node configuration, never the default.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-8"
      ],
      "related": [
        "LANG-21",
        "LANG-22",
        "LANG-24",
        "LANG-25",
        "LANG-26"
      ],
      "backlinks": [
        "LANG-21",
        "LANG-22",
        "LANG-25",
        "LANG-26"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-29",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-29.md"
    },
    {
      "id": "LANG-3",
      "family": "GR-LANG",
      "part": "I",
      "title": "An empty expression is not an error",
      "normative": "An empty expression is valid: it passes validation, and it produces a defined result rather than a failure. A mapper output whose expression is empty is null, whatever the engine. An extractor whose path is empty returns its input unchanged, reports success, and reports no match.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-3.md"
    },
    {
      "id": "LANG-4",
      "family": "GR-LANG",
      "part": "I",
      "title": "A context that is not a map reaches a transformation engine as `data`",
      "normative": "Where the evaluation context is not a map, a transformation engine receives it bound to the single variable `data`.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-4.md"
    },
    {
      "id": "LANG-6",
      "family": "GR-LANG",
      "part": "I",
      "title": "Expression-language evaluation fails loudly",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-7",
        "LANG-8",
        "LANG-9"
      ],
      "backlinks": [
        "LANG-10"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-6.md"
    },
    {
      "id": "LANG-7",
      "family": "GR-LANG",
      "part": "I",
      "title": "Twig yields an escaped string and treats a missing variable as empty",
      "normative": "A Twig template always produces a string, HTML-escaped unless the value is piped through `raw`. A variable absent from the context renders as the empty string rather than failing. A template that cannot be compiled, and a template that fails while rendering, both fail with an error saying which of the two occurred.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "LANG-6",
        "LANG-13",
        "LANG-26"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-7.md"
    },
    {
      "id": "LANG-8",
      "family": "GR-LANG",
      "part": "I",
      "title": "A property path that cannot be read yields null",
      "summary": "Property paths trade diagnosis for calm: nothing raises, and a reader cannot tell a missing value from a stored one.",
      "normative": "Property-path evaluation never raises. A path that cannot be read yields null, which is indistinguishable from a path that reads a stored null.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "LANG-6",
        "LANG-9",
        "LANG-24",
        "LANG-20"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-8.md"
    },
    {
      "id": "LANG-9",
      "family": "GR-LANG",
      "part": "I",
      "title": "A JSONPath query never raises; it falls back to the caller's default",
      "normative": "JSONPath evaluation never raises. 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. The returned value does not tell the two apart.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "LANG-8"
      ],
      "backlinks": [
        "LANG-6"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-lang/lang-9",
      "markdown": "https://flowdrop.io/spec/rules/gr-lang/lang-9.md"
    },
    {
      "id": "MAN-1",
      "family": "GR-MAN",
      "part": "I",
      "title": "A workflow's launch inputs are declared, never inferred",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-2",
        "MAN-3"
      ],
      "backlinks": [
        "MAN-2",
        "MAN-3"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-1.md"
    },
    {
      "id": "MAN-10",
      "family": "GR-MAN",
      "part": "I",
      "title": "A declared input name wins over an internal node-keyed key",
      "summary": "The two input shapes can collide on the same key. The declared manifest is the workflow's public face, so it decides.",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-11",
        "MAN-12"
      ],
      "backlinks": [
        "MAN-11",
        "MAN-12"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-10",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-10.md"
    },
    {
      "id": "MAN-11",
      "family": "GR-MAN",
      "part": "I",
      "title": "A declared input delivers to the port its entry binds",
      "summary": "The manifest is a mapping, and this is the mapping it performs.",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-10",
        "MAN-17"
      ],
      "backlinks": [
        "MAN-10",
        "MAN-17"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-11",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-11.md"
    },
    {
      "id": "MAN-12",
      "family": "GR-MAN",
      "part": "I",
      "title": "The node-keyed input shape is internal and unreachable from outside",
      "summary": "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.",
      "normative": "The node-keyed input shape, addressing a node identifier and port name directly, is an internal contract. Every externally reachable launch door refuses it: a caller supplies declared input names, and nothing else.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-10",
        "MAN-13"
      ],
      "backlinks": [
        "MAN-10",
        "MAN-13"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-12",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-12.md"
    },
    {
      "id": "MAN-13",
      "family": "GR-MAN",
      "part": "I",
      "title": "An undeclared launch key is refused, and its value never delivered",
      "summary": "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.",
      "normative": "A launch key that is not a declared input name is refused, and the refusal names the offending key and the workflow. The value is never written into the resolved initial data, whether the key was refused or merely reported.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-12",
        "MAN-14",
        "MAN-15"
      ],
      "backlinks": [
        "API-2",
        "MAN-12",
        "MAN-14",
        "MAN-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-13",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-13.md"
    },
    {
      "id": "MAN-14",
      "family": "GR-MAN",
      "part": "I",
      "title": "Strictness polices the caller, never the stored manifest",
      "summary": "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.",
      "normative": "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. Strictness applies to what the caller supplied, not to what the workflow stored.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-8",
        "MAN-13"
      ],
      "backlinks": [
        "MAN-8",
        "MAN-13"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-14",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-14.md"
    },
    {
      "id": "MAN-15",
      "family": "GR-MAN",
      "part": "I",
      "title": "Launch inputs are checked in one fixed order and answered once",
      "summary": "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.",
      "normative": "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. Every message ends by naming the inputs the workflow does accept, or by stating that it accepts none. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-16"
      ],
      "related": [
        "MAN-2",
        "MAN-13",
        "MAN-16"
      ],
      "backlinks": [
        "API-2",
        "MAN-2",
        "MAN-13",
        "MAN-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-15",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-15.md"
    },
    {
      "id": "MAN-16",
      "family": "GR-MAN",
      "part": "I",
      "title": "The value check enforces type and enum, and declares the rest",
      "summary": "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.",
      "normative": "The per-value stage of the launch check enforces exactly two things from a contract fragment: `type` and `enum`. 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. The top-level required list is the one declared key that is enforced, and it is enforced by the earlier missing-input stage. Values are never coerced. Every structured type collapses to a single check that the value is a collection. 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. `enum` compares by identity, so a number and its string spelling are different values. Violations across several inputs are aggregated into one refusal, and an input with no contract fragment skips the value check.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-16"
      ],
      "related": [
        "MAN-5",
        "MAN-15",
        "MAN-21"
      ],
      "backlinks": [
        "MAN-15",
        "MAN-21"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-16",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-16.md"
    },
    {
      "id": "MAN-17",
      "family": "GR-MAN",
      "part": "I",
      "title": "Declared outputs are collected by the same mapping, in reverse",
      "summary": "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.",
      "normative": "Each declared output name resolves to the result the bound node produced on the bound port. Presence is decided by whether the key exists, so a null value counts as produced and is returned. An output whose node produced nothing at all is dropped from the result with a warning. A malformed output entry is skipped.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-8",
        "MAN-11"
      ],
      "backlinks": [
        "MAN-8",
        "MAN-11"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-17",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-17.md"
    },
    {
      "id": "MAN-18",
      "family": "GR-MAN",
      "part": "I",
      "title": "The contract is rebuilt when the manifest changes, and versioned when it is",
      "summary": "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.",
      "normative": "A workflow's contract is rebuilt when its declared input or output manifest differs from the stored one, or when a rebuild is forced. 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. A side with no declared entries stores no schema for that side. 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. A rebuild that bumps nothing writes the schemas and leaves the version untouched. A build that fails logs an error, preserves the previously published contract, and does not prevent the workflow from being saved.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-5",
        "MAN-21"
      ],
      "backlinks": [
        "MAN-21",
        "STORE-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-18",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-18.md"
    },
    {
      "id": "MAN-19",
      "family": "GR-MAN",
      "part": "I",
      "title": "A workflow declared asynchronous cannot be launched and waited on",
      "summary": "Asking to wait for a result the workflow has already said it will not deliver inline is a mistake worth catching before anything runs.",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-19",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-19.md"
    },
    {
      "id": "MAN-2",
      "family": "GR-MAN",
      "part": "I",
      "title": "A manifest entry names an input and binds it to a port",
      "summary": "Three fields say what the input is called and where it goes; the rest is documentation for whoever calls the workflow.",
      "normative": "A manifest entry carries a `name`, a `node_id` and a `port`, all required, and may carry `title`, `description`, `examples` and `required`. The optional metadata applies to the input side only. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-1",
        "MAN-6",
        "MAN-15"
      ],
      "backlinks": [
        "MAN-1",
        "MAN-6",
        "MAN-15",
        "MAN-20"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-2.md"
    },
    {
      "id": "MAN-20",
      "family": "GR-MAN",
      "part": "I",
      "title": "The API maps a client interface onto the stored manifest",
      "summary": "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.",
      "normative": "A workflow's API accepts an `interface` object and maps it onto the stored input and output manifests. An entry's client-side identifier becomes the server-side input name; renaming either is a breaking change for callers. 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. An entry carrying no binding is a client-side draft: it is skipped, not stored, and not an error. 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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-2",
        "MAN-3",
        "MAN-21"
      ],
      "backlinks": [
        "STORE-14",
        "MAN-3",
        "MAN-21"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-20",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-20.md"
    },
    {
      "id": "MAN-21",
      "family": "GR-MAN",
      "part": "I",
      "title": "A contract entry states the port's lane as well as its schema",
      "summary": "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.",
      "normative": "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. The lane is never the fragment's `type`. 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. 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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-5",
        "MAN-16",
        "MAN-18",
        "MAN-20"
      ],
      "backlinks": [
        "MAN-5",
        "MAN-16",
        "MAN-18",
        "MAN-20"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-21",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-21.md"
    },
    {
      "id": "MAN-3",
      "family": "GR-MAN",
      "part": "I",
      "title": "However the manifest was written, one validator refuses a bad entry",
      "summary": "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.",
      "normative": "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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-1",
        "MAN-20"
      ],
      "backlinks": [
        "MAN-1",
        "MAN-20"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-3.md"
    },
    {
      "id": "MAN-5",
      "family": "GR-MAN",
      "part": "I",
      "title": "The published contract carries structure and nothing else",
      "summary": "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.",
      "normative": "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`. Every other key is dropped, at every depth, recursing into each declared property and into an item schema. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-6",
        "MAN-7",
        "MAN-21"
      ],
      "backlinks": [
        "MAN-6",
        "MAN-7",
        "MAN-16",
        "MAN-18",
        "MAN-21"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-5.md"
    },
    {
      "id": "MAN-6",
      "family": "GR-MAN",
      "part": "I",
      "title": "The author's words win over the node's",
      "summary": "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.",
      "normative": "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. The overlay applies to the input side only. An empty value is dropped rather than overlaid, so blank author metadata does not erase the port's own.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-2",
        "MAN-5"
      ],
      "backlinks": [
        "MAN-2",
        "MAN-5"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-6.md"
    },
    {
      "id": "MAN-7",
      "family": "GR-MAN",
      "part": "I",
      "title": "Only a flat default is published",
      "summary": "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.",
      "normative": "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. A default of any other shape, including one with a nested value, is dropped and the drop is logged. Dropping a default never affects the rest of the fragment: every other contract key of that entry is retained.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-5"
      ],
      "backlinks": [
        "MAN-5"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-7.md"
    },
    {
      "id": "MAN-8",
      "family": "GR-MAN",
      "part": "I",
      "title": "An entry that cannot be built is skipped, not fatal",
      "summary": "One broken entry must not cost the workflow its whole contract, so the build drops it and carries on.",
      "normative": "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. An entry that is structurally malformed, missing or empty in `name`, `node_id` or `port`, is likewise skipped and never contributes to the contract.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-14",
        "MAN-17"
      ],
      "backlinks": [
        "MAN-14",
        "MAN-17"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-8.md"
    },
    {
      "id": "MAN-9",
      "family": "GR-MAN",
      "part": "I",
      "title": "A workflow used as a node cannot build its own contract forever",
      "summary": "Workflows compose, so a contract build can walk into itself. The guard is per path, not per build, so an honest diamond still resolves.",
      "normative": "When a workflow is used as a node inside another workflow, the contract build descends into it. 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. Encountering the same workflow again as a sibling, not an ancestor, is allowed and builds normally.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-man/man-9",
      "markdown": "https://flowdrop.io/spec/rules/gr-man/man-9.md"
    },
    {
      "id": "MD-1",
      "family": "RT-MD",
      "part": "II",
      "title": "Markdown source can never reach the reader as markup",
      "summary": "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.",
      "normative": "Every text node a markdown-to-HTML conversion emits must be HTML-encoded: prose, list items, blockquote lines, headings and code alike. Markup present in the markdown source must never reach the reader as markup, including where a block opens with a tag.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "MD-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-md/md-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-md/md-1.md"
    },
    {
      "id": "MD-2",
      "family": "RT-MD",
      "part": "II",
      "title": "Encoded exactly once, and quoted where a quote would end an attribute",
      "summary": "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.",
      "normative": "Text is encoded exactly once. A sequence the conversion itself wrote must not be encoded again: a `<` in the markdown source appears in the output as `&lt;` and never as `&amp;lt;`. A text node may keep literal quote characters, which are harmless there. 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. A URL carrying a dangerous scheme must be stripped rather than emitted.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MD-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-md/md-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-md/md-2.md"
    },
    {
      "id": "MEM-1",
      "family": "GR-MEM",
      "part": "I",
      "title": "Appending a tool result twice appends it once",
      "summary": "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.",
      "normative": "Appending to a conversation buffer is idempotent by tool call id. 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. The one exception is an id currently held by a synthetic healed placeholder, which is replaced rather than deduplicated.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-2",
        "MEM-10",
        "MEM-11"
      ],
      "backlinks": [
        "MEM-2",
        "MEM-10",
        "MEM-11",
        "MEM-14"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-1.md"
    },
    {
      "id": "MEM-10",
      "family": "GR-MEM",
      "part": "I",
      "title": "A real tool result replaces the placeholder that stood in for it",
      "summary": "A healed message says a call was interrupted. If the result later arrives, the buffer must show the answer, not the guess.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-1",
        "MEM-3"
      ],
      "backlinks": [
        "MEM-1",
        "MEM-3",
        "MEM-14"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-10",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-10.md"
    },
    {
      "id": "MEM-11",
      "family": "GR-MEM",
      "part": "I",
      "title": "Both spellings of a tool call's id are read",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-1",
        "MEM-2",
        "MEM-3"
      ],
      "backlinks": [
        "MEM-1",
        "MEM-3"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-11",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-11.md"
    },
    {
      "id": "MEM-12",
      "family": "GR-MEM",
      "part": "I",
      "title": "Concurrent buffer appends are serialized, and never fatal",
      "summary": "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.",
      "normative": "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. 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. Serialization taken is always released. Nothing more is claimed: not storage-level atomicity, and no protection against a writer that does not go through this path.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-7"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-12",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-12.md"
    },
    {
      "id": "MEM-13",
      "family": "GR-MEM",
      "part": "I",
      "title": "A session-scoped memory read or write without a real session refuses",
      "summary": "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.",
      "normative": "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. As with the `user` scope, refusing is distinct from resolving to the empty scope identifier, which selects the shared global bucket. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-6",
        "MEM-7"
      ],
      "backlinks": [
        "MEM-6",
        "MEM-7",
        "MEM-14"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-13",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-13.md"
    },
    {
      "id": "MEM-14",
      "family": "GR-MEM",
      "part": "I",
      "title": "An append reports how much of it was new",
      "summary": "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.",
      "normative": "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. 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. A deduplicated message and a placeholder replacement each count as zero, since neither adds an entry. An append under a refused identity scope reports zero, together with its empty buffer.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-1",
        "MEM-10",
        "MEM-13"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-14",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-14.md"
    },
    {
      "id": "MEM-15",
      "family": "GR-MEM",
      "part": "I",
      "title": "A denial is final, and a message is not editable",
      "normative": "A denial by these access rules is final. Where an implementation offers extension points that contribute to an access decision, none of them may grant what these rules have denied. A session message is never updatable or deletable except at the administrative tier. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-9"
      ],
      "backlinks": [
        "MEM-9"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-15",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-15.md"
    },
    {
      "id": "MEM-16",
      "family": "GR-MEM",
      "part": "I",
      "title": "Text becomes a message under three closed rules",
      "summary": "The adapter every text producer needs to reach a message-shaped node. Its defaults are chosen so a malformed turn never reaches a provider.",
      "normative": "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. 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. Three rules are closed. 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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-3",
        "MEM-4",
        "MEM-5"
      ],
      "backlinks": [
        "MEM-4",
        "MEM-5"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-16",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-16.md"
    },
    {
      "id": "MEM-2",
      "family": "GR-MEM",
      "part": "I",
      "title": "An assistant turn is a duplicate only when every call it declares is known",
      "summary": "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.",
      "normative": "An `assistant`-role message is dropped as a duplicate only when every tool call id it declares is already declared somewhere in the buffer. A turn declaring at least one id not yet seen is appended.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-1"
      ],
      "backlinks": [
        "MEM-1",
        "MEM-11"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-2.md"
    },
    {
      "id": "MEM-3",
      "family": "GR-MEM",
      "part": "I",
      "title": "A user turn heals tool calls that were never answered",
      "summary": "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.",
      "normative": "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. 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. Healing runs only at this boundary: appending a `tool` or `assistant` message never triggers it.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-10",
        "MEM-11"
      ],
      "backlinks": [
        "MEM-5",
        "MEM-10",
        "MEM-11",
        "MEM-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-3.md"
    },
    {
      "id": "MEM-4",
      "family": "GR-MEM",
      "part": "I",
      "title": "Assembling messages concatenates in declared port order",
      "summary": "The node that joins several message sources into one list. Port order is the message order, and an unwired source adds nothing at all.",
      "normative": "A message-assembly node takes any number of dynamically declared message-typed inputs and flattens them into one message-typed output. The declared order of the inputs is the order of the messages. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-16"
      ],
      "backlinks": [
        "MEM-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-4.md"
    },
    {
      "id": "MEM-5",
      "family": "GR-MEM",
      "part": "I",
      "title": "Normalizing a conversation makes it sendable to a provider",
      "summary": "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.",
      "normative": "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. (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. (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. (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. (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. The reported drop count comes from the repair pass itself, never from a separate reimplementation of its rules.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-3",
        "MEM-16"
      ],
      "backlinks": [
        "MEM-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-5.md"
    },
    {
      "id": "MEM-6",
      "family": "GR-MEM",
      "part": "I",
      "title": "A user-scoped memory read or write without a real user refuses",
      "summary": "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.",
      "normative": "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. 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. Scopes that are not identity scopes continue to resolve to the empty identifier.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-7",
        "MEM-8",
        "MEM-13"
      ],
      "backlinks": [
        "MEM-7",
        "MEM-8",
        "MEM-13",
        "MEM-9"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-6.md"
    },
    {
      "id": "MEM-7",
      "family": "GR-MEM",
      "part": "I",
      "title": "A refused scope reaches no storage backend",
      "summary": "The refusal is only worth having if every consumer honours it identically, and if nothing is written on the way out.",
      "normative": "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. 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. The resolved scope identifier reported back is empty. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-6",
        "MEM-13"
      ],
      "backlinks": [
        "MEM-6",
        "MEM-12",
        "MEM-13"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-7.md"
    },
    {
      "id": "MEM-8",
      "family": "GR-MEM",
      "part": "I",
      "title": "A session's memory principal is its owner or nobody",
      "summary": "Memory follows the conversation, not whoever happens to be driving it this turn. An unowned conversation therefore has no user memory at all.",
      "normative": "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. 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. The identity passed this way is what the `user` memory scope resolves.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-6"
      ],
      "backlinks": [
        "MEM-6",
        "MEM-9"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-8.md"
    },
    {
      "id": "MEM-9",
      "family": "GR-MEM",
      "part": "I",
      "title": "Driving a session is a write, and an absent identity owns nothing",
      "summary": "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.",
      "normative": "Authorization to read a session does not authorize driving it. 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). Every ownership test requires a real identity on both sides. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MEM-6",
        "MEM-8",
        "MEM-15"
      ],
      "backlinks": [
        "MEM-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-mem/mem-9",
      "markdown": "https://flowdrop.io/spec/rules/gr-mem/mem-9.md"
    },
    {
      "id": "META-1",
      "family": "RT-META",
      "part": "II",
      "title": "The category list is the standard envelope, and its name is a machine name",
      "summary": "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.",
      "normative": "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. `data` is a list of rows whose keys are `name`, `label`, `icon`, `color`, `description`, in that order, every value a string. `name` is the category's identifier, not a display name, and there is no separate `id` key. Only enabled categories are published: a disabled one is absent rather than flagged, so a consumer cannot distinguish it from a deleted one.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "META-3",
        "META-9"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-meta/meta-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-meta/meta-1.md"
    },
    {
      "id": "META-3",
      "family": "RT-META",
      "part": "II",
      "title": "A failed read answers a fixed message and reports the real one",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "META-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-meta/meta-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-meta/meta-3.md"
    },
    {
      "id": "META-4",
      "family": "RT-META",
      "part": "II",
      "title": "The workflow schema door publishes a bare document",
      "summary": "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.",
      "normative": "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. `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\".",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "META-5",
        "META-6"
      ],
      "backlinks": [
        "META-5",
        "META-6",
        "META-9",
        "STORE-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-meta/meta-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-meta/meta-4.md"
    },
    {
      "id": "META-5",
      "family": "RT-META",
      "part": "II",
      "title": "An unknown workflow's schema is a bare error document",
      "normative": "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. Absence is answered before anything else is read, so an unknown identifier never reaches the schema-version or entity-tag logic.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "META-4"
      ],
      "backlinks": [
        "META-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-meta/meta-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-meta/meta-5.md"
    },
    {
      "id": "META-6",
      "family": "RT-META",
      "part": "II",
      "title": "The entity tag carries the body variant, not just the schema version",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "META-4",
        "META-7",
        "META-8"
      ],
      "backlinks": [
        "META-4",
        "META-7",
        "META-8",
        "STORE-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-meta/meta-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-meta/meta-6.md"
    },
    {
      "id": "META-7",
      "family": "RT-META",
      "part": "II",
      "title": "A conditional schema request is exact string equality and nothing more",
      "summary": "A partial wildcard implementation would report \"unchanged\" to a client that holds no copy at all, which is worse than answering unconditionally.",
      "normative": "`If-None-Match` on a workflow schema read is compared for exact string equality against the tag of the variant being requested. A match answers `304` carrying the entity tag and the body `{}`, and does so before the schema itself is assembled. A mismatch answers the full `200`. 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`. Each variant answers its own conditional request, so carrying the variant in the tag costs no variant a `304`.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "META-6"
      ],
      "backlinks": [
        "META-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-meta/meta-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-meta/meta-7.md"
    },
    {
      "id": "META-8",
      "family": "RT-META",
      "part": "II",
      "title": "The two schema variants are cached on deliberately different terms",
      "summary": "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.",
      "normative": "The plain workflow schema document is publicly cacheable for at most 60 seconds. The annotated document is served `no-cache`. 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. A response is invalidated when the workflow it describes changes, and by nothing broader.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "META-6"
      ],
      "backlinks": [
        "META-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-meta/meta-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-meta/meta-8.md"
    },
    {
      "id": "META-9",
      "family": "RT-META",
      "part": "II",
      "title": "The editor metadata doors are read-only and gated before the handler",
      "summary": "Neither door has a handler-side check to fall back on, so what the surface declares is the whole access contract.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "META-1",
        "META-4"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-meta/meta-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-meta/meta-9.md"
    },
    {
      "id": "NET-1",
      "family": "RT-NET",
      "part": "II",
      "title": "An outbound URL is checked before the request, and the check is pinned to it",
      "summary": "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.",
      "normative": "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. A refusal is a node configuration error, which takes the error edge rather than failing the run. 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. Every node that dials a caller-supplied URL performs this check; a second implementation of it is a second thing to forget to fix.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "NET-2",
        "NET-3"
      ],
      "backlinks": [
        "NET-2",
        "NET-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-net/net-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-net/net-1.md"
    },
    {
      "id": "NET-2",
      "family": "RT-NET",
      "part": "II",
      "title": "Every redirect hop is re-validated before it is taken",
      "summary": "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.",
      "normative": "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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "NET-1"
      ],
      "backlinks": [
        "INT-20",
        "NET-1",
        "NET-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-net/net-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-net/net-2.md"
    },
    {
      "id": "NET-3",
      "family": "RT-NET",
      "part": "II",
      "title": "The redirect posture is stated, not inherited",
      "summary": "These restate what most HTTP clients already default to, deliberately: the posture is a decision, not whatever the client happens to ship.",
      "normative": "Outbound requests follow at most 5 redirects. A redirect of a POST degrades to GET rather than preserving the method. The originating URL and its query string are never sent to the next host as a referrer. Hops are restricted to `http` and `https`, so the scheme check cannot be sidestepped mid-chain. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "NET-1",
        "NET-2"
      ],
      "backlinks": [
        "NET-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-net/net-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-net/net-3.md"
    },
    {
      "id": "OCX-1",
      "family": "RT-OCX",
      "part": "II",
      "title": "External invocation builds flat initial data",
      "summary": "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.",
      "normative": "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. 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. The initial data must not be keyed by node id.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "OCX-3"
      ],
      "backlinks": [
        "OCX-3",
        "OCX-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-1.md"
    },
    {
      "id": "OCX-2",
      "family": "RT-OCX",
      "part": "II",
      "title": "External results are retrieved by polling, and only by polling",
      "summary": "A contract of omission: the connector dispatches nothing outbound. Widening it means taking on delivery, timeouts, retry and de-duplication, which belong elsewhere.",
      "normative": "Results of an externally invoked run are retrieved by the caller polling. The connector dispatches no outbound request and subscribes to no runtime execution event. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "OCX-8"
      ],
      "backlinks": [
        "OCX-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-2.md"
    },
    {
      "id": "OCX-3",
      "family": "RT-OCX",
      "part": "II",
      "title": "Polls are scoped by a positive marker, defined once",
      "summary": "Scoping by the presence of a trigger configuration served cron, entity and form runs (output data included) to any external platform that asked.",
      "normative": "A poll answers only runs that were externally invoked, identified by a positive source marker carried in the run's initial data. Scoping must not be inferred from the presence of a trigger configuration identifier, which every trigger kind sets. 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. The marker travels in the run's initial data, not in the options that configure the orchestrator, which never reach it.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "OCX-1",
        "OCX-9"
      ],
      "backlinks": [
        "OCX-1",
        "OCX-9"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-3.md"
    },
    {
      "id": "OCX-4",
      "family": "RT-OCX",
      "part": "II",
      "title": "External invocation honours the configured pipeline identity and mode",
      "normative": "An externally invoked run resolves its pipeline identifier and pipeline mode from the trigger's orchestrator settings, exactly as every other trigger kind does. It must not construct an identifier of its own, which silently discards reuse and singleton modes.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "OCX-7"
      ],
      "backlinks": [
        "OCX-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-4.md"
    },
    {
      "id": "OCX-5",
      "family": "RT-OCX",
      "part": "II",
      "title": "A trigger's condition set is a closed vocabulary",
      "summary": "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.",
      "normative": "A trigger's conditions are a closed vocabulary: the condition keys this specification declares, and no others alongside them. Event-type-specific data a connector needs to carry is written under the designated extension key, whose contents this specification does not constrain. A connector writing its own keys beside the declared ones is writing an invalid condition set.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-5.md"
    },
    {
      "id": "OCX-6",
      "family": "RT-OCX",
      "part": "II",
      "title": "The connector validates nothing it publishes",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "OCX-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-6.md"
    },
    {
      "id": "OCX-7",
      "family": "RT-OCX",
      "part": "II",
      "title": "One execution identifier correlates the invocation with every poll",
      "summary": "This single identifier is what makes an asynchronous round trip work through a synchronous invocation signature.",
      "normative": "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`. 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`. An engine that answers a synthetic request identifier instead has its persisted identifier republished here, so the correlation identifier never varies by engine.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "OCX-4",
        "OCX-8"
      ],
      "backlinks": [
        "OCX-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-7.md"
    },
    {
      "id": "OCX-8",
      "family": "RT-OCX",
      "part": "II",
      "title": "A poll reports every terminal run, cancellation included",
      "normative": "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. A run that is genuinely paused appears in no poll until it resumes or reaches a terminal status. A backlog may need more than one poll to drain, and nothing is dropped on the way.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "OCX-2",
        "OCX-9"
      ],
      "backlinks": [
        "OCX-2",
        "OCX-7",
        "OCX-9"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-8.md"
    },
    {
      "id": "OCX-9",
      "family": "RT-OCX",
      "part": "II",
      "title": "Polls page, and paging never strands the caller",
      "summary": "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.",
      "normative": "Every poll is bounded to a page: a first poll, or one whose cursor is empty or unparseable, costs no more than any other. Paging is only sound if the caller can always walk to the end, so two things bind. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "OCX-3",
        "OCX-8"
      ],
      "backlinks": [
        "OCX-3",
        "OCX-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-ocx/ocx-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-ocx/ocx-9.md"
    },
    {
      "id": "ORC-1",
      "family": "RT-ORC",
      "part": "II",
      "title": "Four execution strategies, each with a stable identifier",
      "summary": "The same workflow can be run four ways. Which way is a deployment choice, not a property of the workflow.",
      "normative": "An implementation provides four execution strategies: direct synchronous, synchronous pipeline, asynchronous, and state graph. Each reports a stable identifier for itself, and that identifier is what callers and stored configuration name it by.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-2",
        "ORC-5"
      ],
      "backlinks": [
        "ORC-2",
        "ORC-5",
        "ORC-13"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-1.md"
    },
    {
      "id": "ORC-10",
      "family": "RT-ORC",
      "part": "II",
      "title": "A run has a scheduler budget, and exhausting it pauses rather than fails",
      "summary": "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.",
      "normative": "A run is bounded by a maximum number of scheduler passes, defaulting to 100, and a maximum execution time. Exhausting either breaks the loop and pauses the run with the reason recorded, and a pending system pause signal is raised. A paused run is resumable and resumes with a fresh budget; re-entry clears a stale pause reason. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-9"
      ],
      "related": [
        "ORC-9",
        "ORC-11",
        "INT-12",
        "SG-7",
        "SG-14",
        "SG-16"
      ],
      "backlinks": [
        "ORC-9",
        "ORC-11",
        "INT-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-10",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-10.md"
    },
    {
      "id": "ORC-11",
      "family": "RT-ORC",
      "part": "II",
      "title": "How a run's terminal status is decided",
      "summary": "A single ladder, in precedence order, so a run that both failed and paused reports the failure.",
      "normative": "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. A cancelled run never reaches this ladder; a cancel signal ends the run before it and is announced there.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-9",
        "ORC-10",
        "ORC-15",
        "INT-12"
      ],
      "backlinks": [
        "ERR-5",
        "ORC-9",
        "ORC-10",
        "INT-11",
        "INT-12",
        "ORC-15"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-11",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-11.md"
    },
    {
      "id": "ORC-12",
      "family": "RT-ORC",
      "part": "II",
      "title": "Asynchronous execution returns immediately",
      "normative": "Asynchronous execution returns the status `queued` to its caller immediately and executes no node in the calling request. It may seed the run from a snapshot of already-completed work.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-2",
        "INT-12"
      ],
      "backlinks": [
        "INT-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-12",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-12.md"
    },
    {
      "id": "ORC-13",
      "family": "RT-ORC",
      "part": "II",
      "title": "Resuming after an interrupt re-enters through the run's own strategy",
      "normative": "When an interrupt is resolved, the run resumes through the strategy the run declared, never through a different one.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-4",
        "ORC-1"
      ],
      "backlinks": [
        "ERR-4",
        "INT-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-13",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-13.md"
    },
    {
      "id": "ORC-14",
      "family": "RT-ORC",
      "part": "II",
      "title": "A unit of work is claimed atomically",
      "summary": "Without this, two workers can promote and execute the same unit of work, and a side-effecting node fires twice.",
      "normative": "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. Queueing a run for execution does not deliver the same work twice.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-9"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-14",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-14.md"
    },
    {
      "id": "ORC-15",
      "family": "RT-ORC",
      "part": "II",
      "title": "A cancelled run is announced like any other terminal outcome",
      "summary": "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.",
      "normative": "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. Consumers of that announcement must treat cancelled as its own outcome and not as a completed run. A cancelled sub-workflow resolves to its caller with status cancelled and empty outputs.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-11"
      ],
      "backlinks": [
        "ORC-11",
        "INT-11",
        "INT-5",
        "INT-18"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-15",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-15.md"
    },
    {
      "id": "ORC-2",
      "family": "RT-ORC",
      "part": "II",
      "title": "How the execution strategy for a run is resolved",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-1",
        "ORC-3",
        "ORC-4"
      ],
      "backlinks": [
        "ORC-1",
        "ORC-3",
        "ORC-4",
        "ORC-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-2.md"
    },
    {
      "id": "ORC-3",
      "family": "RT-ORC",
      "part": "II",
      "title": "An unknown strategy identifier falls back, and says so",
      "normative": "A request for a strategy identifier the implementation does not know falls back to the default strategy and records a warning. Where no fallback strategy is available either, the request fails rather than silently choosing one.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-2"
      ],
      "backlinks": [
        "ORC-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-3.md"
    },
    {
      "id": "ORC-4",
      "family": "RT-ORC",
      "part": "II",
      "title": "State graph is the interactive-session default only",
      "summary": "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.",
      "normative": "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. The general resolution chain never resolves to it.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-2"
      ],
      "backlinks": [
        "ORC-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-4.md"
    },
    {
      "id": "ORC-5",
      "family": "RT-ORC",
      "part": "II",
      "title": "A strategy's capabilities are declared, not asked for",
      "summary": "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.",
      "normative": "A strategy declares its capabilities as part of its definition. 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. Behaviour that depends on a strategy's nature is gated on the declared capabilities and not on the strategy's identity.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-1"
      ],
      "backlinks": [
        "ORC-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-5.md"
    },
    {
      "id": "ORC-7",
      "family": "RT-ORC",
      "part": "II",
      "title": "Direct synchronous execution refuses a workflow that loops",
      "summary": "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.",
      "normative": "Direct synchronous execution walks the compiled execution order once, top to bottom, carrying results in memory, and never re-enters a node. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-13"
      ],
      "related": [
        "CMP-5",
        "ORC-9"
      ],
      "backlinks": [
        "ORC-8"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-7.md"
    },
    {
      "id": "ORC-8",
      "family": "RT-ORC",
      "part": "II",
      "title": "Naming a firing trigger drops the other triggers",
      "normative": "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. Where it does not, the compiled order runs verbatim. Nodes that are not triggers are never filtered.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-7"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-8.md"
    },
    {
      "id": "ORC-9",
      "family": "RT-ORC",
      "part": "II",
      "title": "A pipeline engine is a ready-work loop",
      "summary": "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.",
      "normative": "A pipeline engine repeatedly asks for the work that is ready. 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. A unit whose dependencies are unmet is left idle and untouched. The engine stops when a pass returns nothing; that empty return is what quiescence means.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ORC-10",
        "ORC-11"
      ],
      "backlinks": [
        "ORC-7",
        "ORC-10",
        "ORC-14"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-orc/orc-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-orc/orc-9.md"
    },
    {
      "id": "PIPE-1",
      "family": "RT-PIPE",
      "part": "II",
      "title": "Run status is read from persisted state, on two routes and one envelope",
      "summary": "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.",
      "normative": "A run's status is published by two reads: the full pipeline document and a lightweight status document. 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. 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. Both read persisted state only, so a status written outside the request path is what the next poll returns.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "PIPE-9"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-1.md"
    },
    {
      "id": "PIPE-2",
      "family": "RT-PIPE",
      "part": "II",
      "title": "The job status summary has one shape on every path",
      "normative": "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. 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. The job API's own `status_summary` publishes that same shape.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PIPE-3"
      ],
      "backlinks": [
        "PIPE-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-2.md"
    },
    {
      "id": "PIPE-3",
      "family": "RT-PIPE",
      "part": "II",
      "title": "Status vocabulary is guarded in the summary and raw in the node counts",
      "summary": "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.",
      "normative": "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. The buckets may therefore sum to less than `total`, and a value outside the vocabulary must never appear as a key there. Each read reports the distinct unrecognised values once. 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`.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PIPE-2",
        "PIPE-4"
      ],
      "backlinks": [
        "PIPE-2",
        "PIPE-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-3.md"
    },
    {
      "id": "PIPE-4",
      "family": "RT-PIPE",
      "part": "II",
      "title": "Node statuses are keyed by workflow node id and collapse every iteration",
      "summary": "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.",
      "normative": "`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. Iterations of a node produced by a loop carry the plain node id, so every iteration collapses onto one entry. Only nodes that produced work get a key; a node excluded from execution has no entry and reads as idle. 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. A job entry's `node_id` carries the same ids.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PIPE-3",
        "PIPE-7"
      ],
      "backlinks": [
        "PIPE-3",
        "PIPE-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-4.md"
    },
    {
      "id": "PIPE-5",
      "family": "RT-PIPE",
      "part": "II",
      "title": "A run's jobs are the ones the run itself names",
      "summary": "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.",
      "normative": "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. A job must not be attributed to a run by scanning jobs for a stamp, because nothing is required to write one. 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. The walk is bounded, and null means no candidate is viewable. 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. Each run-scoped surface additionally filters its jobs by per-job view authority: authority over a run never implies authority over its jobs.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PIPE-7"
      ],
      "backlinks": [
        "PIPE-6",
        "PIPE-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-5.md"
    },
    {
      "id": "PIPE-6",
      "family": "RT-PIPE",
      "part": "II",
      "title": "Authorization is decided before the handler, and travels with its cacheability",
      "summary": "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.",
      "normative": "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. 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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PIPE-5",
        "SNAP-2"
      ],
      "backlinks": [
        "SNAP-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-6.md"
    },
    {
      "id": "PIPE-7",
      "family": "RT-PIPE",
      "part": "II",
      "title": "One job entry shape, published identically by every surface",
      "summary": "Three surfaces publish a job. They publish one key set in one order, because two copies of a formatter had already drifted apart once.",
      "normative": "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. `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. `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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PIPE-4",
        "PIPE-5"
      ],
      "backlinks": [
        "PIPE-4",
        "PIPE-5"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-7.md"
    },
    {
      "id": "PIPE-8",
      "family": "RT-PIPE",
      "part": "II",
      "title": "Published execution context is filtered, not forwarded",
      "summary": "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.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-8.md"
    },
    {
      "id": "PIPE-9",
      "family": "RT-PIPE",
      "part": "II",
      "title": "A timestamp is never fabricated",
      "summary": "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.",
      "normative": "`lastExecuted` is the run's completion stamp, or null where the run has not completed, on the full document and the lightweight one alike. `createdAt` is the run's creation stamp, or null where none is recorded, on both documents equally. No timestamp is substituted for a missing one.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-19"
      ],
      "related": [
        "PIPE-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-pipe/pipe-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-pipe/pipe-9.md"
    },
    {
      "id": "PLAY-1",
      "family": "RT-PLAY",
      "part": "II",
      "title": "Playground sessions and messages are addressed and published by UUID",
      "normative": "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. 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. So that one door accepts internal identifiers inbound and answers UUIDs outbound, deliberately.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PLAY-5"
      ],
      "backlinks": [
        "PLAY-2",
        "PLAY-5"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-play/play-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-play/play-1.md"
    },
    {
      "id": "PLAY-2",
      "family": "RT-PLAY",
      "part": "II",
      "title": "Creating a session answers the same row the list publishes",
      "summary": "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.",
      "normative": "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. `owner` is exactly `{id, name}`. `createdAt` and `updatedAt` are ISO 8601 strings, never numeric timestamps. `executions` is an empty list, not null, for a session that has never run.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PLAY-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-play/play-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-play/play-2.md"
    },
    {
      "id": "PLAY-3",
      "family": "RT-PLAY",
      "part": "II",
      "title": "The message poll has its own envelope, with the flags at the top level",
      "summary": "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.",
      "normative": "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. `hasMore` reports page fullness (whether the page returned as many messages as were asked for) and is an inference. `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. `sessionStatus` rides along so a poller needs no second request to learn the turn has finished. The forward and backward cursors are honoured only when they are strings of digits; a cursor that is not is ignored, not refused.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PLAY-4"
      ],
      "backlinks": [
        "PLAY-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-play/play-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-play/play-3.md"
    },
    {
      "id": "PLAY-4",
      "family": "RT-PLAY",
      "part": "II",
      "title": "One message row, three doors, base keys always present",
      "normative": "The message list, the single-message read and the send acknowledgement publish the same message row. Its base keys (`id`, `sessionId`, `role`, `content`, `timestamp`, `status`, `sequenceNumber`, `nodeId`, `metadata`, in that order) are always present. 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. The single-message read resolves lineage on the same terms as the list, so its row is not a lesser one. `timestamp` is an ISO 8601 string. The lightweight message status read is a different, four-key document (`id`, `status`, `sequenceNumber`, `timestamp`) and is not this row.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PLAY-3"
      ],
      "backlinks": [
        "INT-22",
        "PLAY-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-play/play-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-play/play-4.md"
    },
    {
      "id": "PLAY-5",
      "family": "RT-PLAY",
      "part": "II",
      "title": "The session list is ownership-scoped, and an emptied filter means none",
      "summary": "The failure mode this rules out is an explicit request for no sessions answered with every sibling session in the workflow.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "PLAY-1"
      ],
      "backlinks": [
        "PLAY-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-play/play-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-play/play-5.md"
    },
    {
      "id": "R1.a",
      "family": "GR-VAL",
      "part": "I",
      "title": "A node's executor must exist",
      "normative": "A node whose node type resolves to an executor the implementation does not provide is refused with the error code `R1_PLUGIN_MISSING`. The verdict is reached from the executor's declaration alone; the executor is never constructed in order to decide it.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R9"
      ],
      "backlinks": [
        "R1.b",
        "R1.c",
        "R9"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r1-a",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r1-a.md"
    },
    {
      "id": "R1.b",
      "family": "GR-VAL",
      "part": "I",
      "title": "A node with no resolvable node type is not judged by R1",
      "summary": "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.",
      "normative": "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. A node anchored to an unknown node type is refused by R9 instead, and a node with no anchor at all is accepted.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R1.a",
        "R9"
      ],
      "backlinks": [
        "R9"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r1-b",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r1-b.md"
    },
    {
      "id": "R1.c",
      "family": "GR-VAL",
      "part": "I",
      "title": "A missing executor is reported once per node",
      "normative": "A node whose executor is missing yields exactly one R1 error, however many ports the node exposes. The defect is a property of the node, and is never re-reported per port.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R1.a"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r1-c",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r1-c.md"
    },
    {
      "id": "R10",
      "family": "GR-VAL",
      "part": "I",
      "title": "A workflow may not expose a port hidden on its node",
      "summary": "Hidden means hidden in both directions. R7 stops an edge reaching a hidden port; R10 stops the workflow's public surface reaching one.",
      "normative": "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`. 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. A port the node type's metadata does not declare is out of scope, mirroring R7.d. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-2"
      ],
      "related": [
        "R7.c",
        "R7.d",
        "R4.d"
      ],
      "backlinks": [
        "R7.c",
        "R7.d",
        "R4.d"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r10",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r10.md"
    },
    {
      "id": "R11",
      "family": "GR-VAL",
      "part": "I",
      "title": "A configured expression must be valid for its engine at save",
      "summary": "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.",
      "normative": "Every expression carried in a node's configuration must pass its engine's validation when the workflow is saved. A failure is refused with `R11_EXPRESSION_INVALID`, and the error names the node, the config key, the engine and the expression. 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`. An empty expression is always valid. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-3"
      ],
      "related": [
        "R6.d"
      ],
      "backlinks": [
        "R6.k"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r11",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r11.md"
    },
    {
      "id": "R12",
      "family": "GR-VAL",
      "part": "I",
      "title": "A node may not be wired to itself",
      "normative": "An edge whose non-empty `source` equals its `target` is refused with `R12_EDGE_SELF` at `edge.{index}`. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-7"
      ],
      "related": [
        "R8.a",
        "R8.b"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r12",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r12.md"
    },
    {
      "id": "R13",
      "family": "GR-VAL",
      "part": "I",
      "title": "Two identical edges between the same ports are refused",
      "summary": "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.",
      "normative": "Two edges are duplicates when their `source`, `target` and both handle strings are identical. A duplicate is refused with `R13_EDGE_PARALLEL_DUPLICATE` at `edge.{index}`, and the error names the position of the earlier edge it duplicates. 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. An edge with an empty endpoint takes no part in the comparison.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-7"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r13",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r13.md"
    },
    {
      "id": "R2",
      "family": "GR-VAL",
      "part": "I",
      "title": "Node ids are unique within a workflow",
      "summary": "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.",
      "normative": "Node `id`s must be unique within a workflow. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-4"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r2",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r2.md"
    },
    {
      "id": "R3",
      "family": "GR-VAL",
      "part": "I",
      "title": "Every edge carries a unique id",
      "normative": "Every edge in a workflow must carry an `id`, and edge `id`s must be unique within the workflow. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-5"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r3",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r3.md"
    },
    {
      "id": "R4.a",
      "family": "GR-VAL",
      "part": "I",
      "title": "An exposed workflow port must be named in the permitted alphabet",
      "summary": "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.",
      "normative": "A workflow exposure entry's `name` must match `^[a-z0-9_-]+$`. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R4.b",
        "R4.c",
        "R4.e"
      ],
      "backlinks": [
        "R4.b",
        "R4.c",
        "R4.d",
        "R4.e"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r4-a",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r4-a.md"
    },
    {
      "id": "R4.b",
      "family": "GR-VAL",
      "part": "I",
      "title": "Exposure names are unique per side",
      "normative": "Workflow exposure names must be unique within a side. Inputs and outputs are independent, so one name may appear once as an input and once as an output. A repeat within one side is refused with `R4_NAME_DUPLICATE` at `schema.{side}.{index}`.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R4.a"
      ],
      "backlinks": [
        "R4.a",
        "R4.d"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r4-b",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r4-b.md"
    },
    {
      "id": "R4.c",
      "family": "GR-VAL",
      "part": "I",
      "title": "An exposure entry must name a node that exists",
      "normative": "A workflow exposure entry's `node_id` must name a node in the workflow. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R4.a",
        "R4.d"
      ],
      "backlinks": [
        "R4.a"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r4-c",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r4-c.md"
    },
    {
      "id": "R4.d",
      "family": "GR-VAL",
      "part": "I",
      "title": "An exposure entry must name a port the node declares",
      "normative": "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. An entry naming an undeclared port is refused at `schema.{side}.{index}`. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R4.a",
        "R4.b",
        "R10"
      ],
      "backlinks": [
        "R4.c",
        "R10"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r4-d",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r4-d.md"
    },
    {
      "id": "R4.e",
      "family": "GR-VAL",
      "part": "I",
      "title": "An exposure name may not collide with a reserved runtime name",
      "summary": "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.",
      "normative": "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. A reserved name is refused with `R4_NAME_RESERVED` at `schema.{side}.{index}`, on the input side and the output side alike.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R4.a"
      ],
      "backlinks": [
        "R4.a"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r4-e",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r4-e.md"
    },
    {
      "id": "R5.a",
      "family": "GR-VAL",
      "part": "I",
      "title": "A workflow is bounded to 500 nodes",
      "normative": "A workflow must not contain more than 500 nodes. A workflow that does is refused with the error code `R5_TOO_MANY_NODES`, and the save does not take effect.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r5-a",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r5-a.md"
    },
    {
      "id": "R5.b",
      "family": "GR-VAL",
      "part": "I",
      "title": "A workflow is bounded to 1000 edges",
      "normative": "A workflow must not contain more than 1000 edges. A workflow that does is refused with the error code `R5_TOO_MANY_EDGES`, and the save does not take effect.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r5-b",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r5-b.md"
    },
    {
      "id": "R5.c",
      "family": "GR-VAL",
      "part": "I",
      "title": "Every node carries an id",
      "summary": "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.",
      "normative": "Every node in a workflow must carry a non-empty `id`. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r5-c",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r5-c.md"
    },
    {
      "id": "R6.a",
      "family": "GR-VAL",
      "part": "I",
      "title": "Config is a JSON object",
      "summary": "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.",
      "normative": "A node's `data.config`, where present, must be a JSON object. 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. 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).",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R6.b",
        "R6.j"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-a",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-a.md"
    },
    {
      "id": "R6.b",
      "family": "GR-VAL",
      "part": "I",
      "title": "Required config keys must be present",
      "summary": "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.",
      "normative": "Every key the node's derived config schema marks required must be present in the node's `config`. A key present with the value `null` counts as present. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R6.i"
      ],
      "backlinks": [
        "R6.i",
        "R6.a"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-b",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-b.md"
    },
    {
      "id": "R6.c",
      "family": "GR-VAL",
      "part": "I",
      "title": "An unknown config key warns but does not block",
      "normative": "A key in a node's `config` that the node's derived config schema does not declare does not refuse the save. It yields a warning at `node.{id}.config.{key}` saying the value is ignored at execution, and the workflow is stored.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-c",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-c.md"
    },
    {
      "id": "R6.d",
      "family": "GR-VAL",
      "part": "I",
      "title": "A config value must match its declared type",
      "summary": "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.",
      "normative": "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. 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. 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. The arms are distinguished by the error's message, not by its code.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R6.e",
        "R6.f",
        "R6.g",
        "R6.h"
      ],
      "backlinks": [
        "R6.e",
        "R6.f",
        "R6.g",
        "R6.h",
        "R11"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-d",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-d.md"
    },
    {
      "id": "R6.e",
      "family": "GR-VAL",
      "part": "I",
      "title": "A config value must be one of its enumerated values",
      "normative": "Where a config value's schema declares an `enum`, the value must be one of the listed values. Comparison is exact and applies no type coercion, so the number `1` does not satisfy an `enum` listing the string `\"1\"`. A value outside the list is refused with `R6_CONFIG_INVALID` at `node.{id}.config.{key}`.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R6.d"
      ],
      "backlinks": [
        "R6.d"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-e",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-e.md"
    },
    {
      "id": "R6.f",
      "family": "GR-VAL",
      "part": "I",
      "title": "Numeric bounds apply to numbers only",
      "normative": "A config value below its schema's `minimum` or above its `maximum` is refused with `R6_CONFIG_INVALID` at `node.{id}.config.{key}`. Bounds are evaluated only for a value that is a JSON number; a string is never bounds-checked, even when it spells a number.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R6.d"
      ],
      "backlinks": [
        "R6.d"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-f",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-f.md"
    },
    {
      "id": "R6.g",
      "family": "GR-VAL",
      "part": "I",
      "title": "Length bounds count characters, never bytes",
      "summary": "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.",
      "normative": "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}`. Both bounds count Unicode code points, never bytes.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R6.d"
      ],
      "backlinks": [
        "R6.d"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-g",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-g.md"
    },
    {
      "id": "R6.h",
      "family": "GR-VAL",
      "part": "I",
      "title": "A pattern mismatch is refused, a broken pattern is not",
      "summary": "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.",
      "normative": "A string config value that does not match its schema's `pattern` is refused with `R6_CONFIG_INVALID` at `node.{id}.config.{key}`. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R6.d"
      ],
      "backlinks": [
        "R6.d"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-h",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-h.md"
    },
    {
      "id": "R6.i",
      "family": "GR-VAL",
      "part": "I",
      "title": "A null config value means unset",
      "normative": "A config value of `null` means the key is unset. It satisfies a required key, and no schema constraint is evaluated against it, so a `null` never fails type, `enum`, bounds or `pattern`.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R6.b"
      ],
      "backlinks": [
        "R6.b"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-i",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-i.md"
    },
    {
      "id": "R6.j",
      "family": "GR-VAL",
      "part": "I",
      "title": "No node type, no schema-driven config verdict",
      "normative": "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. The unresolvable node type is reported by R9.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R9"
      ],
      "backlinks": [
        "R6.a"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-j",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-j.md"
    },
    {
      "id": "R6.k",
      "family": "GR-VAL",
      "part": "I",
      "title": "There is no separate per-executor config verdict",
      "normative": "A per-executor configuration-validation step was once part of the node executor contract. It decided nothing, and it is withdrawn: an implementation must not gate a save on one. 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.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R11"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-k",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-k.md"
    },
    {
      "id": "R6.l",
      "family": "GR-VAL",
      "part": "I",
      "title": "A malformed secret reference warns the author",
      "summary": "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.",
      "normative": "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 }}`. Config is walked recursively, so a reference nested inside structured config is covered. A correctly written `${{ secrets.NAME }}` produces no warning, and the save is never blocked either way.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r6-l",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r6-l.md"
    },
    {
      "id": "R7.a",
      "family": "GR-VAL",
      "part": "I",
      "title": "An edge may not target a hidden input port",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R7.c"
      ],
      "backlinks": [
        "R7.c",
        "R7.d",
        "R7.e/f"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r7-a",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r7-a.md"
    },
    {
      "id": "R7.b",
      "family": "GR-VAL",
      "part": "I",
      "title": "An edge may not leave a hidden output port",
      "normative": "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}`.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R7.c"
      ],
      "backlinks": [
        "R7.c",
        "R7.d",
        "R7.e/f"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r7-b",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r7-b.md"
    },
    {
      "id": "R7.c",
      "family": "GR-VAL",
      "part": "I",
      "title": "A node instance decides which of its ports are exposed",
      "summary": "One chain answers \"is this port exposed?\" everywhere it is asked: for edges and for the workflow's exposure map alike.",
      "normative": "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. Exposing a port on the instance therefore makes an edge to or from it legal that would otherwise be refused.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R7.a",
        "R7.b",
        "R10"
      ],
      "backlinks": [
        "R7.a",
        "R7.b",
        "R10"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r7-c",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r7-c.md"
    },
    {
      "id": "R7.d",
      "family": "GR-VAL",
      "part": "I",
      "title": "A port the node type does not declare is outside exposure checking",
      "normative": "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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R7.a",
        "R7.b",
        "R10"
      ],
      "backlinks": [
        "R10"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r7-d",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r7-d.md"
    },
    {
      "id": "R7.e/f",
      "family": "GR-VAL",
      "part": "I",
      "title": "Exposure checking is skipped per endpoint, not per edge",
      "summary": "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.",
      "normative": "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. The edge's other endpoint is still checked. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R7.a",
        "R7.b",
        "R8.a",
        "R8.b",
        "R9"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r7-e-f",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r7-e-f.md"
    },
    {
      "id": "R8.a",
      "family": "GR-VAL",
      "part": "I",
      "title": "An edge's source node must exist",
      "normative": "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. An empty `source` counts as missing.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R8.b"
      ],
      "backlinks": [
        "R8.b",
        "R8.c",
        "R7.e/f",
        "W-T",
        "R12"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r8-a",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r8-a.md"
    },
    {
      "id": "R8.b",
      "family": "GR-VAL",
      "part": "I",
      "title": "An edge's target node must exist",
      "normative": "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. An empty `target` counts as missing.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R8.a"
      ],
      "backlinks": [
        "R8.a",
        "R8.c",
        "R7.e/f",
        "W-T",
        "R12"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r8-b",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r8-b.md"
    },
    {
      "id": "R8.c",
      "family": "GR-VAL",
      "part": "I",
      "title": "Two dangling endpoints are two errors",
      "normative": "An edge whose `source` and `target` are both missing yields two errors, one for each endpoint, both at the same `edge.{index}` locator. The endpoints are judged independently, so neither failure suppresses the other.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R8.a",
        "R8.b"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/r8-c",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r8-c.md"
    },
    {
      "id": "R9",
      "family": "GR-VAL",
      "part": "I",
      "title": "A node's node type anchor must resolve",
      "summary": "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.",
      "normative": "A node's `node_type_id` anchor must resolve to an existing node type. 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. A node carrying no anchor is out of scope and is not refused by this rule.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-6"
      ],
      "related": [
        "R1.a",
        "R1.b"
      ],
      "backlinks": [
        "R1.a",
        "R1.b",
        "R6.j",
        "R7.e/f"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-val/r9",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/r9.md"
    },
    {
      "id": "RT-GATE-1",
      "family": "RT-GATE",
      "part": "II",
      "title": "A gated node never executes without consent for that exact call",
      "summary": "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.",
      "normative": "A node whose effective confirmation requirement is true must not execute without a consumed, hash-matching, confirmed consent (RT-GATE-2, RT-GATE-3). 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. 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. 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. 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`.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-18",
        "RT-GATE-2",
        "RT-GATE-3",
        "RT-GATE-4",
        "RT-GATE-7"
      ],
      "backlinks": [
        "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"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-1.md"
    },
    {
      "id": "RT-GATE-11",
      "family": "RT-GATE",
      "part": "II",
      "title": "A gated node whose executor cannot be resolved never executes",
      "summary": "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.",
      "normative": "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. The derivation fails, and the node fails as any node with an unresolvable executor does. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-1",
        "RT-GATE-7"
      ],
      "backlinks": [
        "RT-GATE-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-11",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-11.md"
    },
    {
      "id": "RT-GATE-12",
      "family": "RT-GATE",
      "part": "II",
      "title": "A resolved secret never persists in the gate prompt",
      "summary": "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.",
      "normative": "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 `<secret>` in the prompt shown to the operator and persisted with the interrupt. Tracking is by parameter rather than by matching the secret's text, because the substitution may be partial or nested inside a structured value. 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). 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-1",
        "RT-GATE-3"
      ],
      "backlinks": [
        "RT-GATE-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-12",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-12.md"
    },
    {
      "id": "RT-GATE-13",
      "family": "RT-GATE",
      "part": "II",
      "title": "A gate question belongs to the initiator, and an ownerless one is findable",
      "summary": "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.",
      "normative": "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. A run with no initiator, such as one launched by a schedule, a webhook or an anonymous trigger, raises its question unassigned. 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. 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. Where nobody attends an unassigned gate question it expires and the run is cancelled (RT-GATE-1, INT-18): fail closed, never silently approved.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "INT-18",
        "RT-GATE-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-13",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-13.md"
    },
    {
      "id": "RT-GATE-14",
      "family": "RT-GATE",
      "part": "II",
      "title": "Confirmation governance is its own grant",
      "summary": "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.",
      "normative": "Confirmation governance is controlled by a dedicated administrative authority, separate from the authority to administer node types, and restricted. 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). The strength of the gate is exactly the strength of who holds this authority and who may edit workflows.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-7",
        "RT-GATE-8"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-14",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-14.md"
    },
    {
      "id": "RT-GATE-15",
      "family": "RT-GATE",
      "part": "II",
      "title": "A shipped side-effecting node type states its policy",
      "summary": "\"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.",
      "normative": "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). The derivation reads \"mutates persistent state\" as \"performs an action an operator should approve\", which is not the same question. A shipped declaration should be `ask` where the effect leaves the implementation's own boundary and `skip` where it does not. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-1",
        "RT-GATE-7"
      ],
      "backlinks": [
        "RT-GATE-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-15",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-15.md"
    },
    {
      "id": "RT-GATE-2",
      "family": "RT-GATE",
      "part": "II",
      "title": "Consent is consumed exactly once, declines included",
      "summary": "An approval authorises one execution. An agent loop re-issuing the same call is asked again every time, which is the point.",
      "normative": "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. A decline is consumed the same way, so a decline never re-fires. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-1",
        "RT-GATE-3"
      ],
      "backlinks": [
        "RT-GATE-1",
        "RT-GATE-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-2.md"
    },
    {
      "id": "RT-GATE-3",
      "family": "RT-GATE",
      "part": "II",
      "title": "Consent binds to the node and its exact resolved arguments",
      "summary": "The operator approved one call, not \"this node from now on\". If the arguments drift, the approval no longer describes what would happen.",
      "normative": "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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-1",
        "RT-GATE-2",
        "RT-GATE-9",
        "RT-GATE-12"
      ],
      "backlinks": [
        "RT-GATE-1",
        "RT-GATE-2",
        "RT-GATE-5",
        "RT-GATE-9",
        "RT-GATE-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-3.md"
    },
    {
      "id": "RT-GATE-4",
      "family": "RT-GATE",
      "part": "II",
      "title": "A declined node routes a structured verdict out its error port",
      "summary": "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.",
      "normative": "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. Where an error edge is wired the decline is a handled failure (ERR-7); otherwise the run's default failure behaviour applies (ERR-8). 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. An author who wants first-class branching on a human decision uses an explicit confirmation node.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-7",
        "ERR-8",
        "RT-GATE-1",
        "RT-GATE-5"
      ],
      "backlinks": [
        "RT-GATE-1",
        "RT-GATE-5"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-4.md"
    },
    {
      "id": "RT-GATE-5",
      "family": "RT-GATE",
      "part": "II",
      "title": "A declined tool call is model-recoverable, never run-fatal",
      "summary": "Failing a whole agent run because a person said \"no\" to one tool call would be wrong. The model is told, and carries on.",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-3",
        "RT-GATE-4",
        "RT-GATE-6"
      ],
      "backlinks": [
        "RT-GATE-4",
        "RT-GATE-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-5.md"
    },
    {
      "id": "RT-GATE-6",
      "family": "RT-GATE",
      "part": "II",
      "title": "A gate pause leaves no phantom failure in the tool trail",
      "summary": "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.",
      "normative": "An interrupt escaping a tool invocation records the attempt as interrupted, not failed. 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. 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). One pause raises exactly one question, however many times the tool plane observes the same interrupt.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "ERR-9",
        "RT-GATE-5"
      ],
      "backlinks": [
        "RT-GATE-5"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-6.md"
    },
    {
      "id": "RT-GATE-7",
      "family": "RT-GATE",
      "part": "II",
      "title": "Whether a node asks is a governance decision, resolved in order",
      "summary": "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.",
      "normative": "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. 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. A node type that has never stored a governance mapping resolves to the defaults: authors may waive and may require, with no dynamic surface. A stored mapping is taken literally: an emptied list of allowed controls is a revocation, not a fallback to the defaults.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-1",
        "RT-GATE-8",
        "RT-GATE-9",
        "RT-GATE-11",
        "RT-GATE-15"
      ],
      "backlinks": [
        "RT-GATE-1",
        "RT-GATE-8",
        "RT-GATE-9",
        "RT-GATE-11",
        "RT-GATE-14",
        "RT-GATE-15"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-7.md"
    },
    {
      "id": "RT-GATE-8",
      "family": "RT-GATE",
      "part": "II",
      "title": "Governance can revoke a waiver already stored",
      "summary": "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.",
      "normative": "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. Withdrawing the waive control therefore re-gates every instance that had waived, and a stored choice whose control is no longer granted is ignored. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-7"
      ],
      "backlinks": [
        "RT-GATE-7",
        "RT-GATE-9",
        "RT-GATE-14"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-8.md"
    },
    {
      "id": "RT-GATE-9",
      "family": "RT-GATE",
      "part": "II",
      "title": "Dynamic escalation can add an approval but never remove one",
      "summary": "Data flowing into a node (including arguments a model filled) may raise the bar for that execution. It may never lower it.",
      "normative": "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. A falsy value does not participate: upstream data can add an approval requirement and must never remove one. A value whose truthiness cannot be determined escalates; over-asking is the fail-safe direction for a value crossing a port. 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. 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).",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-GATE-3",
        "RT-GATE-7",
        "RT-GATE-8"
      ],
      "backlinks": [
        "RT-GATE-3",
        "RT-GATE-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-gate/rt-gate-9.md"
    },
    {
      "id": "RT-TOOL-1",
      "family": "RT-TOOL",
      "part": "II",
      "title": "A tool result carries artifacts alongside its data, never inside it",
      "summary": "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.",
      "normative": "A tool result is `{success, data, error}`. 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. Artifacts are attached additively, and are never folded into `data`: the two are disjoint.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-TOOL-2"
      ],
      "backlinks": [
        "RT-TOOL-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-1.md"
    },
    {
      "id": "RT-TOOL-10",
      "family": "RT-TOOL",
      "part": "II",
      "title": "A tool-calling pass reports whether it did any work",
      "summary": "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.",
      "normative": "A node that invokes tools reports `executed_any`, a boolean, and `executed`, the call identifiers behind it, the complement of the `skipped` list. `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. An empty batch reports false. 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. `executed_any` is exposed by default; `executed` is not.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-TOOL-9"
      ],
      "backlinks": [
        "RT-TOOL-9"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-10",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-10.md"
    },
    {
      "id": "RT-TOOL-2",
      "family": "RT-TOOL",
      "part": "II",
      "title": "A tool node emits artifacts on a reserved key that never reaches the model",
      "normative": "A tool node's output may carry the reserved `artifacts` key, a list of `{type, payload}` maps. It is a system channel, exempt from output-port exposure, and so survives whether or not the author exposed the port. 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. The reserved key is always removed from the result's `data`, so it never reaches the prose a tool-consuming node feeds the model. A call that failed or was declined carries no artifacts.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-TOOL-1",
        "RT-TOOL-3"
      ],
      "backlinks": [
        "RT-TOOL-1",
        "RT-TOOL-3",
        "RT-TOOL-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-2.md"
    },
    {
      "id": "RT-TOOL-3",
      "family": "RT-TOOL",
      "part": "II",
      "title": "Artifacts leave a tool call on their own port, apart from the model's prose",
      "normative": "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. That port is not exposed by default. The prose fed back to the model is built from a result's `data` alone and never from its artifacts. Delivery of a run's artifacts must not depend on whether the author exposed the port.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-TOOL-2",
        "RT-TOOL-4"
      ],
      "backlinks": [
        "RT-TOOL-2",
        "RT-TOOL-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-3.md"
    },
    {
      "id": "RT-TOOL-4",
      "family": "RT-TOOL",
      "part": "II",
      "title": "A turn's artifacts are persisted on its message and reported on its result",
      "normative": "The artifacts a run collected are persisted on the last assistant message that run wrote, once per run. 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. On the wire a message carries them as a top-level `toolArtifacts` field rather than as raw message metadata.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-TOOL-3",
        "RT-TOOL-7"
      ],
      "backlinks": [
        "RT-TOOL-3",
        "RT-TOOL-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-4.md"
    },
    {
      "id": "RT-TOOL-5",
      "family": "RT-TOOL",
      "part": "II",
      "title": "Artifact collection is opened and released per run",
      "summary": "Collecting artifacts means holding payloads in memory, so the run that opens collection is the run that ends it, on every way out.",
      "normative": "An implementation need not collect tool artifacts at all. 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. 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. 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. Whether artifacts are collected must not depend on which entry point launched the run.",
      "posture": "normative-target",
      "level": "optional",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-5.md"
    },
    {
      "id": "RT-TOOL-6",
      "family": "RT-TOOL",
      "part": "II",
      "title": "Artifacts are bounded once, at the point they are collected",
      "normative": "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. 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. 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. A drop is per artifact, so a later, smaller artifact that fits the remaining budget is still kept. An artifact with an empty `type` is refused outright. A payload is untrusted tool and model output: a consumer must treat every value in it as plain text and escape it on output.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-TOOL-2",
        "RT-TOOL-7"
      ],
      "backlinks": [
        "RT-TOOL-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-6.md"
    },
    {
      "id": "RT-TOOL-7",
      "family": "RT-TOOL",
      "part": "II",
      "title": "A pause does not destroy artifacts, and does not deliver them twice",
      "normative": "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. 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. 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. 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. The held list is itself bounded; where it would exceed the bound, the newest entries are dropped with a warning.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-TOOL-4",
        "RT-TOOL-6"
      ],
      "backlinks": [
        "RT-TOOL-4",
        "RT-TOOL-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-7.md"
    },
    {
      "id": "RT-TOOL-8",
      "family": "RT-TOOL",
      "part": "II",
      "title": "A model's tool arguments are normalized against the tool's own schema",
      "summary": "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.",
      "normative": "Before a tool is invoked, the arguments a model supplied are reconciled with the tool's declared input schema. 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`. 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. 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. Keys are never modified, and text containing no character reference is returned unchanged.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "references": {
        "normative": [
          {
            "source": "HTML Standard",
            "title": "Named character references",
            "url": "https://html.spec.whatwg.org/multipage/named-characters.html",
            "note": "The set of named references a string argument is decoded against."
          }
        ]
      },
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-8.md"
    },
    {
      "id": "RT-TOOL-9",
      "family": "RT-TOOL",
      "part": "II",
      "title": "A tool call executes at most once per run",
      "summary": "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.",
      "normative": "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. 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. 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. 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. A call that carries no identifier cannot be guarded and always runs.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "RT-TOOL-10"
      ],
      "backlinks": [
        "RT-TOOL-10"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-tool/rt-tool-9.md"
    },
    {
      "id": "SCH-1",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A node's ports come from its two declared schemas",
      "summary": "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.",
      "normative": "A node type declares its input surface as one JSON Schema and its output surface as another. Those two schemas are the only source of a node's ports. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-1.md"
    },
    {
      "id": "SCH-10.a",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A site's lane configuration overlays the shipped one",
      "summary": "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.",
      "normative": "A stored site-level lane configuration is an overlay on the shipped defaults, not a replacement. 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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-10",
        "SCH-41",
        "SCH-44",
        "SCH-46"
      ],
      "backlinks": [
        "SCH-36",
        "SCH-41",
        "SCH-44",
        "SCH-46",
        "SCH-45"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-10-a",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-10-a.md"
    },
    {
      "id": "SCH-10",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "The map from a schema type to a port lane",
      "summary": "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.",
      "normative": "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. Any other value, and an absent type, yield the sink, never `string`. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-34",
        "SCH-35",
        "SCH-36",
        "SCH-37",
        "SCH-41"
      ],
      "backlinks": [
        "SCH-8",
        "SCH-10.a",
        "SCH-34",
        "SCH-35",
        "SCH-41"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-10",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-10.md"
    },
    {
      "id": "SCH-11",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Every node but a start node gets a trigger input",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-16"
      ],
      "backlinks": [
        "SCH-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-11",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-11.md"
    },
    {
      "id": "SCH-12",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Every node but a terminal node gets a trigger output",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-16"
      ],
      "backlinks": [
        "SCH-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-12",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-12.md"
    },
    {
      "id": "SCH-13",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A tool-exposed node gets a tool output",
      "normative": "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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-16"
      ],
      "backlinks": [
        "SCH-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-13",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-13.md"
    },
    {
      "id": "SCH-14",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Every executable node gets a hidden error output",
      "normative": "A reserved `error` output port is injected on every node type except a non-executable one, carries display order 120, and ships hidden.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-16"
      ],
      "backlinks": [
        "SCH-16"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-14",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-14.md"
    },
    {
      "id": "SCH-15",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A reserved port states its exposure only when it diverges",
      "normative": "An injected reserved port declares `x-exposed-by-default: false` only where that diverges from the default of true. Where it is exposed by default it says nothing, and a reader takes silence as exposed.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-16",
        "SCH-27"
      ],
      "backlinks": [
        "SCH-27"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-15",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-15.md"
    },
    {
      "id": "SCH-16",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "What each reserved port defaults to on the canvas",
      "normative": "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. A node type may override the default per port, and stores only the keys whose value diverges from the default.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-11",
        "SCH-12",
        "SCH-13",
        "SCH-14",
        "SCH-32"
      ],
      "backlinks": [
        "SCH-8",
        "SCH-11",
        "SCH-12",
        "SCH-13",
        "SCH-14",
        "SCH-15",
        "SCH-32"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-16",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-16.md"
    },
    {
      "id": "SCH-17",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "The unified input port",
      "normative": "A unified `input` port is prepended to a node's ports where its node type exposes one. It carries the `json` lane, and its description enumerates the node's connectable parameter keys only.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-8",
        "SCH-19"
      ],
      "backlinks": [
        "SCH-8",
        "SCH-19"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-17",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-17.md"
    },
    {
      "id": "SCH-18",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "The unified output port",
      "normative": "A unified `output` port is prepended to a node's ports where its node type exposes one. It carries the `json` lane, and its description enumerates the node's exposed output keys only.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-7",
        "SCH-8",
        "SCH-19"
      ],
      "backlinks": [
        "SCH-8",
        "SCH-19"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-18",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-18.md"
    },
    {
      "id": "SCH-19",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A unified port's description skips trigger and hidden keys",
      "normative": "Both unified-port key enumerations skip the `trigger` port and every hidden parameter. A hidden port does not exist on the instance, so it never appears in a unified port's description.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-4",
        "SCH-17",
        "SCH-18"
      ],
      "backlinks": [
        "SCH-4",
        "SCH-17",
        "SCH-18"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-19",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-19.md"
    },
    {
      "id": "SCH-2",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Gate flags come from the node type, and default off",
      "summary": "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.",
      "normative": "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. A parameter the node type does not declare therefore appears in neither derived schema and in neither `required` list. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-3",
        "SCH-6"
      ],
      "backlinks": [
        "SCH-3",
        "SCH-6"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-2.md"
    },
    {
      "id": "SCH-20",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Visual type is the node type's, and is offered where there is a choice",
      "normative": "A node's visual type comes from its node type, never from a declared schema. 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. This write is unconditional: it overwrites a declared property of the same name.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-21"
      ],
      "backlinks": [
        "SCH-21"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-20",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-20.md"
    },
    {
      "id": "SCH-21",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "The reserved config properties every node carries",
      "normative": "`instanceTitle` and `instanceDescription` are injected into every node's config schema; `maxRetries` and `ports` into an executable node's config schema only. Each injection is skipped where the node type already declares a property of that name.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-20"
      ],
      "backlinks": [
        "SCH-20",
        "SCH-23"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-21",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-21.md"
    },
    {
      "id": "SCH-22",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A config-edit descriptor appears only where one is provided",
      "normative": "A node's metadata carries a `configEdit` descriptor only where its node type provides one. A node type that provides none carries no such key.",
      "posture": "normative-target",
      "level": "optional",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-22",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-22.md"
    },
    {
      "id": "SCH-23",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "When a node has a ui schema, and when it has none",
      "summary": "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.",
      "normative": "The ui schema is generated from the final config schema, after every reserved property has been injected. 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. Groups are collapsed by default; a group is opened for a node instance where that instance sets one of the fields in it.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-21",
        "SCH-31"
      ],
      "backlinks": [
        "SCH-31"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-23",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-23.md"
    },
    {
      "id": "SCH-24",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A node type that fails to build does not fail the request",
      "summary": "One unbuildable node type costs the reader that node's metadata, not the whole response.",
      "normative": "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. This governs failures attributable to the node type. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-25"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-24",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-24.md"
    },
    {
      "id": "SCH-25",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Enrichment anchors on the node type and changes nothing else",
      "normative": "A node is enriched from the node type it records, and from nothing else. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-29"
      ],
      "backlinks": [
        "SCH-29",
        "SCH-24"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-25",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-25.md"
    },
    {
      "id": "SCH-26",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "The catalog an editor reads",
      "normative": "The node catalog served to an editor lists enabled node types only, sorted by category and then by name.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-26",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-26.md"
    },
    {
      "id": "SCH-27",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A served port omits everything that matches the default",
      "summary": "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.",
      "normative": "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. A client must read the absence of those keys as no default, exposed, and order 0 respectively.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-8",
        "SCH-15"
      ],
      "backlinks": [
        "SCH-8",
        "SCH-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-27",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-27.md"
    },
    {
      "id": "SCH-28",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A node's executor is resolved from its node type",
      "normative": "The executor for a node is resolved from that node's node type. An executor named in stored node metadata is never trusted.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-29"
      ],
      "backlinks": [
        "SCH-29"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-28",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-28.md"
    },
    {
      "id": "SCH-29",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Stored metadata is never authoritative at run time",
      "normative": "Before a workflow runs, every node is re-enriched from its live node type and the workflow is normalized from the result. Metadata stored with the workflow is never authoritative at run time.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-25",
        "SCH-28"
      ],
      "backlinks": [
        "SCH-25",
        "SCH-28",
        "CMP-2"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-29",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-29.md"
    },
    {
      "id": "SCH-3",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "One parameter schema splits into two derived schemas",
      "normative": "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`. Both, one, or neither is legal.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-2"
      ],
      "backlinks": [
        "SCH-2",
        "SCH-6"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-3.md"
    },
    {
      "id": "SCH-31",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Config field order applies inside a ui schema only",
      "normative": "`x-config-order` orders config fields ascending; a property whose value is absent or not numeric counts as 0, and ties keep declaration order. 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. The earlier spelling `x-display-order` carries no meaning; a schema that still writes only it orders at 0, like any untagged property.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-23"
      ],
      "backlinks": [
        "SCH-4",
        "SCH-23"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-31",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-31.md"
    },
    {
      "id": "SCH-32",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Every re-enterable node gets a hidden loop-back input",
      "summary": "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.",
      "normative": "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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-16",
        "SCH-37",
        "SCH-38"
      ],
      "backlinks": [
        "SCH-16",
        "SCH-33",
        "SCH-38"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-32",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-32.md"
    },
    {
      "id": "SCH-33",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A schema that will not load never rewrites stored parameters",
      "summary": "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.",
      "normative": "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. 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. 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. Only the render path may treat an unloadable schema as no properties, where that correctly means render no rows.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-32"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-33",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-33.md"
    },
    {
      "id": "SCH-34",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A property's type is a JSON Schema type, not a port lane",
      "summary": "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.",
      "normative": "A property's `type` is one of JSON Schema's seven type words, or an array whose every member is one. It is never a port lane. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-9",
        "SCH-10",
        "SCH-35",
        "SCH-37"
      ],
      "backlinks": [
        "SCH-9",
        "SCH-10",
        "SCH-35",
        "SCH-37"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-34",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-34.md"
    },
    {
      "id": "SCH-35",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Lane derivation is total, and an undeclared port is the sink",
      "summary": "\"What does an undeclared port mean?\" once had seven disagreeing answers. It has one: the sink.",
      "normative": "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`. Derivation never falls back to `string`. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-9",
        "SCH-10",
        "SCH-34",
        "SCH-40"
      ],
      "backlinks": [
        "SCH-9",
        "SCH-10",
        "SCH-34",
        "SCH-40"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-35",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-35.md"
    },
    {
      "id": "SCH-36",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "One declaration site for the shipped lane vocabulary",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-10.a",
        "SCH-41"
      ],
      "backlinks": [
        "SCH-10",
        "SCH-41"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-36",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-36.md"
    },
    {
      "id": "SCH-37",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A control port declares its lane, never a schema type",
      "normative": "`trigger`, `tool` and `loop_back` carry no value, so no JSON Schema type describes them. 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. Control lanes remain full members of the lane vocabulary; what is forbidden is spelling one in `type`. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-34",
        "SCH-38"
      ],
      "backlinks": [
        "SCH-10",
        "SCH-32",
        "SCH-34",
        "SCH-38",
        "SCH-40"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-37",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-37.md"
    },
    {
      "id": "SCH-38.a",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A retired lane keeps one release of served-but-disabled compatibility",
      "normative": "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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-38"
      ],
      "backlinks": [
        "SCH-38"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-38-a",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-38-a.md"
    },
    {
      "id": "SCH-38",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A loop-back input is a control sink, and `any` is retired",
      "summary": "`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`.",
      "normative": "A `loop_back` input declares the `trigger` lane. `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. `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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-32",
        "SCH-37",
        "SCH-38.a",
        "SCH-39"
      ],
      "backlinks": [
        "SCH-32",
        "SCH-37",
        "SCH-38.a",
        "SCH-39"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-38",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-38.md"
    },
    {
      "id": "SCH-39",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Compatibility is asymmetric, and the sink needs rules both ways",
      "summary": "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.",
      "normative": "Compatibility maps an output lane to the set of input lanes it may enter. It is seeded by exact match and widened only by explicit rules, so a lane with no rules accepts nothing but itself. 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. `tool` is excluded from every sink rule in both directions. A rule that widens states its reason where it is declared, since the served `from`/`to` pair cannot carry one. The stakes are authoring-time: a wrong rule costs an author a wire they cannot draw, or lets them draw one nothing will reject.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-38",
        "SCH-43"
      ],
      "backlinks": [
        "SCH-38",
        "SCH-43"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-39",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-39.md"
    },
    {
      "id": "SCH-4",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A hidden parameter is dropped from everything",
      "normative": "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`. Hiding does not suppress a reserved config-only parameter: one a node type has opted into still reaches the config schema. Two spellings are honoured: `format: hidden`, which is the one to write, and a bare `hidden: true`, which is deprecated. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-5",
        "SCH-19",
        "SCH-31"
      ],
      "backlinks": [
        "SCH-5",
        "SCH-19"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-4.md"
    },
    {
      "id": "SCH-40",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A port's lane is consumed at a public boundary",
      "summary": "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.",
      "normative": "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. A port on the sink or on a control lane places no constraint: any JSON value is accepted, including an array or null. 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. The name a projection gives the field that carries the lane is likewise a client-facing contract.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-35",
        "SCH-37"
      ],
      "backlinks": [
        "SCH-35"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-40",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-40.md"
    },
    {
      "id": "SCH-41",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "The lane vocabulary is the served payload, not the shipped set",
      "summary": "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.",
      "normative": "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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-10",
        "SCH-10.a",
        "SCH-36",
        "SCH-42"
      ],
      "backlinks": [
        "SCH-10",
        "SCH-10.a",
        "SCH-36",
        "SCH-42",
        "SCH-44"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-41",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-41.md"
    },
    {
      "id": "SCH-42",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A shape is a named JSON Schema, and the shape id is the lane id",
      "summary": "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.",
      "normative": "A port shape is a named JSON Schema whose id is a lane id: one id space, no second vocabulary axis. A shape is declared in one of two places, and the difference is whose it is. A 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`. 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. Every shipped lane that is not a primitive has a shape. A shape names a value; it does not check one. Nothing validates a value against a shape.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-41",
        "SCH-43",
        "SCH-44",
        "SCH-46"
      ],
      "backlinks": [
        "SCH-41",
        "SCH-43",
        "SCH-44",
        "SCH-46"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-42",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-42.md"
    },
    {
      "id": "SCH-43",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Shape compatibility is nominal and mostly derived",
      "summary": "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.",
      "normative": "A shape is compatible with another because they share an id, never because two schemas were compared. 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. 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. Any further widening is a hand-written rule that states its reason where it is declared. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-39",
        "SCH-42"
      ],
      "backlinks": [
        "SCH-39",
        "SCH-42"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-43",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-43.md"
    },
    {
      "id": "SCH-44",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A shape refines another declaration of its lane; four ids are reserved",
      "normative": "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. The winner replaces the entry whole rather than key by key, because a partial shape is a deliberate redefinition. 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. Precedence is resolved in one place, so which declaration wins never depends on load order. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-10.a",
        "SCH-41",
        "SCH-42"
      ],
      "backlinks": [
        "SCH-10.a",
        "SCH-42",
        "SCH-45"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-44",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-44.md"
    },
    {
      "id": "SCH-45",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A stored lane overlay is checked when it is stored",
      "summary": "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.",
      "normative": "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. 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). 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-10.a",
        "SCH-44",
        "SCH-46"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-45",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-45.md"
    },
    {
      "id": "SCH-46",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A shape's schema is served on the lane, not on every port",
      "summary": "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.",
      "normative": "A shape's JSON Schema is served on the lane entry, once. 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\". 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. 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. Such an observed schema is authoring information only: it is a sample, not a contract, and must never become what enforcement checks. A stored overlay entry for the same lane id replaces the composed entry whole, its schema included.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-10.a",
        "SCH-42"
      ],
      "backlinks": [
        "SCH-10.a",
        "SCH-42",
        "SCH-45"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-46",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-46.md"
    },
    {
      "id": "SCH-5",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Reserved config-only parameters always carry a default",
      "normative": "`dynamicInputs`, `dynamicOutputs` and `branches` are reserved config-only parameters and are never input ports. 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. Such an entry is never omitted and never null, unlike an ordinary configurable parameter, whose entry is omitted when its resolved default is null.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-4"
      ],
      "backlinks": [
        "SCH-4"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-5.md"
    },
    {
      "id": "SCH-6",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "Which required list a required parameter lands in",
      "normative": "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`.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-2",
        "SCH-3"
      ],
      "backlinks": [
        "SCH-2"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-6.md"
    },
    {
      "id": "SCH-7",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "An output is exposed unless the node type says otherwise",
      "normative": "A declared output survives into a node's metadata unless the node type marks that output not exposed. Where the node type says nothing about it, the output is exposed: the default is fail-open.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "SCH-18"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-7.md"
    },
    {
      "id": "SCH-8",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "How a schema property becomes a port",
      "summary": "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.",
      "normative": "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. This is the only schema-to-port mapping. The unified input and output ports are not schema-derived: they always carry the `json` lane and display order 0.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-10",
        "SCH-16",
        "SCH-17",
        "SCH-18",
        "SCH-27"
      ],
      "backlinks": [
        "SCH-17",
        "SCH-18",
        "SCH-27"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-8.md"
    },
    {
      "id": "SCH-9",
      "family": "GR-SCHEMA",
      "part": "I",
      "title": "A union type resolves to its first non-null member",
      "normative": "Where a property's type is an array of types, its lane is derived from the first member that is not `null`. An array that is empty, or that holds only `null`, resolves to the sink. Every member must itself be a JSON Schema type.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SCH-34",
        "SCH-35"
      ],
      "backlinks": [
        "SCH-34",
        "SCH-35"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-schema/sch-9",
      "markdown": "https://flowdrop.io/spec/rules/gr-schema/sch-9.md"
    },
    {
      "id": "SG-1",
      "family": "RT-SG",
      "part": "II",
      "title": "State merges field by field, and a state value is never mutated",
      "normative": "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. A field the update omits keeps its current value. The merge yields a new state; the state it was applied to is not modified.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-3"
      ],
      "backlinks": [
        "SG-2",
        "SG-3"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-1.md"
    },
    {
      "id": "SG-10",
      "family": "RT-SG",
      "part": "II",
      "title": "A loopback driven from the trigger port belongs to no branch",
      "normative": "Where a loopback edge's source is the reserved `trigger` port, the re-entry it drives is attributed to no branch.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-7"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-10",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-10.md"
    },
    {
      "id": "SG-11",
      "family": "RT-SG",
      "part": "II",
      "title": "Every node completion writes a checkpoint, and the run writes a last one",
      "normative": "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. 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. A run that asks for a checkpoint store the implementation does not know is refused.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-12"
      ],
      "backlinks": [
        "SG-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-11",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-11.md"
    },
    {
      "id": "SG-12",
      "family": "RT-SG",
      "part": "II",
      "title": "Where a run's starting state comes from",
      "normative": "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. 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. Seeding from a run that is paused rather than ended restores it untouched, which is what keeps a pause taken mid-loop resumable.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-11",
        "SG-13"
      ],
      "backlinks": [
        "INT-13",
        "SG-11",
        "SG-13"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-12",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-12.md"
    },
    {
      "id": "SG-13",
      "family": "RT-SG",
      "part": "II",
      "title": "A run records the configuration it resolved and resumes on it",
      "normative": "A run records the execution configuration it resolved, including the thread it runs on and the checkpoint store it uses. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-12",
        "SG-17"
      ],
      "backlinks": [
        "SG-12"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-13",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-13.md"
    },
    {
      "id": "SG-14",
      "family": "RT-SG",
      "part": "II",
      "title": "Exceeding the iteration budget ends the run, it does not pause it",
      "normative": "A state update that would push the run's iteration count strictly past the configured maximum ends the run. The budget's own last iteration is allowed; only exceeding it ends the run. 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. This is a terminal verdict and not a pause: no resume handle is offered, and the status is never `paused`.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-7"
      ],
      "backlinks": [
        "ORC-10",
        "SG-7"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-14",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-14.md"
    },
    {
      "id": "SG-15",
      "family": "RT-SG",
      "part": "II",
      "title": "A run reports the identifier of the run it created",
      "normative": "The execution identifier a run reports is the identifier of the run record the engine created, and it replaces any identifier the caller supplied. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-15",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-15.md"
    },
    {
      "id": "SG-16",
      "family": "RT-SG",
      "part": "II",
      "title": "What a loop is (its body, and that it is keyed by its head)",
      "summary": "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.",
      "normative": "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. 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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-17",
        "SG-18"
      ],
      "backlinks": [
        "ORC-10",
        "SG-7",
        "SG-17",
        "SG-18"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-16",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-16.md"
    },
    {
      "id": "SG-17",
      "family": "RT-SG",
      "part": "II",
      "title": "A loop's extent is fixed at launch and every execution carries its rounds",
      "normative": "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. 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. 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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-16",
        "SG-18",
        "SG-19",
        "SG-20"
      ],
      "backlinks": [
        "SG-7",
        "SG-13",
        "SG-16",
        "SG-18",
        "SG-19",
        "SG-20"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-17",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-17.md"
    },
    {
      "id": "SG-18",
      "family": "RT-SG",
      "part": "II",
      "title": "Nested loops keep separate rounds, tangled ones are merged",
      "normative": "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. 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. 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. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-16",
        "SG-17",
        "SG-19"
      ],
      "backlinks": [
        "SG-16",
        "SG-17",
        "SG-19"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-18",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-18.md"
    },
    {
      "id": "SG-19",
      "family": "RT-SG",
      "part": "II",
      "title": "A source from an earlier round does not satisfy an edge",
      "summary": "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.",
      "normative": "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. 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. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-17",
        "SG-18",
        "SG-20",
        "DATA-4"
      ],
      "backlinks": [
        "ERR-10",
        "BR-6",
        "DATA-4",
        "SG-17",
        "SG-18",
        "SG-20"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-19",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-19.md"
    },
    {
      "id": "SG-2",
      "family": "RT-SG",
      "part": "II",
      "title": "A node sees run state only where it asks for it, and none of it is persisted",
      "normative": "Three separate guarantees. 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. Separately, the live state is handed only to a node type that declares itself state-aware, and never to one that does not. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-1"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-2.md"
    },
    {
      "id": "SG-20",
      "family": "RT-SG",
      "part": "II",
      "title": "A data port no live producer can still fill fails its consumer",
      "summary": "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.",
      "normative": "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. 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. 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. Four restrictions bound the verdict. It applies to data edges only, and only on 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 keeps the port fillable. 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. And the run must carry the reachability sets the test needs. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-17",
        "SG-19",
        "DATA-4"
      ],
      "backlinks": [
        "BR-6",
        "DATA-4",
        "SG-17",
        "SG-19"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-20",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-20.md"
    },
    {
      "id": "SG-3",
      "family": "RT-SG",
      "part": "II",
      "title": "A node's state update is applied through the reducers once it completes",
      "normative": "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. It is a control output and is never removed by output exposure, whatever the author exposed.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-1",
        "DATA-8"
      ],
      "backlinks": [
        "SG-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-3.md"
    },
    {
      "id": "SG-4",
      "family": "RT-SG",
      "part": "II",
      "title": "A ForEach node initializes its iterator once and completes on an empty list",
      "normative": "A ForEach node initializes its iterator on the round where the state carries none, and requires items to iterate. A value that is not a list is wrapped as a single item, and the items are re-indexed so that iteration is positional. An empty list completes the node immediately rather than entering the loop. On completion the node emits its defined output set, including the results collected across the rounds it ran.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-5",
        "SG-7"
      ],
      "backlinks": [
        "SG-5"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-4.md"
    },
    {
      "id": "SG-5",
      "family": "RT-SG",
      "part": "II",
      "title": "loop_back is a value on a ForEach node and a bare signal everywhere else",
      "normative": "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. 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. A node type that wants the value declares its own port for it, which is exactly what suppresses the reserved injection.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-4"
      ],
      "backlinks": [
        "SG-4"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-5.md"
    },
    {
      "id": "SG-6",
      "family": "RT-SG",
      "part": "II",
      "title": "Routing is decided per edge and dispatched per target",
      "normative": "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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-9"
      ],
      "backlinks": [
        "SG-9"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-6.md"
    },
    {
      "id": "SG-7",
      "family": "RT-SG",
      "part": "II",
      "title": "A loop's budget counts rounds of the loop, not executions of a node",
      "normative": "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. That bound is per loop, keyed by the loop's head, and counts rounds rather than executions of any one node in the body. 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. The round counters are restored when a paused run resumes, so the bound holds across a pause.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-16",
        "SG-17",
        "SG-14"
      ],
      "backlinks": [
        "ORC-10",
        "SG-4",
        "SG-10",
        "SG-14"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-7.md"
    },
    {
      "id": "SG-8",
      "family": "RT-SG",
      "part": "II",
      "title": "A node executed more than once keys its results by occurrence",
      "normative": "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. Partial results reported for an interrupted run use an equivalent scheme. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "DATA-2"
      ],
      "backlinks": [
        "DATA-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-8.md"
    },
    {
      "id": "SG-9",
      "family": "RT-SG",
      "part": "II",
      "title": "An edge condition never decides routing",
      "normative": "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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SG-6"
      ],
      "backlinks": [
        "SG-6"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-sg/sg-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-sg/sg-9.md"
    },
    {
      "id": "SNAP-1",
      "family": "RT-SNAP",
      "part": "II",
      "title": "The snapshot API carries two casings, and acceptance is the lenient half",
      "summary": "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.",
      "normative": "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. The snapshot document published under `data.snapshot` is the snapshot's own camelCase shape: `workflowId`, `executionId`, `nodeStates`, `initialInput`, `iterationCount`, `threadId`, `createdAt`, `updatedAt`. The save door accepts either spelling in its body and normalises; a read always answers camelCase. `node_count` is the number of node states in the snapshot, not a count taken from the raw stored payload.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SNAP-2"
      ],
      "backlinks": [
        "SNAP-2"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-snap/snap-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-snap/snap-1.md"
    },
    {
      "id": "SNAP-2",
      "family": "RT-SNAP",
      "part": "II",
      "title": "Snapshot access is decided on the snapshot, and absence answered first",
      "normative": "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. A read that finds the record but no readable payload answers `404` as well, never a partial document. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "runtime",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "SNAP-1",
        "PIPE-6"
      ],
      "backlinks": [
        "PIPE-6",
        "SNAP-1"
      ],
      "url": "https://flowdrop.io/spec/rules/rt-snap/snap-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-snap/snap-2.md"
    },
    {
      "id": "ST-1",
      "family": "RT-ST",
      "part": "II",
      "title": "The status surface answered inside a success envelope",
      "normative": "Retired with the polled status surface. It answered with a `{success, data}` envelope on success and `{success: false, error}` on failure, alongside the HTTP status code.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-1.md"
    },
    {
      "id": "ST-2",
      "family": "RT-ST",
      "part": "II",
      "title": "An execution was reported by id and status",
      "normative": "Retired with the polled status surface. 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.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-2.md"
    },
    {
      "id": "ST-3",
      "family": "RT-ST",
      "part": "II",
      "title": "Node statuses passed through, and an untracked run was empty rather than absent",
      "normative": "Retired with the polled status surface. 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.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-3.md"
    },
    {
      "id": "ST-4",
      "family": "RT-ST",
      "part": "II",
      "title": "The detail view carried derived run metrics",
      "normative": "Retired with the polled status surface. 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.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-4",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-4.md"
    },
    {
      "id": "ST-5",
      "family": "RT-ST",
      "part": "II",
      "title": "An execution was reported with a fixed field set",
      "normative": "Retired with the polled status surface. 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.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-5",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-5.md"
    },
    {
      "id": "ST-6",
      "family": "RT-ST",
      "part": "II",
      "title": "A node was reported with a fixed field set",
      "normative": "Retired with the polled status surface. A node was reported with the fields status, node_id, node_type, start_time, end_time, execution_time, error and output.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-6",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-6.md"
    },
    {
      "id": "ST-7",
      "family": "RT-ST",
      "part": "II",
      "title": "The status vocabulary was closed",
      "normative": "Retired with the polled status surface. Its status vocabulary was initialized, idle, running, completed, failed and interrupted. The vocabulary itself outlived the surface as the status set carried on runtime status broadcasts, there joined by skipped.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-7",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-7.md"
    },
    {
      "id": "ST-8",
      "family": "RT-ST",
      "part": "II",
      "title": "An update naming an unknown run registered it rather than dropping it",
      "normative": "Retired with the polled status surface. 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.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-8",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-8.md"
    },
    {
      "id": "ST-9",
      "family": "RT-ST",
      "part": "II",
      "title": "Each transition carried a payload defined for it",
      "normative": "Retired with the polled status surface. 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. These payloads outlived the surface, still riding the runtime status broadcasts.",
      "posture": "deprecated",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-st/st-9",
      "markdown": "https://flowdrop.io/spec/rules/rt-st/st-9.md"
    },
    {
      "id": "STORE-1",
      "family": "GR-STORE",
      "part": "I",
      "title": "A request body is bounded before anything is parsed",
      "summary": "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.",
      "normative": "A request body must be JSON whose top level is an object or an array. 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. An implementation must accept a document nested 63 levels deep.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "API-1"
      ],
      "backlinks": [
        "API-1"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-1",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-1.md"
    },
    {
      "id": "STORE-10",
      "family": "GR-STORE",
      "part": "I",
      "title": "Read-path repairs must never run on the save path",
      "summary": "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.",
      "normative": "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. None of that may happen on the save, validate or mutate path, which must see the workflow exactly as submitted or as stored.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-5"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-10",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-10.md"
    },
    {
      "id": "STORE-11",
      "family": "GR-STORE",
      "part": "I",
      "title": "Status vocabularies are closed sets of strings",
      "normative": "Job, pipeline, session and message status are each a closed vocabulary. A status is a string, and one spelling serves everywhere: what is persisted is what every JSON payload carries and what every event carries. An implementation must not persist one spelling and publish another.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-12"
      ],
      "backlinks": [
        "STORE-12",
        "STORE-13",
        "INT-22"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-11",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-11.md"
    },
    {
      "id": "STORE-12",
      "family": "GR-STORE",
      "part": "I",
      "title": "A finished turn leaves the session completed, not idle",
      "normative": "`idle` means a session was created and has never executed. A session whose turn has finished is released as `completed`. The two are distinct states, and an implementation must not use `idle` to mean that a turn has finished.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-11"
      ],
      "backlinks": [
        "STORE-11"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-12",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-12.md"
    },
    {
      "id": "STORE-13",
      "family": "GR-STORE",
      "part": "I",
      "title": "One name per concept for a node's type",
      "summary": "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.",
      "normative": "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. A node's visual type is `visual_type` in a per-node snapshot payload. The bare key `node_type` must not be written anywhere. A node's own identifier is `node_id` and is unaffected.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-7",
        "STORE-11"
      ],
      "backlinks": [
        "STORE-7"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-13",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-13.md"
    },
    {
      "id": "STORE-14",
      "family": "GR-STORE",
      "part": "I",
      "title": "Every surface that returns a workflow returns one object",
      "summary": "List, create, read and update all publish the same workflow object. A client learns one shape, not four.",
      "normative": "Every surface that returns a workflow publishes the same keys in the same order: `id`, `name`, `description`, `nodes`, `edges`, `metadata`, `created`, `changed`, `uid`. `nodes` is enriched with each node's node-type metadata, and `metadata` is the value as published on read. The spelling is uniformly lower-case, `created`, `changed` and `uid` included. 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. One derivation serves every surface, including any surface that embeds a workflow outside the API.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-4",
        "MAN-20"
      ],
      "backlinks": [
        "STORE-4",
        "STORE-6",
        "STORE-15"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-14",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-14.md"
    },
    {
      "id": "STORE-15",
      "family": "GR-STORE",
      "part": "I",
      "title": "Searching the workflow list matches a literal substring of the name",
      "summary": "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.",
      "normative": "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. The term is matched literally and carries no pattern syntax: `%` and `_` match themselves and nothing else. The same filter applies to the count, so the reported total describes the filtered set rather than the collection (API-6). A term that matches nothing answers an empty page with a coherent pagination block, not a refusal and not a not-found. A term of `0` is an ordinary search term. A search parameter that is not a single scalar value is refused with `400`.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-19"
      ],
      "related": [
        "API-6",
        "STORE-14"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-15",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-15.md"
    },
    {
      "id": "STORE-2",
      "family": "GR-STORE",
      "part": "I",
      "title": "A workflow must be named",
      "summary": "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.",
      "normative": "A workflow's `name` is required when it is created and when it is updated, and must be a non-empty string. An implementation must accept a name of at least 255 Unicode code points, and may accept longer. A request that omits the name, sends an empty one, or exceeds the implementation's limit is refused with 400, and nothing is stored.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-19"
      ],
      "backlinks": [
        "STORE-4"
      ],
      "references": {
        "normative": [
          {
            "source": "The Unicode Standard",
            "title": "Definition of code point",
            "url": "https://www.unicode.org/versions/latest/",
            "note": "The unit this rule counts."
          }
        ],
        "informative": [
          {
            "source": "Unicode UAX",
            "title": "Unicode Text Segmentation",
            "url": "https://www.unicode.org/reports/tr29/",
            "note": "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."
          }
        ]
      },
      "url": "https://flowdrop.io/spec/rules/gr-store/store-2",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-2.md"
    },
    {
      "id": "STORE-3",
      "family": "GR-STORE",
      "part": "I",
      "title": "A client-supplied id never overwrites an existing workflow",
      "normative": "A create request may supply the workflow's `id`. If a workflow with that id already exists, the request is refused with 409 and the stored workflow is left exactly as it was. An implementation must never silently turn a create into an update.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [
        "STORE-9"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-3",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-3.md"
    },
    {
      "id": "STORE-4",
      "family": "GR-STORE",
      "part": "I",
      "title": "Absent collections default to empty, and an update touches only what it sends",
      "summary": "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.",
      "normative": "On create, `nodes`, `edges` and `metadata` default to the empty list when absent, and a workflow that declares no `interface` declares no ports. On update, a key the request omits is left as stored; `name` is the exception, required on update as on create. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-2",
        "STORE-14"
      ],
      "backlinks": [
        "STORE-14"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-4",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-4.md"
    },
    {
      "id": "STORE-5",
      "family": "GR-STORE",
      "part": "I",
      "title": "A workflow refused by validation is never partially stored",
      "normative": "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. Only errors refuse and only errors are reported: a workflow carrying warnings alone is stored, and its warnings appear nowhere in the response. Create and update refuse identically, and a refused update leaves the stored workflow untouched.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-9",
        "API-8"
      ],
      "backlinks": [
        "STORE-9",
        "STORE-10",
        "API-8"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-5",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-5.md"
    },
    {
      "id": "STORE-6",
      "family": "GR-STORE",
      "part": "I",
      "title": "Storing a workflow drops the editor's scratch state",
      "summary": "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.",
      "normative": "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`. 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). A node's `measured` and `position`, and its `data.config`, `data.label` and `data.extensions`, survive the write unchanged. 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. Discarding is idempotent, so storing an already-stored workflow drops nothing further.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-14"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-6",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-6.md"
    },
    {
      "id": "STORE-7",
      "family": "GR-STORE",
      "part": "I",
      "title": "Stored node metadata carries the type anchor and nothing else",
      "normative": "Storage constrains a node's `data.metadata` to a single key, `node_type_id`. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "STORE-13"
      ],
      "backlinks": [
        "STORE-13"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-7",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-7.md"
    },
    {
      "id": "STORE-8",
      "family": "GR-STORE",
      "part": "I",
      "title": "A published contract version is three numbers",
      "summary": "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.",
      "normative": "A workflow's published contract version is three dot-separated non-negative integers. 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). 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).",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "MAN-18",
        "META-4",
        "META-6"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-8",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-8.md"
    },
    {
      "id": "STORE-9",
      "family": "GR-STORE",
      "part": "I",
      "title": "Import gates run in a fixed order and roll back what they generated",
      "summary": "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.",
      "normative": "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. Any refusal after node-type generation rolls the generated node types back, so a refused import leaves nothing behind. An unsupported envelope format is refused with 422. An untrusted publisher is refused with 403, unless the caller both confirms the publisher and is permitted to do so. 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. A workflow that fails validation is refused with 422 and `details`. An id that already exists is refused with 409, never overwriting. 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. 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`. 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. What is stored is the entry as submitted, so an author's additional entry metadata survives the import.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api"
      ],
      "added": "1.0",
      "changed": "1.0",
      "rulings": [
        "OPEN-19"
      ],
      "related": [
        "STORE-3",
        "STORE-5"
      ],
      "backlinks": [
        "STORE-5"
      ],
      "url": "https://flowdrop.io/spec/rules/gr-store/store-9",
      "markdown": "https://flowdrop.io/spec/rules/gr-store/store-9.md"
    },
    {
      "id": "TRIG-1",
      "family": "RT-TRIG",
      "part": "II",
      "title": "Overlap is judged against the workflow's own unfinished runs",
      "summary": "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.",
      "normative": "Overlap is decided against runs of the same workflow that have not reached a terminal state: pending, running or paused. 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. 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. The buffer holds at most one deferred firing per trigger: a second firing while one is buffered is dropped, never stacked and never overwritten. 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. A policy an implementation does not recognise must be treated as Skip, the only fail-safe direction, since every alternative destroys a running workflow.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-trig/trig-1",
      "markdown": "https://flowdrop.io/spec/rules/rt-trig/trig-1.md"
    },
    {
      "id": "TRIG-2",
      "family": "RT-TRIG",
      "part": "II",
      "title": "Jitter is rolled once and never re-rolled",
      "summary": "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.",
      "normative": "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. Otherwise the bounds are clamped before use, so a negative minimum becomes zero and a minimum above the maximum is used as both bounds. 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. 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. The record is scoped to a single trigger, so one trigger's window never holds another back.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-trig/trig-2",
      "markdown": "https://flowdrop.io/spec/rules/rt-trig/trig-2.md"
    },
    {
      "id": "TRIG-3",
      "family": "RT-TRIG",
      "part": "II",
      "title": "A skip is recorded, but it is not an execution",
      "summary": "\"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.",
      "normative": "A trigger keeps, per trigger, the time it last ran, the number of times it has run, and a history. Recording an execution advances all three. Recording a skip appends a history entry carrying the reason for the skip and leaves the last-run time and the run count untouched. 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. 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.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/rt-trig/trig-3",
      "markdown": "https://flowdrop.io/spec/rules/rt-trig/trig-3.md"
    },
    {
      "id": "VAL-LAUNCH",
      "family": "GR-VAL",
      "part": "I",
      "title": "The validator runs again at launch, before anything is created",
      "summary": "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.",
      "normative": "Launching a workflow re-runs every validation rule against the stored definition. 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. 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.",
      "posture": "normative-target",
      "level": "core",
      "profiles": [
        "storage-api",
        "runtime"
      ],
      "added": "1.0",
      "changed": "1.0",
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/val-launch",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/val-launch.md"
    },
    {
      "id": "W-T",
      "family": "GR-VAL",
      "part": "I",
      "title": "An edge leaving a terminal node warns",
      "summary": "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.",
      "normative": "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. A terminal node with several outgoing edges therefore produces one warning per edge, and the workflow stays valid. 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. An edge whose source is itself dangling produces no warning; R8 owns that.",
      "posture": "normative-target",
      "level": "extended",
      "profiles": [
        "storage-api",
        "editor-client"
      ],
      "added": "1.0",
      "changed": "1.0",
      "related": [
        "R8.a",
        "R8.b"
      ],
      "backlinks": [],
      "url": "https://flowdrop.io/spec/rules/gr-val/w-t",
      "markdown": "https://flowdrop.io/spec/rules/gr-val/w-t.md"
    }
  ],
  "reserved": [
    {
      "id": "LANG-5",
      "family": "GR-LANG",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "MAN-4",
      "family": "GR-MAN",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "VAL-PERF",
      "family": "GR-VAL",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "ORC-6",
      "family": "RT-ORC",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "META-2",
      "family": "RT-META",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "ST-10",
      "family": "RT-ST",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "EXPO-9",
      "family": "GR-EXPO",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "EXPO-18",
      "family": "GR-EXPO",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "EXPO-19",
      "family": "GR-EXPO",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "INT-21",
      "family": "RT-INT",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "RT-GATE-10",
      "family": "RT-GATE",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "MIG-1",
      "family": "RT-MIG",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "MIG-2",
      "family": "RT-MIG",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "MIG-3",
      "family": "RT-MIG",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "MIG-4",
      "family": "RT-MIG",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "MIG-5",
      "family": "RT-MIG",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "MIG-6",
      "family": "RT-MIG",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "MIG-7",
      "family": "RT-MIG",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "UPD-1",
      "family": "RT-UPD",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "UPD-2",
      "family": "RT-UPD",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "UPD-3",
      "family": "RT-UPD",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    },
    {
      "id": "UPD-4",
      "family": "RT-UPD",
      "note": "declined by this specification; in use in an implementation registry. Never issued here."
    }
  ]
}