# Workflows — Stage-to-Signal Mapping Spec (Phase 0)

> Companion to [`WORKFLOWS_FEATURE_DESIGN.md`](WORKFLOWS_FEATURE_DESIGN.md) v4.1. This document is the load-bearing contract that converts a `Stage` (closed-vocabulary domain model from `kestrel_sovereign/features/workflows/models.py`) into a `Signal` the dispatcher fires (existing platform — see [`SIGNAL_DISPATCHER.md`](SIGNAL_DISPATCHER.md), [`SIGNAL_SOURCES_GUIDE.md`](SIGNAL_SOURCES_GUIDE.md)). Phase 1's `WorkflowRunner` consumes this mapping; agents authoring workflows reference it.

## 1. Why this document exists

The Workflows feature explicitly does NOT define its own runtime; it composes onto the existing `SignalDispatcher`. That means:

- Every stage execution = one `dispatch_signal(...)`/`enqueue_signal(...)` call against a registered `SourceRegistration`.
- Every gate evaluation runs over the `SignalResult` the dispatcher returns.
- Every compensation = another `dispatch_signal(...)` against a different registered source.
- Emergency abort cancels the supervised dispatch task for the in-flight stage; graceful cancel never does.

The mapping is small, but the wrong default in any column would either silently bypass the dispatcher's safety rails or block legitimate flows. Phase 0 nails it down before Phase 1 builds the runner.

## 2. Stage → Signal envelope

A `Stage` (see `kestrel_sovereign/features/workflows/models.py`) carries:

| Stage field           | Signal envelope field            | Notes |
|-----------------------|----------------------------------|-------|
| `name`                | (workflow-only — not on Signal)  | The runner records it on the `workflow_stage_links` row, not on the Signal envelope. Auditors join via `signal_id`. |
| `signal_source`       | `Signal.source`                  | Must be a name registered in the dispatcher's `SourceRegistry`. The runner refuses to start a run if any stage's `signal_source` is unregistered (Phase 1 boundary check). |
| `signal_mode`         | `Signal.mode`                    | Must be in the source's `allowed_modes`. The dispatcher already enforces this at register-time; the runner re-checks at run-start as a defense-in-depth gate. |
| `params`              | `Signal.payload` (verbatim)      | Validated against the source's `schema` field by the dispatcher's existing schema validator. The Workflows registrar does NOT pre-validate; the dispatcher's existing path is authoritative. |
| `gate.type`           | (workflow-only — see §3)         | Evaluated AFTER the dispatcher returns `SignalResult`. |
| `gate.params`         | (workflow-only — see §3)         | Some gate params (notably `red_team_clear`) influence the COGNITION dispatch *before* it runs (e.g. reviewer pool selection); see §3.4 in this doc and design §3.4. |
| `compensate`          | (workflow-only — see §4)         | Either a sentinel (`noop_idempotent`, `compensate_record_only`) or the name of a registered `SourceRegistration` that the runner dispatches a separate signal against during cancellation. |
| `forbidden_modules`   | (workflow-only — see §5)         | Used by `constitutional_boundary_clean` gate; not relevant to the Signal envelope itself. |
| `irreversible`        | (workflow-only)                  | Affects compensation routing only. |
| `non_deterministic`   | (workflow-only)                  | Salts the idempotency key per attempt so legitimate retries don't collapse. |
| `read_only`           | (workflow-only)                  | Eligibility for `noop_idempotent` compensation. |

`Stage.params.timeout_seconds` is reserved by the runner as a per-stage dispatch ceiling. If absent, the runner uses the per-mode defaults from the design doc; on timeout it cancels the supervised dispatch task, records a failed gate reason `stage_timeout:<mode>:<seconds>`, and then follows the normal compensation path.

### Fields the runner SETS on every Signal

- `Signal.target_agent` = the agent the workflow run is bound to (`WorkflowRun.started_by_did`).
- `Signal.causation_chain` = the workflow-run frame plus any inherited frames. The dispatcher's existing `CausationFrame` infrastructure handles TTL=5 cycle detection; Workflows adds a `(workflow_run_id, stage_name, attempt_number)` frame so per-stage events trace back through cycles.
- `Signal.session_id` = `WorkflowRun.run_id` (so all signals from one run group on the same conversation/log axis).
- `Signal.visibility` = `Visibility.INTERNAL` by default. Stages that produce user-visible output set this on their stage definition (Phase 1 adds the field; Phase 0 omits it).
- `Signal.urgency` = `Urgency.NORMAL` (workflows are NOT real-time; the urgency vocabulary is for inbound signals like webhooks and heartbeats).
- `Signal.dedupe_key` = derived from the stage's `idempotency_key` (per design §3.5: `sha256(run_id || stage_name || sha256(canonical_input || attempt_number || engine_nonce))`). The dispatcher's existing `dedupe_key` semantics apply.
- `Signal.id` = generated by the dispatcher at dispatch time. The runner records it on the `workflow_stage_links` row in the `signal_id` column.

### What the runner MUST NOT set

- `trust` — set on the source registration, not per-call. The dispatcher uses the registration's `trust` value.
- `rate_limit` / `coalescing_window` / `attention_policy` — same: registration-level, not per-call.
- Per-signal `prompt_template` — the dispatcher renders only `registration.prompt_template`. Per-signal overrides require a SignalDispatcher contract change tracked in [#1146](https://github.com/KestrelSovereignAI/kestrel-sovereign/issues/1146). If a workflow needs a different prompt for a COGNITION stage, the operator registers a NEW `SourceRegistration` with that prompt and the stage's `signal_source` points to it. (See design §3.1.)

## 3. Gate → SignalMode + params shape

Gates run AFTER the dispatched signal completes; they evaluate over `SignalResult`. The `gate.type` does NOT directly determine the signal mode of the stage it's attached to — that's the `Stage.signal_mode` field. But a gate that itself dispatches signals (the only one in Phase 0–2 is `red_team_clear`, and to a lesser extent `tests_pass`/`ci_green`/`lint_clean` if implemented as ACTION sources) does carry its own mapping.

The table below codifies the per-gate expectations.

| Gate type                         | Stage's `signal_mode` (typical) | What the gate does |
|-----------------------------------|----------------------------------|--------------------|
| `signal_status_ok`                | any                              | Pure predicate over `SignalResult.status`. Pass iff `status == OK`. No additional dispatch. |
| `tests_pass`                      | `ACTION`                         | The stage's signal IS the test runner; gate inspects `SignalResult.action_result` for an exit-0 marker. |
| `ci_green`                        | `ACTION`                         | Same: the stage's signal triggers CI; gate polls GH Actions for required-checks pass. The poll itself is the gate's job, not a separate dispatch. |
| `lint_clean`                      | `ACTION`                         | Same: stage runs lint; gate inspects exit code + scope coverage. |
| `red_team_clear`                  | `ACTION` or `COGNITION`          | Gate dispatches N reviewer signals (each a `COGNITION` against a registered reviewer source) and aggregates blockers. See §3.4. |
| `council_approve`                 | `ACTION`                         | Stage's ACTION enqueues an approval; gate watches the council source's `signal_log` for N-of-M approvals. |
| `consent_collect`                 | `ACTION`                         | Stage routes through the existing `HooksManager` paused-turn path. Gate completes when the hook resolves. NOT a Signal — the only gate that's not Signal-shaped (per design §3.2 v3 → v4 reframing). |
| `signature_collected`             | `ACTION`                         | Gate watches a registered signing source for the named DID's signature. |
| `script(...)`                     | `ACTION` or `COGNITION`          | Gate dispatches a `compute.*` ACTION signal (existing `features/compute/` sandbox) with the content-addressed predicate. The script's stdout determines pass/fail. |
| `constitution_echo_verified`      | `COGNITION` only                 | Defense-in-depth wrapper over #1137's dispatcher-level injection. Gate inspects `SignalResult` for the canary echo (codex/local: `constitution_canary` field; claude_code: phantom-tool receipt — Phase 2 of #1137). |
| `constitutional_boundary_clean`   | `ACTION` (typical)               | Gate runs a static-import scan over `SignalResult.action_result`'s emitted code (or `SignalResult.artifact` for `ARTIFACT` stages that emit code). No additional dispatch. |

### 3.1 `red_team_clear` reviewer dispatch shape

When the gate fires it dispatches one signal per reviewer in the pool. Each reviewer signal is:

- `Signal.source = "review.<reviewer_did_or_alias>"` — caller registers each reviewer as its own source (per design §3.2 v3 → v4 reframing: actors are sources, not new primitives).
- `Signal.mode = COGNITION`.
- `Signal.payload = { "pr_diff": <fenced UNTRUSTED>, "canary": <per-invocation>, "rubric": <derived from gate.params> }`.
- `Signal.causation_chain = [<workflow_run_frame>, <gate_frame>, <reviewer_frame>]`. The TTL=5 ceiling caps fanout in pathological cases.

The aggregator runs over the N `SignalResult` returns; any reviewer reporting any blocker fails the gate. The adjudication path (per design §3.4) dispatches a separate `signature_collected` gate-shaped signal to a third DID.

## 4. Compensation → signal dispatch

When a workflow run is cancelled, the runner walks the completed stages in reverse and dispatches the compensation per stage. The mapping:

| `Stage.compensate` value           | Runner action                                                            |
|------------------------------------|---------------------------------------------------------------------------|
| `noop_idempotent`                  | No dispatch. The runner records `compensate_state="not_required"` on the stage_link only after the ACTION handler passes the runtime `read_only=True` write-counter check. |
| `compensate_record_only`           | No dispatch. The runner records `compensate_state="record_only"` on the stage_link. The run status becomes `cancelled_with_irreversible_residue` (per design §3.5). |
| _any other value_                  | Treated as a registered source name. The runner constructs a `Signal` with `source=<value>`, `mode=ACTION` (compensations are ALWAYS `ACTION` mode — they MUST have observable side effects), and `payload` includes the original stage's `params` plus a `compensate=true` flag. The dispatched signal's `SignalResult` determines `compensate_state`: `OK` → `complete`, anything else → `failed`. |

**Compensation signal mode is always `ACTION`.** Even if the original stage was `COGNITION`, its compensation is by definition a side-effect-reversing action. Registrar (Phase 1) refuses to register a workflow whose compensation source's `allowed_modes` doesn't include `ACTION`.

**`read_only=True` enforcement:** the Phase 1 runner uses a backend-portable write-counter shim around the registered ACTION handler. A `noop_idempotent` stage that writes any non-`workflow_*` table during that handler window fails its gate with `read_only_violation:<table>` and does not receive `compensate_state="not_required"`. Handler child tasks inherit the audit callback; after the handler returns, late non-`workflow_*` writes are rejected before execution so asynchronous side effects cannot bypass the stage decision. Dispatcher-owned logging happens outside the handler window, so normal `signal_log` audit writes do not make every read-only stage fail.

**Force abort:** `workflow_force_abort(run_id, reason, authority_did, authority_sig)` is not compensation. The runner verifies a sovereign DID signature over the force-abort payload, cancels the in-flight supervised dispatch task (stage or active compensator), records `forced=True` and `gate_reason="force_abort:<reason>"` on the in-flight or pending stage link, clears current stages, and terminalizes the run without dispatching compensators. Completed irreversible / `compensate_record_only` stages are still marked `record_only` so residue appears in run status.

## 5. `forbidden_modules` enforcement

When `Stage.forbidden_modules` is non-empty, the runner runs a static-import scan over `SignalResult.action_result` (for `ACTION`) or `SignalResult.artifact` (for `ARTIFACT`). The scan uses Python's `ast` module, not regex. Any `import` or `from ... import` reaching a name in `forbidden_modules` (substring-prefix-match on the dotted name) → `gate_fail(reason=constitutional_boundary_violation)`.

The list of forbidden module names is part of the canonical signed payload (see `WorkflowSpec.compute_spec_hash`), so an attacker can't loosen the boundary by post-sign mutation.

## 6. Idempotency-key derivation

Per design §3.5: `idempotency_key = sha256(run_id || stage_name || sha256(canonical_stage_input || attempt_number || engine_nonce))`.

- `canonical_stage_input` = JSON dump of `stage.params` after `_thaw_value` (matching the canonical bytes the spec_hash signs over).
- `attempt_number` = 1 on first dispatch; bumped on every retry. `non_deterministic=True` stages bump it on every dispatch (so legitimate non-deterministic retries don't collapse against a stale cache).
- `engine_nonce` = a 16-byte random value the runner generates at run-start and stores on the `workflow_runs` row. Used to prevent adversarial collision: an attacker who knows the workflow definition and its inputs cannot precompute `idempotency_key` for a forged retry.

The dispatcher's `dedupe_key` field (existing) carries this same value. The `workflow_stage_links.idempotency_key` column carries it for the runner's audit trail.

## 7. Signal envelope fields the runner does NOT touch

For completeness, fields that come from the source registration and are NEVER overridden per call:

- `RedactionPolicy` — every source must declare one; the dispatcher rejects un-policy'd registrations.
- `retention_days` — workflow runs use the source's retention for the `signal_log` row; the workflow's own `retention_days` (on `workflow_definitions`) governs the `workflow_runs` and `workflow_stage_links` rows independently.
- `result_summary` — opt-in per-source UI summarization callback; workflows inherit it.

## 8. Phase 1 enforcement

The Phase 1 `WorkflowRunner` enforces this mapping at run-start:

1. Every stage's `signal_source` is in the `SourceRegistry`. Refuse-to-start otherwise.
2. Every stage's `signal_mode` is in the source's `allowed_modes`.
3. Every real (non-sentinel) `compensate` value is also a registered source whose `allowed_modes` includes `ACTION`.
4. The named-gate sources (red-team reviewers, council quorum members, script sandbox) all resolve.

These are pre-execution refusals — the run does not start, and no signals fire, when any check fails. This matches the pattern proven by [#1137](https://github.com/KestrelSovereignAI/kestrel-sovereign/issues/1137)'s constitution-injection epic (refuse-before-side-effect).

## 9. Out of scope for this Phase 0 spec

- Per-signal prompt-template overrides ([#1146](https://github.com/KestrelSovereignAI/kestrel-sovereign/issues/1146)).
- Hybrid (classical + post-quantum) signature shape on workflow definitions and stage transitions — Phase 1 problem; Phase 0's signing helpers cover the legacy ECDSA path.
- Phantom-tool receipt for `claude_code` reviewers (Phase 2 of [#1137](https://github.com/KestrelSovereignAI/kestrel-sovereign/issues/1137)).
- Reflection-cycle migration ([#1145](https://github.com/KestrelSovereignAI/kestrel-sovereign/issues/1145)).

## 10. Cross-references

- `kestrel_sovereign/features/workflows/models.py` — Stage / Edge / Gate / Trigger / WorkflowSpec / WorkflowRun / StageLink dataclasses.
- `kestrel_sovereign/features/workflows/schema.py` — JSON schemas + `validate_spec_payload`.
- `kestrel_sovereign/features/workflows/store.py` — three-table migration.
- `kestrel_sovereign/features/workflows/signing.py` — author-spec and stage-transition signing.
- `kestrel_sdk/signals/models.py` — Signal / SourceRegistration definitions.
- `kestrel_sovereign/signals/dispatcher.py` — the engine Workflows composes onto.
- `docs/architecture/WORKFLOWS_FEATURE_DESIGN.md` — the parent design doc.
