--- id: INT-16 family: RT-INT level: core profiles: [runtime] posture: normative-target added: "1.0" changed: "1.0" source: https://flowdrop.io/spec/rules/rt-int/int-16 specification: FlowDrop Workflow Specification 1.0-draft licence: CC BY 4.0 --- # INT-16 — A refused signal says why in a code a client can act on *RT-INT (Part II) · level: core · profiles: runtime · added in 1.0* Three different refusals share one status code, and a client has to tell them apart to say anything useful to an operator. The discriminator is a stable code, not the wording of a message. ## The rule > **Normative.** This is the rule. > > 1. The signal API refuses in three distinguishable ways, all `409`, each carrying a stable machine-readable code beside its human-readable message: the run is in a terminal state and cannot be signalled or resumed (`PIPELINE_TERMINAL`); an inward signal is already pending for the run (`INWARD_SIGNAL_ALREADY_PENDING`); and there is no active pause to resume (`NO_ACTIVE_PAUSE`). > > 2. A client classifies a refusal by that code; the message wording is not a contract and an implementation may change it. > > 3. A refusal answers with the error envelope (API-1). > > 4. A newly created cancel or pause signal answers `202`; a resume that resolved a pending pause answers `200`. > > 5. A signal addressed to a run that does not exist answers `404` to a caller holding blanket authority over runs (existence is no secret from someone who may act on any run), and an opaque `403` to every other caller, indistinguishable from the answer for a run that exists but is not theirs, so that the route cannot be used to enumerate which runs exist. ## What it means Three different refusals on the signal API share the one status, `409`, so the status alone cannot tell an operator which of the three happened; the `error_code` is what does. A code, once published, keeps its meaning forever — API-8 covers that guarantee for two of the three codes here; the third, `PIPELINE_TERMINAL`, is the same contract applied to a run that has already finished. Success is not one code either: creating a new signal and resolving one that already existed are different events on the same door, and they answer differently so a caller can tell which happened without inspecting the body. The existence check on a missing run is scoped to who is asking. A caller who may already act on any run loses nothing by being told a run does not exist, so they get an honest `404`. Everyone else gets the same opaque `403` whether the run does not exist or simply is not theirs — the two cases must be indistinguishable, or the difference between them becomes a way to enumerate which runs exist. ## Example ```http title="Cancelling a run that has already finished" verdict="409 PIPELINE_TERMINAL" POST /flowdrop/api/pipelines/{pipeline}/cancel {} ``` ```http title="Pausing a run for the first time" verdict="202 accepted" POST /flowdrop/api/pipelines/{pipeline}/pause {} ``` ```http title="Resuming that same pause" verdict="200 resolved" POST /flowdrop/api/pipelines/{pipeline}/resume {} ``` A caller with blanket authority over runs who cancels one that does not exist gets `404`; every other caller gets `403` for that same request, and `403` again for a run that exists but is someone else's — the two answers are identical on purpose. ## Why Recorded under OPEN-18. ## Related rules - Names: API-1, API-8, INT-5 - Referenced by: INT-17 --- Rule identifiers are permanent and are never renumbered. This specification carries no implementation status: each implementation publishes its own standing against these rules. Licensed CC BY 4.0.