﻿# FlowMarkup Validation Reference

**Version:** 0.9.0
**Date:** 2026-03-28
**Designed by:** Łukasz Nawojczyk
**Copyright:** © 2026 Progralink Łukasz Nawojczyk. All rights reserved.
**License:** Open Web Foundation Agreement 1.0 (OWFa 1.0)

## Overview

FlowMarkup flows pass through four validation layers before and during execution:

1. **YAML/JSON parse** -- syntax errors, malformed YAML, missing frontmatter delimiters in `.flowmarkup.md`
2. **JSON Schema (Draft 2020-12)** -- structural conformance: required keys, type constraints, enum values, `oneOf` step resolution
3. **Static analysis (SA-)** -- semantic correctness checked before execution; catches bugs that schema cannot express
4. **Runtime** -- dynamic errors discovered at execution time; some are catchable by `catch:`, others are pre-execution failures

This document catalogs all static analysis rules and key runtime error conditions, with examples.

## Severity

| Severity | Keyword | Meaning |
|---|---|---|
| **ERROR** | `[hard]` | Flow will not load or run. Must be fixed. |
| **WARN** | `[warn]` | Likely bug or unsafe pattern. Fix strongly recommended. |
| **INFO** | `[info]` | Informational note. Review at author's discretion. |

## Status

| Symbol | Meaning |
|---|---|
| ✅ | Rule is fully specified; validators SHOULD implement it |
| 📋 | Rule is specified; lower implementation priority |

---

## Part 1: Static Analysis Rules

Rules that can be checked BEFORE execution -- purely from analyzing the YAML document.

---

### 1.1 Core Semantic Rules (CORE-\*)

These fundamental data-flow and semantic analysis rules are specified in section 5.3 of `FLOWMARKUP-ENGINE.md`. They represent the deepest checks a full static analyzer performs.

#### CORE-1 -- Input completeness 📋 ERROR

Every action's required `params` entries must resolve to a defined variable or literal on every execution path reaching the step.

**Bad:**
```yaml
flowmarkup:
  do:
    - call:
        service: claude
        params:
          prompt: user_input   # ERROR: 'user_input' never defined
```

---

#### CORE-2 -- Output reachability 📋 WARN

Variables referenced in expressions must have a producing step on every execution path.

**Bad:**
```yaml
- if:
    condition: =cached
    then:
      - set:
          result: =cache_value
- log: "Result: {{ result }}"   # WARN: 'result' undefined when cached is false
```

---

#### CORE-3 -- Error coverage 📋 WARN

Every action's declared `errors` must either be caught by an enclosing `try`/`catch` or declared in the flow's `throws`.

**Bad:**
```yaml
- call:
    service: SERVICES.payment
    errors: [PaymentDeclinedError]   # WARN: not caught and not in flow throws:
```

---

#### CORE-4 -- Type compatibility 📋 WARN

Types must match between producer and consumer. If a step outputs `$kind: NUMBER` and the consumer expects `$kind: TEXT`, static analysis should flag the mismatch.

---

#### CORE-5 -- Flow output fulfillment 📋 ERROR

For multi-param output contracts, every `output.required` param must be set before completion on every execution path. For single-value output contracts, every path must end with `return: <expression>`.

**Bad:**
```yaml
flowmarkup:
  output:
    required:
      result: TEXT
      status: TEXT
  do:
    - call:
        service: SERVICES.processor
        result: { result: =RESULT.data }
    - return: { result: result }
    # ERROR: 'status' never set
```

---

#### CORE-6 -- Loop scope for `break`/`continue` 📋 ERROR

`break` and `continue` must appear inside a `forEach`, `while`, or `repeat` loop. Usage outside a loop body is an error.

**Bad:**
```yaml
flowmarkup:
  do:
    - break:   # ERROR: not inside a loop
```

---

#### CORE-7 -- `return` output keys 📋 ERROR

For multi-value `return: { key: ... }`, keys must match the flow's declared output contract. Unknown keys are an error; missing required keys are an error.

**Bad:**
```yaml
flowmarkup:
  output:
    required:
      result: TEXT
  do:
    - return:
        data: response   # ERROR: key 'data' not in output contract
```

---

#### CORE-8 -- `throw` error type declaration 📋 WARN

The `error` type in a `throw` step must either be caught by an enclosing `try`/`catch` or declared in the flow's `throws` list.

---

#### CORE-9 -- Step label uniqueness 📋 INFO

`_label_` values should be unique within the flow for clarity in observability and tooling.

---

#### CORE-10 -- Group branch variable conflicts 📋 WARN

In `mode: PARALLEL`, concurrent branches writing to the same flow variable is a data race. In `mode: RACE`, same-variable writes are intentional (no warning).

**Bad:**
```yaml
- parallel:
    a:
    - set:
        result: 'from_a'   # WARN: both branches write 'result'
    b:
    - set:
        result: 'from_b'
```

---

#### CORE-11 -- Concurrent `forEach` variable writes 📋 WARN

If `concurrent: true` and steps inside the body write to flow-level variables, static analysis should warn (last-writer-wins race condition).

**Bad:**
```yaml
- forEach:
    items: "orders"
    as: order
    concurrent: true
    do:
      - set:
          last_processed: order.id   # WARN: race across concurrent iterations
```

---

#### CORE-12 -- `assert` reachability 📋 INFO

Variables asserted non-null may be treated as guaranteed non-null in subsequent steps on the same execution path, enabling more precise variable-undefined warnings.

---

#### CORE-13 -- Variable name format 📋 ERROR

Flow variable names must be valid CEL identifiers using `snake_case`. Hyphens are the CEL subtraction operator.

**Bad:** `result: claude-result` -- **Good:** `result: claude_result`

---

#### CORE-14 -- Error data key validation 📋 WARN

Keys in `throw.data` should be valid identifiers (accessed via `ERROR.DATA.<key>`).

---

#### CORE-15 -- Single-value / multi-param output contract consistency 📋 ERROR

Single-value output (`output: { $kind: ... }`) requires expression-form `return`. Multi-param output requires map-form `return`. Mixing is an error.

**Bad:**
```yaml
flowmarkup:
  output:
    $kind: TEXT
  do:
    - return:
        result: response   # ERROR: must be `return: response`
```

---

#### CORE-16 -- `call` output form vs target contract 📋 WARN

When the target's output contract is statically known: single-value contract requires `result:` to be a string; multi-param requires `result:` to be an object.

---

### 1.2 Action Contract (AC-\*)

These rules validate the step-level `params:`/`result:`/`errors:` against the action provider's declared contract.

#### AC-1 -- All required input params present 📋 ERROR

Every param declared as `required` in the action's input contract must be present with a non-null value.

---

#### AC-2 -- Output required params mapped 📋 WARN

Every param declared as `required` in the action's output contract should be mapped to a flow variable.

---

#### AC-3 -- Errors subset of action declaration 📋 WARN

Step-level `errors:` must be a subset of what the action declares as throwable.

**Bad:**
```yaml
- call:
    service: SERVICES.claude
    errors: [FileNotFoundError]   # WARN: claude never throws FileNotFoundError
```

---

### 1.3 Identity (SA-ID-\*)

#### SA-ID-1 -- Duplicate `_id_` ✅ ERROR

`_id_` values must be unique within the entire flow document.

**Bad:**
```yaml
- call:
    _id_: fetch_data
    service: SERVICES.db
- call:
    _id_: fetch_data   # ERROR: duplicate
    service: SERVICES.api
```

**Good:** use distinct identifiers -- `fetch_db`, `fetch_api`.

---

### 1.4 Data Elements & Initialization (SA-CONST-\*, SA-INIT-\*, SA-READONLY-\*)

#### SA-CONST-1 -- Write to readonly data element ✅ ERROR

Any write target must not name a key declared in `const:` or a data element marked `$readonly: true` (in `vars:` or `set:`).

**Bad (1):**
```yaml
flowmarkup:
  const:
    max_retries: 3
  do:
    - set:
        max_retries: 5    # ERROR: const is immutable
```

**Bad (2):**
```yaml
flowmarkup:
  vars:
    api_base:
      $readonly: true
      $kind: STRING
      $value: =ENV.API_HOST
  do:
    - set:
        api_base: "https://other.com"    # ERROR: readonly data element
```

---

#### SA-CONST-2 -- `const` and `vars` data element namespace collision ✅ ERROR

The same data element name must not appear in both `const:` and `vars:`.

**Bad:**
```yaml
flowmarkup:
  const:
    timeout_ms: 5000
  vars:
    timeout_ms: 0     # ERROR: name already in const:
```

---

#### SA-INIT-1 -- `const:` CEL references flow-local name ✅ ERROR

At `const:` init time, only `ENV.*`, `SECRET.*`, `GLOBAL.*`, `CONTEXT.*`, and `RUNTIME.*` are in scope. Referencing another `const:` key or a `vars:` key is an error.

**Bad:**
```yaml
flowmarkup:
  const:
    base_url: =ENV.API_BASE
    full_url: =base_url + '/v2'   # ERROR: base_url is a const key
```

---

#### SA-INIT-2 -- `vars:` CEL references another `vars:` name ✅ ERROR

`vars:` entries are initialized in undefined order. Referencing a sibling `vars:` name is an error.

**Bad:**
```yaml
flowmarkup:
  vars:
    count: 0
    doubled: =count * 2   # ERROR: 'count' is another vars: name
```

---

#### SA-READONLY-1 -- `$readonly: true` in `vars:` declaration 📋 INFO

A `vars:` entry marked `$readonly: true` is functionally equivalent to a `const:` entry. Consider moving to `const:` for clarity.

**Exception:** When the `vars:` entry references `const:` names in its `$value` expression — since `const:` entries cannot reference other `const:` names (SA-INIT-1), `$readonly: true` in `vars:` is the only way to achieve a readonly computed value that depends on constants.

**Triggers:**

```yaml
flowmarkup:
  const:
    base_url: "https://api.example.com"
  vars:
    endpoint:
      $readonly: true                    # INFO: consider const: instead
      $kind: STRING
      $value: =base_url + "/v2"          # Exception: references const name — vars: $readonly is required
```

---

#### SA-READONLY-2 -- Redundant `$readonly` on inherently read-only scope 📋 INFO

`$readonly: true` on `ENV.*`, `SECRET.*`, `RUNTIME.*`, or `RESOURCES.*` references is redundant — these scopes are already read-only by design.

---

### 1.5 Expression Prefix (SA-QUOTE-\*, SA-INTERP-\*)

#### SA-INTERP-1 -- `=` prefix combined with `{{ }}` template ✅ ERROR

The `=` prefix and `{{ }}` template interpolation are mutually exclusive on the same value.

**Bad:**
```yaml
- if:
    condition: "=order.status == '{{expected}}'"   # ERROR
```

**Good:**
```yaml
- if:
    condition: =order.status == expected_status
```

---

#### SA-INTERP-2 -- `{{ }}` template on a boolean or numeric field ✅ ERROR

Template interpolation always produces a string. Fields that require a boolean or numeric result cannot use `{{ }}`.

Fields where this applies: `condition:`, `forEach.items:`, `repeat.until:`, `switch.value:`.

**Bad:**
```yaml
- if:
    condition: "{{ score_var }}"   # ERROR: string is always truthy
```

**Good:** `condition: =score_var`

---

#### SA-QUOTE-1 -- Bare string on expression field looks like missing `=` prefix 📋 WARN

A bare string (no `=`, no `{{ }}`) on an expression field that looks like a CEL expression is likely missing the `=` prefix.

Detection heuristics: scope prefixes, dotted access, CEL operators, method calls, known variable names. Detection is limited to fields known to accept expressions (`condition:`, `items:`, `value:`, `until:`, typed `$value:`) — not all string fields.

**Bad:**
```yaml
- if:
    condition: counter > 5      # WARN: looks like CEL, missing =
```

**Good:** `condition: =counter > 5`

---

#### SA-QUOTE-2 -- `=` prefix on a carved-out field ✅ ERROR

Some fields never evaluate as CEL. Using `=` on them is always wrong.

Affected fields: `forEach.as:`, `forEach.index:`, `onYield.as:`, `onYield.index:`, `result:` target keys, `_id_:`, `_label_:`, `_notes_:`, `_meta_:`, `throw.error:`, `catch:` map keys, `emit.event:`, `waitFor.event:`, `exec.command:`, `run.flow:`, `switch.match:` map keys.

**Bad:**
```yaml
- forEach:
    items: =order.items
    as: =item                   # ERROR: binding name, not CEL
    do:
      - call:
          service: =SERVICES.svc
          _id_: =my_step        # ERROR: _id_ is plain text
```

---

### 1.6 Result Binding (SA-RESULT-\*)

#### SA-RESULT-1 -- Object-form `result:` value does not start with `=` ✅ ERROR

In object-form `result:`, every value is a CEL expression. Values without `=` are treated as string literals.

**Bad:**
```yaml
- call:
    service: SERVICES.processor
    result:
      my_var: RESULT.data   # ERROR: missing = prefix
```

**Good:** `my_var: =RESULT.data`

---

#### SA-RESULT-2 -- `RESULT.*` referenced outside a `result:` context ✅ WARN

The `RESULT` binding is only populated inside `result:` object-form expressions on action steps.

**Bad:**
```yaml
- call:
    service: SERVICES.processor
- log: "='Data: ' + RESULT.data"  # WARN: RESULT is null here
```

---

#### SA-RESULT-3 -- `RESULT.*` used as a write target ✅ ERROR

`RESULT` is a read-only binding and must not appear as a write target.

**Bad:**
```yaml
- set:
    RESULT.data: =some_value   # ERROR: RESULT is read-only
```

---

#### SA-RESULT-4 -- List-form `result:` key not in action's declared output 📋 WARN

When `result: [key1, key2]` is used, each key should match a declared output field of the action. Missing keys are set to `null` at runtime, which may indicate a typo.

**Bad:**
```yaml
- call:
    service: db
    operation: query
    result: [rows, typo_field]   # WARN: typo_field not in declared output
```

---

### 1.7 Error Hierarchy (SA-ERR-\*)

#### SA-ERR-1 -- Circular inheritance in `throws:` ✅ ERROR

`throws:` declarations must not form a `$parent:` cycle.

**Bad:**
```yaml
throws:
  - $kind: NetworkError
    $parent: ServiceError
  - $kind: ServiceError
    $parent: NetworkError   # ERROR: cycle
```

---

#### SA-ERR-2 -- Unknown `$parent:` reference ✅ ERROR

Every `$parent:` value must name a user-defined type declared in the same `throws:` list. System error types (those listed in the retryable/non-retryable table in §4.1 of the specification) are implicitly available as parents and do not need re-declaration.

**Bad:**
```yaml
throws:
  - $kind: DbError
    $parent: SomeUndeclaredError   # ERROR: SomeUndeclaredError not declared
```

**Good:**
```yaml
throws:
  - $kind: DbError
    $parent: TimeoutError   # OK: TimeoutError is a system error type
```

---

#### SA-ERR-3 -- Unreachable catch key ✅ WARN

A parent-type key before a child-type key in `catch:` makes the child unreachable.

**Bad:**
```yaml
catch:
  NetworkError:
    - log: 'Network error'
  ConnectionRefusedError:          # WARN: never reached (child of NetworkError)
    - log: 'Refused'
```

**Good:** reverse the order -- most-specific first.

---

#### SA-ERR-4 -- Error type self-parent ✅ ERROR

A `throws:` entry must not name itself as its `$parent:`.

---

#### SA-ERR-5 -- `default` catch-all before specific error types ✅ WARN

In any `catch:` map (flow-level, `try:` step, or action-step inline), a `default` key before specific error type keys makes those handlers unreachable.

**Bad:**
```yaml
catch:
  default:
    - set: { fallback: true }
  CircuitOpenError:           # WARN: unreachable
    - log: circuit open
```

**Good:** put `default` last.

---

#### SA-ERR-6 -- `catch:` on `async: true` step ✅ WARN

`async: true` is fire-and-forget: errors do not propagate. A `catch:` handler is unreachable.

**Bad:**
```yaml
- call:
    service: analytics
    operation: track
    async: true
    catch:              # WARN: unreachable
      NetworkError:
        - log: tracking failed
```

---

#### SA-ERR-7 -- `catch:` combined with `rollback:` on same step ✅ ERROR

Conflicting semantics: if `catch:` catches the error, rollback never fires. Use `try:/catch:` explicitly.

**Bad:**
```yaml
- call:
    service: payment
    operation: charge
    rollback:
      - call: { service: payment, operation: refund, params: { tx: =tx_id } }
    catch:                # ERROR: conflicting with rollback:
      NetworkError:
        - log: charge failed
```

---

#### SA-ERR-8 -- Nested hierarchy parent naming ✅ ERROR

Parent key in nested `throws:` hierarchy does not match error naming pattern (PascalCase, starts with uppercase letter).

**Bad:**
```yaml
throws:
- networkError:           # lowercase — SA-ERR-8
  - ConnectionRefusedError
```

**Good:**
```yaml
throws:
- NetworkError:
  - ConnectionRefusedError
```

---

#### SA-ERR-9 -- `throws:` includes caught error type 📋 ERROR

Flow `throws:` list includes an error type that is caught by an enclosing `try`/`catch` within the same flow. Caught errors should not appear in `throws:` — they are handled, not propagated.

---

#### SA-ERR-10 -- Unguarded child-specific `ERROR.DATA` field access in parent catch 📋 WARN

A `catch:` handler catches a parent error type whose children have heterogeneous `data:` fields, and the handler accesses a child-specific `ERROR.DATA` field without a `has()` guard. If a different child was thrown, the field may not exist.

**Bad:**
```yaml
catch:
  PaymentError:
    # PaymentError has children ChargeError (data: { tx_id }) and RefundError (data: { refund_id })
    - log: =ERROR.DATA.tx_id       # WARN: tx_id only exists on ChargeError
```

**Good:**
```yaml
catch:
  PaymentError:
    - if:
        condition: =has(ERROR.DATA.tx_id)
        then:
          - log: =ERROR.DATA.tx_id
```

---

#### SA-ERR-11 -- `ERROR.CAUSE` chain depth in catch handlers 📋 WARN

A `catch:` handler accesses `ERROR.CAUSE` in a CEL expression without depth bounds (e.g., recursive CAUSE traversal). The engine truncates the cause chain at 16 entries, but catch handlers should not assume unlimited depth. Prevents catch handlers from accidentally processing unbounded error chains. Flags `catch:` handlers that access `ERROR.CAUSE` chains without depth guard, or flows with error chains that could exceed the configurable depth limit.

> **Rationale:** CWE-674 (Uncontrolled Recursion). `ERROR.CAUSE` chain traversal without depth guard risks unbounded processing.

---

### 1.8 Variables & Data Elements (SA-VAR-\*)

#### SA-VAR-1 -- Variable name not snake_case 📋 ERROR

Flow variable names must be valid CEL identifiers using `snake_case`. The `$` character is forbidden in variable names.

**Bad:** `claude-result: =RESULT.data` -- **Good:** `claude_result: =RESULT.data`

> See also CORE-13.

---

#### SA-VAR-2 -- `ENV.*` write target 📋 ERROR

Environment variables are read-only.

**Bad:**
```yaml
- set:
    ENV.LOG_LEVEL: 'debug'   # ERROR: ENV.* is read-only
```

---

#### SA-VAR-3 -- Reserved variable prefixes in `vars:` declarations 📋 ERROR

`vars:` keys must not use scope prefixes (`GLOBAL.`, `CONTEXT.`, `LOCAL.`, `ENV.`).

**Bad:**
```yaml
flowmarkup:
  vars:
    CONTEXT.correlation_id: "''"   # ERROR
```

---

#### SA-VAR-4 -- Reserved variable name `event` 📋 ERROR

`event` is a reserved CEL binding in `trigger.condition` and `waitFor.condition`.

**Bad:** `as: event` -- **Good:** `as: evt`

---

#### SA-VAR-5 -- `CONTEXT.*` in `vars:` declarations 📋 ERROR

Context variables are engine-managed and cannot be declared in `vars:`. Use `set:` for context writes.

---

#### SA-VAR-6 -- Missing `=` prefix on expression field value that looks like a variable reference 📋 WARN

A bare value that looks like a variable name is treated as a string literal. See also SA-QUOTE-1.

**Bad:**
```yaml
- set:
    count: total_items        # WARN: string literal, not the variable
```

**Good:** `count: =total_items`

---

#### SA-VAR-7 -- `camelCase` variable name 📋 WARN

Variable names must be `snake_case`. A `camelCase` name violates convention.

**Bad:** `as: orderId` -- **Good:** `as: order_id`

---

#### SA-VAR-8 -- Undeclared data element reference 📋 WARN

A CEL expression references a data element not declared anywhere in the flow. Usually a typo.

**Bad:**
```yaml
vars:
  order_total: 0
do:
- set:
    total: =order_totl + item.price   # WARN: typo for 'order_total'
```

---

### 1.9 Type System (SA-TYPE-\*)

#### SA-TYPE-1 -- Type name not PascalCase 📋 ERROR

Keys in `types:` must match `^[A-Z][a-zA-Z0-9]*$`.

**Bad:** `order_item: { type: object }` -- **Good:** `OrderItem`

---

#### SA-TYPE-2 -- `$ref` does not resolve 📋 ERROR

A `$ref` that cannot be resolved produces `SchemaResolutionError` at load time.

---

#### SA-TYPE-3 -- Circular `$ref` in type definitions 📋 ERROR

Reference cycles (A -> B -> A) in `types:` produce `SchemaResolutionError`.

---

#### SA-TYPE-15 -- `$ref` chain exceeds maximum resolution depth 📋 ERROR

A `$ref` reference chain (A references B, B references C, ...) exceeds the maximum resolution depth (default: 32). While each individual reference is acyclic (no cycles per SA-TYPE-3), deeply nested chains can cause stack overflow or excessive memory consumption during schema resolution. *(CWE-400)*

---

#### SA-TYPE-4 -- Field access on typed variable -- property not in schema 📋 WARN

Accessing a property not listed in the type schema. May indicate a typo.

**Bad:** `assert: "order.stauts != null"` -- `stauts` is a typo for `status`.

---

#### SA-TYPE-5 -- Required field missing when constructing typed value 📋 WARN

A `set` step constructs a value for a typed variable but omits fields from `required`.

---

#### SA-TYPE-6 -- Enum value not in schema enum 📋 WARN

A static literal assigned to a typed field does not appear in the field's `enum` constraint.

---

#### SA-TYPE-7 -- `$schema:` on primitive type with no constraints 📋 INFO

`$schema:` on `$kind: STRING` with no added constraints provides no benefit.

---

#### SA-TYPE-8 -- `$schema:` and `$enum:` both present on `paramDef` 📋 ERROR

`$schema:` and `$enum:` are mutually exclusive.

---

#### SA-TYPE-9 -- External schema file not found 📋 ERROR

A `$ref` pointing to a non-existent local file produces `SchemaLoadError`.

---

#### SA-TYPE-10 -- Type name in `$schema:` string form not found in `types:` 📋 ERROR

`$schema: OrderItem` when `OrderItem` is not declared in the flow's `types:`.

---

#### SA-TYPE-11 -- `http://` schema URL without `integrity:` ✅ ERROR

Non-TLS schema fetches to external hosts are rejected. For loopback hosts, downgraded to warning.

---

#### SA-TYPE-12 -- `https://` schema URL without `integrity:` ✅ WARN

TLS provides transport security but not content pinning. `integrity:` ensures the schema is exactly what was authored.

---

#### SA-TYPE-13 -- `$ref` URL targets private/internal IP range ✅ ERROR

A `$ref` URL in `types:` resolves to a private or internal IP address (`10.*`, `172.16-31.*`, `192.168.*`, `127.*`, `169.254.*`, `::1`, `fc00::/7`, `fd00::/8`, `fe80::/10`). Schema fetches MUST NOT reach internal network endpoints — this prevents SSRF via schema resolution. The engine MUST resolve DNS and validate the resolved IP (not just the hostname) before connecting, to prevent DNS rebinding attacks.

**Bad:**
```yaml
types:
  InternalSchema: { $ref: "http://192.168.1.100/schema.json" }   # ERROR: private IP
```

---

#### SA-TYPE-14 -- `$ref` schema fetch redirect to private/internal IP range 📋 ERROR

A `$ref` schema fetch received an HTTP redirect (301/302/307/308) where the redirect target resolves to a private or internal IP address. Schema fetches MUST follow the same redirect origin enforcement as the `request` action: each redirect target MUST be validated against private/internal IP ranges before connecting, and the engine MUST NOT follow redirects to origins not authorized by the engine's schema-fetch configuration. Maximum redirect depth: 5.

**Bad:**
```yaml
types:
  ExternalSchema: { $ref: "https://cdn.example.com/schema.json" }   # ERROR if cdn redirects to 169.254.169.254
```

---

### 1.10 Secrets (SA-SECRET-\*)

All SA-SECRET rules detect escape vectors where opaque `SecretValue` can leak.

#### SA-SECRET-1 -- `SECRET.*` in `set:` value 📋 ERROR

Copying a `SecretValue` into a flow variable.

**Good:** pass `SECRET.*` directly to action `params:`.

---

#### SA-SECRET-2 -- `SECRET.*` in `return:` value 📋 ERROR

Returning a secret exposes it to the caller.

---

#### SA-SECRET-3 -- `SECRET.*` in `log:` expression 📋 ERROR

Secret values must not be written to log output.

---

#### SA-SECRET-4 -- `SECRET.*` in `emit.data:` value 📋 ERROR

Secret values must not be published to the event bus.

---

#### SA-SECRET-5 -- `SECRET.*` in `throw.data:` or `throw.message:` 📋 ERROR

Secret values must not be exposed in error payloads or messages.

---

#### SA-SECRET-6 -- `SECRET.*` in `assert:` condition 📋 ERROR

Comparison is not supported on opaque `SecretValue`. Use `has(SECRET.name)` instead.

---

#### SA-SECRET-7 -- `SECRET.*` in `switch.value:` 📋 ERROR

`switch` requires equality comparison; not supported for `SecretValue`.

---

#### SA-SECRET-8 -- `SecretValue` in boolean condition 📋 ERROR

`condition:` fields cannot evaluate a `SecretValue` as boolean. Use `has(SECRET.name)`.

---

#### SA-SECRET-9 -- `SECRET.*` in `forEach.items:` 📋 ERROR

Iterating over an opaque `SecretValue` is nonsensical.

---

#### SA-SECRET-10 -- `SECRET.*` in flow-level `output:` 📋 ERROR

Exposing secrets in the flow's output contract violates opacity.

---

#### SA-SECRET-11 -- `SECRET.*` in `vars:` initializer 📋 ERROR

Assigning a secret at variable declaration time copies it into flow scope.

---

#### SA-SECRET-12 -- `SECRET.*` in `decode()`/`parse()` 📋 ERROR

Passing an opaque `SecretValue` to `decode()` or `parse()` attempts to inspect its contents.

---

#### SA-SECRET-13 -- `SECRET.*` used without `requires.SECRET` ✅ WARN

> **Consolidated into SA-CAP-1** (section 1.11). Kept here for cross-reference. The flow references secrets but does not declare `requires: { SECRET: [...] }`.

---

#### SA-SECRET-14 -- `requires.SECRET` lists secrets not used in flow body ✅ WARN

`requires: { SECRET: [...] }` declares secret names that are never referenced in the flow body. Unused secret declarations widen the flow's attack surface without benefit — remove unused entries for principle of least privilege.

---

#### SA-SECRET-15 -- `SECRET.*` field access on wrong type 📋 ERROR

E.g., `SECRET.text_secret.username` -- a `text`-type secret has no `username` field.

---

#### SA-SECRET-16 -- `SECRET.*` in `request.url:` 📋 ERROR

Embedding secrets in URLs leaks to access logs, proxy logs, and browser history.

**Good:** `headers: { X-API-Key: =SECRET.api_key }` or `auth: { bearer: =SECRET.api_key }`

---

#### SA-SECRET-17 -- `SECRET.*` in `exec.args:` 📋 ERROR

Secrets in command-line arguments are visible in `ps` and `/proc`. Use `exec.env:` instead.

---

#### SA-SECRET-21 -- `SECRET.*` in `exec.env:` values 📋 WARN

Environment variables of child processes are readable via `/proc/PID/environ` on Linux by the same user (and root). While safer than command-line arguments (`exec.args:`), `exec.env:` is not fully opaque. For higher-security secret injection, consider `exec.stdin:` or ensure the engine sets `PR_SET_DUMPABLE=0` on child processes (Linux). *(CWE-214: Invocation of Process Using Visible Sensitive Information)*

---

#### SA-SECRET-18 -- `SECRET.*` in `request.query:` values 📋 ERROR

Secret values in query parameters leak to server access logs, proxy logs, CDN caches, and `Referer` headers — the same exposure vectors as secrets in URLs (SA-SECRET-16). Use `request.headers:` or `request.auth:` instead.

> **Rationale:** CWE-598 (Use of GET Request Method With Sensitive Query Strings). Query parameters are logged by proxies, CDNs, browser history, and server access logs, making secret exposure virtually certain. Engines MUST also enforce this at runtime: if a resolved `SecretValue` is detected in a query parameter position during request construction, the engine MUST reject the request with `SecretInjectionError`.

**Error:**
```yaml
- request:
    url: "https://maps.googleapis.com/maps/api/geocode/json"
    query:
      key: =SECRET.google_maps_key   # ERROR: secret in query param — use headers instead
      address: =address
```

**Good:**
```yaml
- request:
    url: "https://api.example.com/data"
    headers:
      X-API-Key: =SECRET.api_key     # OK: secret in header — not logged by default
```

---

#### SA-SECRET-19 -- Inline secret definition in `cap:` 📋 ERROR

Inline secret definitions in `cap:` are not supported. Secrets MUST come from the engine's secrets provider. The `cap: { SECRET: ... }` property only accepts an array of secret names to pass through from the caller's secret set.

**Error:**
```yaml
- run:
    flow: "processor.flowmarkup.yaml"
    cap:
      SECRET:
        api_token:
          type: text
          value: "sk-ant-api03-REDACTED"   # ERROR: inline secret definitions not supported
```

**Good:**
```yaml
- run:
    flow: "processor.flowmarkup.yaml"
    cap:
      SECRET: [api_token]                  # OK: pass-through reference to provider-managed secret
```

---

#### SA-SECRET-20 -- `SECRET.*` in `{{ }}` interpolation ✅ ERROR

Template interpolation auto-stringifies the value, exposing the secret.

**Bad:**
```yaml
- log: 'Using key: {{SECRET.api_key}}'   # ERROR
```

---

#### SA-SECRET-22 -- Function parameter receives `SECRET.*` value 📋 ERROR

A user-defined function is called with a `SECRET.*` value as an argument. Secrets MUST be passed directly to action `params:`, not through intermediate function calls. Function parameters are plain values — they lose the `SecretValue` handle's opacity guarantees.

**Bad:**
```yaml
functions:
  build_header:
    params: [token]
    body: ="Bearer " + token
do:
  - set:
      auth: =build_header(SECRET.api_token)     # ERROR: secret passed to function
```

**Good:**
```yaml
- request:
    url: https://api.example.com/data
    auth:
      bearer: =SECRET.api_token                   # OK: secret passed directly to action
```

---

#### SA-SECRET-23 -- `SECRET.*` access inside unbounded loop without rate limit 📋 WARN

SECRET.* access inside unbounded loop (while, repeat, forEach without maxItems) without explicit rate limit annotation. Recommend: move SECRET resolution outside the loop and pass the resolved value, or add explicit rateLimit: on the containing step.

**Warn:**
```yaml
- forEach:
    items: =requests
    as: req
    do:
      - request:
          url: =req.url
          auth:
            bearer: =SECRET.api_token             # WARN: SECRET access in unbounded loop
```

**Good:**
```yaml
# Good: Use SECRET directly at the action boundary
- forEach:
    items: =requests
    as: req
    maxItems: 100
    do:
      - request:
          url: =req.url
          auth:
            bearer: =SECRET.api_token              # OK: bounded loop, direct at action boundary
```

---

#### SA-SECRET-24 -- `$declassify` audit requirement 📋 ERROR

A variable declaration uses `$declassify: true`. `$declassify` removes the `$secret` taint flag, which may allow secret-derived values to reach output boundaries. Every use MUST be justified and audited. The transformation MUST be a one-way function from the normative safe-transform list (see FLOWMARKUP-SECRETS.md §7.4) and audit logging MUST be configured. The `$declassify` annotation MUST include a `via:` field naming the transform function used. When the engine's `declassify.policy` is `DENY` (the default), any flow containing `$declassify` MUST fail with this rule at ERROR severity.

> **Rationale:** CWE-778 (Insufficient Logging). `$declassify` bypasses secret taint tracking and requires audit trail for compliance. CWE-200 (Exposure of Sensitive Information).

---

#### SA-SECRET-25 -- `hmac()` key in `$declassify` context is not a `SECRET.*` reference 📋 ERROR

When `$declassify` is used with `hmac()`, the HMAC key parameter MUST be a `SecretValue`. Using a plain variable or literal as the HMAC key undermines the one-way property since an attacker with key knowledge can verify guesses against the HMAC output.

**Error:**
```yaml
- set:
    user_hash:
      $declassify: true
      $value: =hmac(SECRET.user_token, plain_key)   # ERROR: HMAC key is not a SECRET.*
```

**Good:**
```yaml
- set:
    user_hash:
      $declassify: true
      $value: =hmac(SECRET.user_token, SECRET.hmac_key)   # OK: HMAC key is a SECRET.*
```

> **Rationale:** CWE-328 (Use of Weak Hash). HMAC with a known key degrades to a simple hash, enabling offline brute-force against the original secret value.

---

#### SA-SECRET-26 -- `SECRET.*` in `request.body`, `request.query`, or `request.url` 📋 ERROR

`SECRET.*` values MUST NOT appear in `request.body`, `request.query`, or `request.url` fields. Secrets MUST be injected via `request.auth.bearer`, `request.auth.basic`, `request.headers`, or service `params:`. Query parameters leak to server access logs, proxy logs, CDN caches, and `Referer` headers. Request bodies and URLs have similar exposure risks through logging middleware. `request.headers` is a valid injection point — custom authentication headers (e.g., `X-API-Key`) are the preferred alternative to query parameters.

> **Note:** SA-SECRET-16 and SA-SECRET-18 independently cover `request.url` and `request.query`. SA-SECRET-26 consolidates those cases and extends coverage to `request.body`, which is not covered by any other rule.

**Error:**
```yaml
- request:
    url: ="https://api.example.com/data?key=" + SECRET.api_key   # ERROR: secret in URL
    body:
      json: { token: =SECRET.auth_token }                        # ERROR: secret in body
```

**Good:**
```yaml
- request:
    url: "https://api.example.com/data"
    headers:
      X-API-Key: =SECRET.api_key                                 # OK: secret in header
    auth:
      bearer: =SECRET.auth_token                                 # OK: secret in auth
```

> **Rationale:** CWE-200 (Exposure of Sensitive Information). CWE-598 (Sensitive Query Strings).

---

#### SA-SECRET-27 -- Action provider error schema includes reflected input fields 📋 ERROR

Action provider error schema includes fields that could contain reflected input (e.g., `ERROR.DATA.request`, `ERROR.DATA.headers`). When an action provider fails, error response fields may echo back resolved secret values (CWE-209). The engine MUST apply secret redaction to `ERROR.DATA` fields before making error data available to flow `catch:` handlers — fields derived from resolved `SecretValue` handles MUST be replaced with `[REDACTED]`. See ENGINE.md §5.4 item 51.

**Error:**
```yaml
- call:
    service: api
    operation: authenticate
    params: { token: =SECRET.api_token }
    result: { response: =RESULT }
    errors: [AuthError]
    catch:
      AuthError:
        - log: "Auth failed: {{ERROR.DATA.request}}"   # ERROR: ERROR.DATA.request may contain reflected secret
```

---

### 1.10a Log Injection (SA-LOG-\*)

#### SA-LOG-1 -- Log injection via user-controlled input 📋 ERROR

`log:` expression concatenates or interpolates user-controlled input (`input:` parameters, `EVENT.DATA.*`) without sanitization. The engine MUST strip CR/LF characters from all log output by default to prevent log injection. *(CWE-117)*

---

### 1.11 Capabilities (SA-CAP-\*) -- CONSOLIDATED

**Parent rule:** Using a deny-by-default scope without declaring the corresponding `requires:` entry prevents load-time capability validation.

| Scope/Feature | Required Declaration | Original Rule |
|---|---|---|
| `SECRET.*` | `requires: { SECRET: [...] }` | SA-SECRET-13 |
| `exec` steps | `requires: { EXEC: [...] }` | SA-EXEC-1 |
| `RUNTIME.*` | `requires: { RUNTIME: true }` | SA-RT-1 |
| `RESOURCES.*` | `requires: { RESOURCES: {...} }` | SA-RES-1 |
| `SERVICES.*` (inline CEL) | `requires: { SERVICES: [...] }` or `services:` | SA-SVC-9 |
| `mail` steps | `requires: { MAIL: true }` | SA-MAIL-3 |
| `request` steps | `requires: { REQUEST: [...] }` | SA-REQ-12 |

#### SA-CAP-1 -- Deny-by-default scope used without `requires:` declaration ✅ WARN

The flow uses a deny-by-default scope or feature but does not declare the corresponding `requires:` entry.

**Bad:**
```yaml
flowmarkup:
  do:
    - exec:
        command: make        # WARN: no requires.EXEC declared
    - call:
        service: SERVICES.api
        params: { key: SECRET.api_key }   # WARN: no requires.SECRET declared
    - assert:
        condition: =RUNTIME.OS.NAME == 'Linux'   # WARN: no requires.RUNTIME declared
```

**Good:**
```yaml
flowmarkup:
  requires:
    EXEC: [make]
    SECRET: [api_key]
    RUNTIME: true
  do:
    - exec:
        command: make
    - call:
        service: SERVICES.api
        params: { key: SECRET.api_key }
```

---

#### SA-CAP-2 -- `INHERIT` capability forwarding to unverified remote flow 📋 ERROR

`cap: { SECRET: INHERIT }` or `cap: { SSH: INHERIT }` on a `run:` step targeting a remote flow without `integrity:`. Forwarding high-sensitivity capabilities to unverified remote code is privilege escalation.

**Bad:**
```yaml
- run:
    flow: "https://example.com/process.flowmarkup.yaml"
    cap: { SECRET: INHERIT }          # ERROR: INHERIT to remote without integrity
```

**Good:**
```yaml
- run:
    flow: "https://example.com/process.flowmarkup.yaml"
    integrity: sha256-abc123...
    cap: { SECRET: INHERIT }          # OK: integrity verified
```

---

#### SA-CAP-3 -- `INHERIT` when explicit enumeration is possible 📋 ERROR

`cap: { SECRET: INHERIT }` on a `run:` step when the sub-flow's `requires: { SECRET: [...] }` lists fewer secrets than the caller possesses. Prefer explicit: `cap: { SECRET: ["only_needed_key"] }`.

---

#### SA-CAP-4 -- `cap: { EXEC: INHERIT }` or `cap: { SSH: INHERIT }` on `run:` to unverified flow 📋 ERROR

cap: { EXEC: INHERIT } or cap: { SSH: INHERIT } on a run: step targeting a flow without integrity: verification. EXEC and SSH capabilities grant shell access — forwarding them to unverified flows is equivalent to granting arbitrary code execution. Differs from SA-CAP-2 (which covers full INHERIT to remote flows) by targeting specific high-risk categories on any flow reference.

**Bad:**
```yaml
- run:
    flow: ./plugins/processor.flowmarkup
    cap:
      EXEC: INHERIT                               # ERROR: shell access to unverified flow
```

**Good:**
```yaml
- run:
    flow: ./plugins/processor.flowmarkup
    integrity: "sha256-abc123..."
    cap:
      EXEC: INHERIT                               # OK: integrity-verified
```

---

#### SA-CAP-5 -- `cap: INHERIT` forwards unused capabilities to sub-flow 📋 WARN

cap: INHERIT (full inherit) on any run: step where the sub-flow's requires: declares capabilities broader than those actively used by the calling flow. This may forward unused but granted capabilities.

---

### 1.12 Global Variable Access (SA-GLOBAL-\*)

#### SA-GLOBAL-1 -- `GLOBAL.*` write without `requires: { GLOBAL: [...] }` per-key declaration ✅ ERROR

A flow writes to `GLOBAL.*` keys but does not declare per-key access in `requires: { GLOBAL: [...] }`. Without per-key declarations, any flow can write to any GLOBAL key, risking cross-flow state corruption. This is a static analysis error — all GLOBAL write access must be declared per-key.

**Bad:**
```yaml
flowmarkup:
  requires:
    GLOBAL: READ_WRITE            # ERROR: unrestricted — any key
  do:
    - set:
        GLOBAL.request_count: =GLOBAL.request_count + 1
```

**Good:**
```yaml
flowmarkup:
  requires:
    GLOBAL: [request_count]       # per-key: only request_count
  do:
    - set:
        GLOBAL.request_count: =GLOBAL.request_count + 1
```

---

#### SA-GLOBAL-2 -- `GLOBAL.*` access to undeclared key ✅ ERROR

When `requires: { GLOBAL: [key1, key2] }` uses the per-key array form, accessing a GLOBAL key not in the list is an error.

**Bad:**
```yaml
flowmarkup:
  requires:
    GLOBAL: [request_count]
  do:
    - set:
        GLOBAL.admin_flag: true   # ERROR: admin_flag not declared
```

---

#### SA-GLOBAL-3 -- `GLOBAL.*` read-modify-write without `lock:` 📋 WARN

A **multi-step** read-modify-write pattern where `GLOBAL.*` is read in one step and written in a subsequent step without being enclosed in a `lock:` directive. Individual GLOBAL writes are atomic, but read-modify-write across separate steps is NOT transactional — concurrent flows may overwrite each other's changes (last-writer-wins).

Note: a **single-step** `set: { GLOBAL.x: =GLOBAL.x + 1 }` is safe because the engine evaluates the expression and writes the result atomically within one step. This rule only fires for multi-step RMW patterns (read in step A, write in step B).

**Warn:**
```yaml
- set:
    counter: =GLOBAL.request_count        # step A reads GLOBAL
- set:
    GLOBAL.request_count: =counter + 1    # step B writes GLOBAL — WARN: multi-step RMW without lock
```

**OK (single-step):**
```yaml
- set:
    GLOBAL.request_count: =GLOBAL.request_count + 1   # OK: single-step atomic RMW
```

**Good:**
```yaml
- lock:
    name: request_counter
    scope: GLOBAL
    timeout: 5s
    do:
      - set:
          GLOBAL.request_count: =GLOBAL.request_count + 1  # OK: inside lock
```

---

### 1.13 Circuit Breaker (SA-CB-\*)

#### SA-CB-1 -- `circuitBreaker` on `async: true` step ✅ ERROR

> **See SA-ASYNC-1** (section 1.16). Async steps suppress error propagation; the circuit breaker cannot observe failures.

---

#### SA-CB-2 -- `circuitBreaker.name` missing `=` prefix on expression value 📋 WARN

`name:` is a CEL expression field. Without `=`, the value is a string literal.

**Good:** `name: payment_service` (literal) or `name: =dynamic_name_var` (dynamic)

---

#### SA-CB-3 -- `circuitBreaker scope: LOCAL` in concurrent context 📋 INFO

Each concurrent unit gets its own LOCAL-scoped breaker instance. Failures don't accumulate.

**Good:** use `scope: GLOBAL` or `scope: CONTEXT` for shared failure tracking.

---

#### SA-CB-4 -- `circuitBreaker.errors` includes permanent error types ✅ WARN

Permanent errors (`ValidationError`, `BadRequestError`, `AuthenticationError`, `AccessDeniedError`, `MissingCapabilityError`, `NotFoundError`, `ConfigurationError`, `AssertionError`, `AddressError` — see FLOWMARKUP-ERRORS.md "Permanent error types") indicate structural faults that will not resolve on retry. Use `nonCountable:` to exclude them.

**Bad:**
```yaml
circuitBreaker:
  name: 'api'
  errors: [ValidationError, TimeoutError]   # WARN: ValidationError is permanent
```

---

#### SA-CB-5 -- `circuitBreaker` + `rollback:` interaction 📋 INFO

When the circuit is OPEN, the action never executes, so no `rollback:` handler is registered.

---

#### SA-CB-6 -- Flow-level `circuitBreaker` with `scope: LOCAL` ✅ WARN

Each flow instance runs exactly once; a LOCAL-scoped flow-level breaker is a no-op.

---

#### SA-CB-7 -- `circuitBreaker.threshold: 1` 📋 INFO

Threshold of 1 trips the breaker on a single failure, causing false positives from transient errors. Also fires on integer shorthand `circuitBreaker: 1` and string shorthand `circuitBreaker: "1/name"`.

---

#### SA-CB-8 -- `circuitBreaker.errors`/`nonCountable` vs `retry.errors` mismatch 📋 WARN

Conflicting fault-handling intent between CB error counting and retry error classification.

---

#### SA-CB-9 -- `circuitBreaker` inside `lock` body 📋 WARN

When the circuit is OPEN, `CircuitOpenError` is thrown instantly while the lock is held. Place the breaker outside the lock boundary.

---

#### SA-CB-10 -- `circuitBreaker` + `condition:` interaction 📋 INFO

When `condition:` evaluates to false, the step is skipped and no breaker telemetry is recorded.

---

#### SA-CB-11 -- Short `circuitBreaker` name with `scope: GLOBAL` ✅ WARN

GLOBAL-scoped names are shared across all flows. Short, generic names risk unintended collisions. Also evaluates names from string shorthand (e.g., `circuitBreaker: "5/api"` with default scope GLOBAL).

**Bad:** `name: 'api'` with `scope: GLOBAL`, or `circuitBreaker: "5/api"` (default scope GLOBAL)
**Good:** `name: 'com.example.orders.api_gateway'`

---

#### SA-CB-12 -- Dynamic circuit breaker name (CEL expression, not literal) 📋 WARN

`circuitBreaker.name` is a CEL expression (`=` prefixed). Dynamic circuit breaker names from user input can exhaust the circuit breaker registry, causing `ResourceExhaustedError`. Same risk as SA-LOCK-8 for lock names. Prefer static circuit breaker names or bounded sets.

---

#### SA-CB-13 -- Integer `circuitBreaker` shorthand without `_id_` on step ✅ ERROR

Integer shorthand (`circuitBreaker: 5`) auto-derives the circuit breaker name from the step's `_id_`. Without `_id_`, the name cannot be determined.

**Bad:**
```yaml
- call:
    service: payment
    operation: charge
    circuitBreaker: 5              # ERROR: no _id_ on step
```

**Good:**
```yaml
- call:
    _id_: charge_payment
    service: payment
    operation: charge
    circuitBreaker: 5              # name derived as "charge_payment"
```

---

### 1.14 Rate Limiting (SA-RL-\*)

#### SA-RL-1 -- `rateLimit strategy: WAIT` without `timeout:` 📋 WARN

Without a timeout, the flow may block indefinitely when the window is full.

---

#### SA-RL-2 -- `rateLimit` on `async: true` action 📋 ERROR/WARN

> **See SA-ASYNC-1** (section 1.16). For `strategy: WAIT`: blocks before async launch. For `strategy: REJECT`: rejection is silently suppressed.

---

#### SA-RL-3 -- Flow-level `rateLimit` with `scope: LOCAL` 📋 WARN

Each flow instance runs once; LOCAL scope is meaningless for invocation throttling.

---

#### SA-RL-4 -- `rateLimit.timeout:` with `strategy: REJECT` 📋 INFO

No waiting occurs on REJECT; `timeout:` has no effect.

---

### 1.15 Retry (SA-RETRY-\*)

#### SA-RETRY-1 -- `retry.onErrors` and `retry.nonRetryable` both present 📋 ERROR

Mutually exclusive: `onErrors` is a whitelist, `nonRetryable` is a blacklist.

**Bad:**
```yaml
retry:
  onErrors: [TimeoutError]
  nonRetryable: [ValidationError]   # ERROR: mutually exclusive
```

---

#### SA-RETRY-2 -- Retrying permanent error types 📋 WARN

Permanent errors (`ValidationError`, `BadRequestError`, `AuthenticationError`, `AccessDeniedError`, `MissingCapabilityError`, `NotFoundError`, `ConfigurationError`, `AssertionError`, `AddressError`) will not resolve on retry. This applies to all actions including `mail` (consolidates former SA-MAIL-9).

**Bad:**
```yaml
retry:
  onErrors: [ValidationError, TimeoutError]   # WARN: ValidationError is permanent
```

---

#### SA-RETRY-3 -- `retry: =expr` (CEL reference form) ✅ INFO

References a retryPolicy object by name. Ensure the referenced value is a valid `retryPolicy` object at runtime.

**Good:**
```yaml
const:
  STD_RETRY: 3/2s/EXPONENTIAL
- call:
    service: payment
    operation: charge
    retry: =STD_RETRY
```

---

### 1.16 Async (SA-ASYNC-\*) -- CONSOLIDATED

`async: true` is fire-and-forget. Errors do not propagate, results are not returned. Multiple rules across categories express this same principle; they are consolidated here.

#### SA-ASYNC-1 -- `async: true` with incompatible properties ✅ WARN/ERROR

The following properties are silently ignored or behave incorrectly with `async: true`:

| Property | Severity | Effect | Former Rules |
|---|---|---|---|
| `result:` | WARN | async never returns a value | SA-ASYNC-1, SA-MAIL-4 |
| `retry:` | WARN | errors suppressed; retry never triggers | SA-ASYNC-1 |
| `timeout:` | WARN | fire-and-forget ignores timeout | SA-ASYNC-1 |
| `rateLimit:` | WARN | WAIT blocks before async; REJECT silently suppressed | SA-RL-2 |
| `circuitBreaker:` | ERROR | CB cannot observe failures | SA-CB-1 |
| `rollback:` | ERROR | no error propagation; rollback unreachable | SA-ROLLBACK-1 |
| `catch:` | WARN | errors are not catchable inline | SA-ERR-6 |

**Bad:**
```yaml
- call:
    service: SERVICES.analytics
    async: true
    result:
      analytics_result: =RESULT.data   # WARN: async never returns
    retry:
      maxAttempts: 3                    # WARN: errors suppressed
    rollback:                           # ERROR: unreachable
      - log: 'Undo analytics'
```

---

### 1.17 Exec (SA-EXEC-\*)

#### SA-EXEC-1 -- `exec` steps without `requires.EXEC` ✅ WARN

> **See SA-CAP-1** (section 1.11). `exec` is deny-by-default.

---

#### SA-EXEC-2 -- `exec.command` not in `requires.EXEC` allowlist ✅ ERROR

If `requires.EXEC` is an array, every `exec.command` must appear in it.

**Bad:**
```yaml
flowmarkup:
  requires:
    EXEC: [git]
  do:
    - exec:
        command: curl    # ERROR: curl not in allowlist
```

---

#### SA-EXEC-3 -- `exec.command` contains path separator 📋 WARN

Absolute paths are fragile. Use bare executable names resolved via `PATH`.

---

#### SA-EXEC-4 -- `exec` without `timeout:` 📋 WARN

Processes may hang indefinitely. Always set a timeout.

---

#### SA-EXEC-5 -- `exitCode` in output AND `ExecError` in `errors` 📋 WARN

When `exitCode` is mapped in `result:`, `ExecError` is never auto-thrown. Listing `ExecError` in `errors:` is dead code.

---

#### SA-EXEC-6 -- `exec.stdin` with `async: true` 📋 INFO

With `async: true`, stdin delivery is fire-and-forget; the process may not receive all data.

---

#### SA-EXEC-7 -- `exec.command` contains shell metacharacters 📋 WARN

`exec` runs a process directly (no shell). Shell metacharacters (`|`, `>`, `<`, `&&`, `;`, `$()`) will not be interpreted.

---

#### SA-EXEC-8 -- `requires: { EXEC: INHERIT }` invalid ✅ ERROR

`INHERIT` is not valid on `requires:`. Flows MUST declare specific executables (e.g., `EXEC: ["git", "python3"]`). `INHERIT` is only valid on `cap:` (the forwarding side). See R-SEC-8.

---

#### SA-EXEC-9 -- `cap: { EXEC: INHERIT }` on `run` step 📋 WARN

Passing through caller's command execution capability to a sub-flow. Prefer explicit executable names. See R-SEC-8.

**Bad:**
```yaml
- run:
    flow: "processor.flowmarkup.yaml"
    cap:
      EXEC: INHERIT                  # WARN: passes all caller EXEC capabilities to sub-flow
```

**Good:**
```yaml
- run:
    flow: "processor.flowmarkup.yaml"
    cap:
      EXEC: [git, make]             # OK: explicit executable names only
```

---

#### SA-EXEC-10 -- `EXEC` allowlist contains interpreter names ✅ ERROR

The `requires: { EXEC: [...] }` or `cap: { EXEC: [...] }` allowlist contains interpreter names (`python3`, `python`, `node`, `ruby`, `bash`, `sh`, `zsh`, `fish`, `dash`, `ksh`, `csh`, `tcsh`, `perl`, `php`, `cmd`, `cmd.exe`, `powershell`, `pwsh`, `powershell.exe`). Granting access to a general-purpose interpreter effectively grants arbitrary code execution, bypassing the allowlist's intent. Shell interpreters (`bash`, `sh`, `zsh`, `fish`, `dash`, `ksh`, `csh`, `tcsh`, `cmd`, `cmd.exe`, `powershell`, `pwsh`, `powershell.exe`) are unconditionally rejected. Scripting interpreters (`python3`, `python`, `node`, `ruby`, `perl`, `php`) require explicit engine-level acknowledgment (see ENGINE §5.4 item 42).

**Error:**
```yaml
flowmarkup:
  requires:
    EXEC: [git, python3]             # ERROR: python3 is an interpreter — effectively grants arbitrary execution
```

```yaml
flowmarkup:
  requires:
    EXEC: [git, bash]                # ERROR: bash is a shell — unconditionally rejected
```

**Good:**
```yaml
flowmarkup:
  requires:
    EXEC: [git, myapp]               # OK: specific executables only
```

---

#### SA-EXEC-11 -- `exec` step with dynamically constructed `args:` from untrusted input 📋 WARN

An `exec` step has `args:` entries that incorporate flow input parameters, `EVENT.DATA.*`, or `CONTEXT.*` values without prior validation. While `exec` has no shell interpretation, malicious arguments can still exploit application-specific vulnerabilities (e.g., `git` flag injection with `--upload-pack`).

**Warn:**
```yaml
- exec:
    command: git
    args: ['clone', =user_provided_url]   # WARN: user input in exec args without validation
```

**Good:**
```yaml
- assert:
    condition: "=user_provided_url.matches('^https://github\\\\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+\\\\.git$')"
    message: "Invalid repository URL"
- exec:
    command: git
    args: ['clone', =user_provided_url]   # OK: validated against strict pattern
```

---

#### SA-EXEC-12 -- `exec.args:` contains argument starting with `-` from CEL expression 📋 WARN

An `exec` step has `args:` entries where a CEL expression may produce values starting with `-` (dash). This can cause argument injection — attackers may pass `--flag=value` to change command behavior. Mitigate by prepending `--` (end-of-options marker) before user-controlled arguments where the command supports it.

**Warn:**
```yaml
- exec:
    command: git
    args: ['log', =branch_name]           # WARN: branch_name could start with - (e.g., --all)
```

**Good:**
```yaml
- exec:
    command: git
    args: ['log', '--', =branch_name]     # OK: -- prevents argument injection
```

---

#### SA-EXEC-13 -- `exec.env:` value derived from user-controlled input ✅ ERROR

`exec.env` key or value derived from CEL expressions referencing user-controlled input (flow input parameters, `EVENT.DATA`, `YIELD` values) without validation against the denied environment variable list (`LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, `PATH`, `HOME`, `SHELL`, `IFS`, `CDPATH`, `ENV`, `BASH_ENV`, `PYTHONPATH`, `NODE_PATH`, `RUBYLIB`, `PERL5LIB`, `CLASSPATH`). Attacker-controlled environment variables can manipulate process behavior or inject credentials into child process environments. Validate value content and restrict sensitive variable names.

> **Rationale:** CWE-426 (Untrusted Search Path). User-controlled `exec.env` values can override security-critical environment variables.

---

### 1.18 Mail (SA-MAIL-\*)

#### SA-MAIL-1 -- `mail` step with no recipient 📋 ERROR

At least one of `to:`, `cc:`, or `bcc:` is required.

---

#### SA-MAIL-2 -- `mail` step without mail capability 📋 ERROR

`mail` is deny-by-default. The engine rejects flows without the capability provisioned.

---

#### SA-MAIL-3 -- `mail` without `requires: { MAIL: true }` ✅ WARN

> **See SA-CAP-1** (section 1.11).

---

#### SA-MAIL-4 -- `mail` + `async: true` with incompatible properties 📋 ERROR/WARN

> **See SA-ASYNC-1** (section 1.16). Covers `result:`, `retry:`, `timeout:`, `rateLimit:`, `rollback:`.

---

#### SA-MAIL-5 -- No `body:` and no `attachments:` 📋 INFO

Subject-only emails are valid but unusual. May indicate a missing body.

---

#### SA-MAIL-6 -- `smtp.tls: NONE` 📋 ERROR

Unencrypted SMTP transmits credentials in plaintext. `tls: NONE` MUST be rejected. Engines MUST NOT allow unencrypted SMTP connections. This rejection applies to both static literals and CEL-resolved values; engines MUST validate the resolved TLS mode at runtime when `smtp.tls` is a CEL expression.

---

#### SA-MAIL-7 -- *(Removed)*

The `smtp.verifyTLS` option has been removed from the specification. TLS certificate verification for SMTP connections is always enabled and cannot be disabled per-flow. The engine uses the operating system's native certificate store.

---

#### SA-MAIL-8 -- `headers` contains engine-managed key 📋 WARN

Engine-managed headers (`From`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Content-Type`) set in `headers:` are ignored.

---

#### SA-MAIL-9 -- `contentId:` without `inline: true` 📋 WARN

`contentId` is only meaningful for inline attachments referenced via `cid:` in HTML body.

---

#### SA-MAIL-10 -- HTML body without text fallback 📋 INFO

`body.html:` without `body.text:` has no plain-text fallback.

---

#### SA-MAIL-11 -- `mail.to:` unquoted email address ✅ ERROR

An unquoted email address like `user@example.com` is parsed as CEL syntax error.

**Bad:** `to: [user@example.com]` -- **Good:** `to: ['user@example.com']`

---

#### SA-MAIL-12 -- `cap: { MAIL: true }` on `run` step — suggest recipient restrictions 📋 INFO

Granting unrestricted email sending capability to a sub-flow. Consider using a recipient allowlist: `MAIL: ["@example.com"]`. See R-SEC-7.

---

#### SA-MAIL-13 -- `mail` step with explicit `from:` field 📋 WARN

Sender spoofing risk. Engine MUST validate sender authorization via SMTP configuration (SPF/DKIM/DMARC alignment). Review that the `from:` address is authorized. See ENGINE.md §5.4 item 15.

---

#### SA-MAIL-14 -- `body.html:` contains `{{ }}` interpolation 📋 INFO

Informational note: `body.html:` uses `{{ }}` template interpolation. Interpolated values are auto-escaped by the engine (HTML entities for `<`, `>`, `&`, `"`, `'`). Use `htmlRaw()` to bypass auto-escaping for pre-sanitized content.

**Info:**
```yaml
- mail:
    to: ['admin@example.com']
    subject: "User Report"
    body:
      html: '<h1>Report for {{user_name}}</h1>'   # INFO: auto-escaped — safe by default
```

**Bypassing auto-escape for pre-sanitized content:**
```yaml
- mail:
    to: ['admin@example.com']
    subject: "User Report"
    body:
      html: '<div>{{htmlRaw(sanitized_html)}}</div>'  # OK: htmlRaw() bypasses auto-escape
```

---

#### SA-MAIL-15 -- `htmlRaw()` with user-controlled input 📋 ERROR

htmlRaw() argument traces to input:, EVENT.DATA, RESULT, or any variable without $sanitized: true annotation. Use htmlSanitize() before htmlRaw() for user-controlled HTML content: body.html: =htmlRaw(htmlSanitize(user_input)).

**Error:**
```yaml
- mail:
    to: ['admin@example.com']
    body:
      html: '<div>{{htmlRaw(user_provided_html)}}</div>'  # ERROR: user input in htmlRaw()
```

**Good:**
```yaml
# Option 1: Don't use htmlRaw() — auto-escaping handles user input safely
- mail:
    to: ['admin@example.com']
    body:
      html: '<div>{{user_provided_text}}</div>'           # OK: auto-escaped

# Option 2: Sanitize HTML before htmlRaw() using a service
- call:
    service: sanitizer
    operation: sanitize_html
    params: { html: =user_provided_html }
    result: { safe_html: =RESULT.sanitized }
- mail:
    to: ['admin@example.com']
    body:
      html: '<div>{{htmlRaw(safe_html)}}</div>'           # OK: sanitized before htmlRaw()
```

---

#### SA-MAIL-16 -- `htmlRaw()` used outside `body.html:` context 📋 ERROR

`htmlRaw()` is only meaningful in `body.html:` template interpolation. Using it in `log:`, `set:`, `return:`, or other contexts has no security benefit and may indicate a misunderstanding of the escaping model.

---

#### SA-MAIL-17 -- `mail` `from:` address derived from user input 📋 ERROR

The `from:` field in a `mail` step references a CEL expression incorporating flow input parameters, `EVENT.DATA.*`, or `CONTEXT.*` values. User-controlled `from:` addresses enable phishing from authorized infrastructure. *(CWE-183)* Sender addresses MUST be static or derived from engine configuration. Parallels SA-EXEC-11 for argument injection.

**Error:**
```yaml
- mail:
    to: ['admin@example.com']
    from: "={{input.sender_email}}"      # ERROR: user-controlled from: address
    subject: "Report"
    body: { text: "..." }
```

**Good:**
```yaml
- mail:
    to: ['admin@example.com']
    from: 'noreply@example.com'          # OK: static sender address
    subject: "Report"
    body: { text: "..." }
```

---

#### SA-MAIL-18 -- `cap: { MAIL: INHERIT }` on `run` step 📋 WARN

Forwarding the caller's full email capability to a sub-flow via `INHERIT`. Consider restricting to explicit recipient allowlists instead. See also SA-CAP-3 (prefer explicit enumeration over INHERIT).

**Warn:**
```yaml
- run:
    flow: "notify.flowmarkup.yaml"
    cap:
      MAIL: INHERIT                       # WARN: forwarding full mail capability
```

**Good:**
```yaml
- run:
    flow: "notify.flowmarkup.yaml"
    cap:
      MAIL: ["@example.com"]             # OK: restricted recipient allowlist
```

---

#### SA-MAIL-19 -- `requires: { MAIL: INHERIT }` invalid ✅ ERROR

`INHERIT` is not valid on `requires:`. Flows MUST declare explicit recipient patterns (e.g., `MAIL: ["@example.com"]`). `INHERIT` is only valid on `cap:` (the forwarding side). See R-SEC-7.

---

#### SA-MAIL-20 -- User-controlled mail recipients with unrestricted MAIL capability ✅ WARN/ERROR

`mail.to`, `mail.cc`, or `mail.bcc` contains a CEL expression referencing user-controlled input, AND the MAIL capability is `true` (not domain-restricted). Unrestricted MAIL capability combined with user-controlled recipients enables the flow to be used as a spam relay. Domain-restricted MAIL capability mitigates this. Severity is ERROR when MAIL capability is `true`/unrestricted, WARN when domain-restricted.

> **Rationale:** CWE-183 (Permissive List of Allowed Inputs). User-controlled mail recipients (`mail.to`, `mail.cc`, `mail.bcc`) derived from user input without domain/address allowlist validation.

---

#### SA-MAIL-21 -- Mail `subject:` contains CR/LF from user-controlled input ✅ ERROR

Mail `subject:` contains CR (`\r`) or LF (`\n`) characters from user-controlled input. CRLF in email subjects enables SMTP header injection in some implementations. The engine MUST strip CR/LF from all email subject values before sending. *(CWE-93: Improper Neutralization of CRLF Sequences)*

---

### 1.19 HTTP Request (SA-REQ-\*)

#### SA-REQ-1 -- `request.expect.status` + `result.status` both present 📋 INFO

Both are unusual together: `expect.status` throws `HttpError` on mismatch before result mapping runs.

---

#### SA-REQ-2 -- `request` body on bodyless methods 📋 WARN

`GET`, `HEAD`, `OPTIONS` conventionally have no request body per RFC 9110.

---

#### SA-REQ-3 -- `request` `auth:` and `Authorization` header conflict 📋 WARN

`auth:` takes precedence over a manually set `Authorization` header.

---

#### SA-REQ-4 -- `request` `HttpError` not handled 📋 INFO

If `RESULT.status` is not mapped, `HttpError` is not in `errors:`, and no enclosing `catch:` covers it, 4xx/5xx responses throw unhandled `HttpError`. Propagation to the caller is a valid pattern.

---

#### SA-REQ-5 -- *(Removed)*

The `verifyTLS` option has been removed from the specification. TLS certificate verification is always enabled and cannot be disabled per-flow. The engine uses the operating system's native certificate store. Custom CA certificates for internal services must be installed in the OS certificate store or configured at the engine level.

---

#### SA-REQ-6 -- `request` decomposed URL with full URL 📋 WARN

If `url:` is present together with `scheme:` or `path:`, the decomposed properties are ambiguous.

---

#### SA-REQ-7 -- `request` structured body with `Content-Type` header conflict 📋 WARN

`body.json:`, `body.urlencoded:`, `body.multipart:` auto-set `Content-Type`. A manual `Content-Type` header is ignored.

---

#### SA-REQ-8 -- `request` `retry:` with `status` mapped in `result` 📋 WARN

When `status` is mapped, `HttpError` is not auto-thrown. `retry:` only fires for connection errors.

---

#### SA-REQ-9 -- `cap: { REQUEST: INHERIT }` on `run` step 📋 WARN

Passing through caller's outbound HTTP capability to a sub-flow. Prefer explicit origin patterns (e.g., `REQUEST: ["api.example.com"]`). See R-SEC-6.

---

#### SA-REQ-10 -- `requires: { REQUEST: INHERIT }` invalid ✅ ERROR

`INHERIT` is not valid on `requires:`. Flows MUST declare explicit origin patterns (e.g., `REQUEST: ["api.example.com"]`). `INHERIT` is only valid on `cap:` (the forwarding side).

---

#### SA-REQ-11 -- `request` with `auth:` (basic or bearer) over `http://` scheme 📋 ERROR

Auth (`basic` or `bearer`) over `http://` scheme. ERROR for non-local URLs. Severity is INFO when the static URL targets a local address (`localhost`, `127.0.0.0/8`, `::1`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). For CEL-computed URLs where locality cannot be determined at static analysis time, severity remains ERROR. *(CWE-319: Cleartext Transmission of Sensitive Information)*

**Error:**
```yaml
- request:
    url: 'http://api.example.com/data'
    auth:
      bearer: =SECRET.api_token         # ERROR: auth over http:// — credentials in plaintext
```

**Good:**
```yaml
- request:
    url: 'https://api.example.com/data'
    auth:
      bearer: =SECRET.api_token         # OK: auth over https://
```

---

#### SA-REQ-12 -- `request` step without `requires: { REQUEST: [...] }` ✅ ERROR

`request` is deny-by-default. A flow using `request` steps MUST declare `requires: { REQUEST: [...] }` with explicit origin patterns.

**Bad:**
```yaml
flowmarkup:
  requires: {}                            # ERROR: no REQUEST declaration
  do:
    - request:
        url: "https://api.example.com"
```

**Good:**
```yaml
flowmarkup:
  requires:
    REQUEST: ["https://api.example.com"]  # explicit origin allowlist
  do:
    - request:
        url: "https://api.example.com"
```

#### SA-REQ-13 -- `parseAs` with `async: true` ✅ WARN

`async: true` discards the response, so `parseAs` is meaningless.

**Bad:**
```yaml
- request:
    method: POST
    url: "=webhook_url"
    async: true
    parseAs: JSON      # WARN: async discards response
```

**Good:**
```yaml
- request:
    method: POST
    url: "=webhook_url"
    async: true             # No parseAs needed
```

---

#### SA-REQ-14 -- `RESULT.body.decode(FORMAT)` where `parseAs` would suffice 📋 INFO

Engine-level lint. When a `request` step's `result:` maps `body` and the flow subsequently calls `.decode(FORMAT)` on it, suggest using `parseAs` instead for engine-level auto-parsing. This requires data-flow tracing beyond `validate-flow.py` scope — spec-only, not implemented in the validator.

---

#### SA-REQ-15 -- SSRF via CEL-computed URL ✅ ERROR

`request.url` is a CEL expression that references user-controlled input (flow input parameters, `EVENT.DATA`, or `YIELD` values) without applying URL validation (scheme check, hostname allowlist check). CEL-computed URLs that incorporate user input can be manipulated to target internal services, cloud metadata endpoints (`169.254.169.254`), or other protected resources. Validate scheme (`http`/`https` only) and hostname against the REQUEST capability allowlist before use, or decompose the URL into `scheme` + `host` + `path` with static host values. **Forbidden schemes:** Engines MUST reject the following URL schemes in `request.url` at both static analysis and runtime: `file`, `ftp`, `gopher`, `data`, `dict`, `ldap`, `ldaps`, `tftp`, `sftp`, `smb`, `telnet`, `jar`, `netdoc`, `expect` — see [FLOWMARKUP-SPECIFICATION.md](FLOWMARKUP-SPECIFICATION.md) §4.3 for the normative forbidden scheme list.

> **Rationale:** CWE-918 (Server-Side Request Forgery). `request.url` derived from CEL expression or user input without SSRF allowlist validation.

---

#### SA-REQ-16 -- CRLF injection in custom request headers ✅ ERROR

`request.headers` contains values derived from CEL expressions referencing user-controlled input, and no CRLF validation is applied. Carriage return and line feed characters in header values can inject additional headers or split the HTTP response, enabling cache poisoning, XSS, or session fixation. Engine MUST reject header values containing `\r` or `\n` at runtime. SA rule flags the static pattern for early detection.

> **Rationale:** CWE-113 (HTTP Response Splitting). `request.headers` value containing CR (`\r`) or LF (`\n`) characters, or derived from user input without CRLF sanitization.

---

#### SA-REQ-17 -- Dynamic operation name detection 📋 WARN

An action step (`call` or `request`) has its operation name derived from a CEL expression rather than a static literal. Dynamic operation names enable potential dispatch to unintended operations — an attacker who controls the expression input can redirect execution to arbitrary service operations or HTTP methods. When `request.method` is a CEL expression, it could produce `CONNECT`, `TRACE`, or other methods that behave differently from expected CRUD operations. When `call.operation` is a CEL expression, it could invoke arbitrary service methods. Verify the expression cannot evaluate to unexpected values.

> **Rationale:** CWE-94 (Improper Control of Generation of Code). Action step with operation name derived from CEL expression, enabling potential dispatch to unintended operations.

---

#### SA-REQ-18 -- User-controlled header key names ✅ ERROR

`request.headers` key names derived from user-controlled input. Header key names MUST be static string literals. Dynamic header keys enable injection of security-sensitive headers (`Host`, `X-Forwarded-For`, `Transfer-Encoding`). *(CWE-113)*

---

#### SA-REQ-19 -- Static `request.url` targeting private/internal IP ranges ✅ ERROR

Static `request.url` literal targeting private/internal IP ranges (RFC 1918, RFC 4193, link-local, loopback) or cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`, `metadata.google.internal`). *(CWE-918)*

---

#### SA-REQ-20 -- Unbounded request body size 📋 WARN

`request.body` expression references unbounded collection or uses string generation functions (`string.repeat()`, unbounded `join()`) without size guard. The engine MUST enforce a configurable maximum request body size (default: 10 MB). *(CWE-400)*

---

#### SA-REQ-21 -- `request` follows redirects without explicit `followRedirects:` declaration 📋 WARN

`request` step follows HTTP redirects without explicit `followRedirects:` declaration. Default redirect-following can lead to SSRF when an allowed origin redirects to internal endpoints. Flow authors SHOULD explicitly declare redirect behavior. When `followRedirects: true` (or default), the engine MUST validate each redirect target against the REQUEST allowlist (ENGINE §5.4 item 16). *(CWE-601: URL Redirection to Untrusted Site)*

---

### 1.20 Storage (SA-STORAGE-\*)

#### SA-STORAGE-1 -- `storage` step without `requires.STORAGE` ✅ WARN

> **See SA-CAP-1** (section 1.11). `storage` is deny-by-default.

---

#### SA-STORAGE-2 -- `storage.url` does not match any alias or URL pattern in `requires.STORAGE` ✅ ERROR

Every `storage.url` must resolve to an alias or match a URL pattern declared in `requires.STORAGE`.

---

#### SA-STORAGE-3 -- `storage` operation not in allowed operation list ✅ ERROR

If `requires.STORAGE` restricts operations for a matching alias or URL pattern (Form 2/3), every `storage.operation` must be in the allowed list.

---

#### SA-STORAGE-4 -- `requires: { STORAGE: INHERIT }` invalid ✅ ERROR

`INHERIT` is not valid on `requires:`. Flows MUST declare specific aliases/URL patterns and operations. `INHERIT` is only valid on `cap:` (the forwarding side).

---

#### SA-STORAGE-5 -- `cap: { STORAGE: INHERIT }` on `run` step 📋 WARN

Passing through caller's storage capability to a sub-flow. Prefer explicit alias/URL-pattern/operation restrictions.

---

#### SA-STORAGE-6 -- `copy`/`move` across different URL authorities ✅ ERROR

`copy` and `move` operate within a single resolved URL authority (same scheme+host+port after alias resolution). Cross-authority `copy`/`move` are not supported — use `transfer` + `delete`.

---

#### SA-STORAGE-7 -- `transfer` with top-level `url:`/`path:`/`data:`/`parseAs:` ✅ ERROR

`transfer` uses `source:` and `target:` blocks. The single-URL fields (`url:`, `path:`, `data:`, `parseAs:`) are mutually exclusive with `source:`/`target:`.

---

#### SA-STORAGE-8 -- `storage delete`/`move` flagged as destructive 📋 WARN

Destructive operations. Ensure intent is clear.

---

#### SA-STORAGE-9 -- `storage` without `timeout:` 📋 WARN

Storage operations may hang indefinitely. Always set a timeout.

---

#### SA-STORAGE-10 -- `storage put` with `async: true` 📋 WARN

Fire-and-forget write — data may not be delivered. Ensure this is intentional.

---

#### SA-STORAGE-11 -- `storage transfer` where source and target resolve to the same URL authority 📋 INFO

Same-authority transfer is valid but consider `copy` instead.

---

#### SA-STORAGE-12 -- `storage put` with `parseAs:` ✅ ERROR

`parseAs` is only valid for `get` operations. `put` writes data, it does not decode.

---

#### SA-STORAGE-13 -- `storage get` with `parseAs:` combined with `async: true` 📋 WARN

Async discards the result, so `parseAs` is meaningless.

---

#### SA-STORAGE-14 -- Local storage provider in use 📋 WARN

Local filesystem storage providers have elevated security implications. Verify `basePath` is correctly restricted.

---

#### SA-STORAGE-15 -- `url:` contains userinfo component ✅ ERROR

`url:` must not contain userinfo (`scheme://user:pass@host`). Credentials are resolved by the engine's credential mapping, not embedded in URLs.

---

#### SA-STORAGE-16 -- Both `url:` path component and `path:` field are non-trivial 📋 WARN

When `url:` contains a non-trivial path component and `path:` is also set, the effective path may be ambiguous. Prefer using `url:` for the base endpoint and `path:` for the relative path, or put the full path in `url:` alone.

---

#### SA-STORAGE-17 -- Storage `path:` contains URL-encoded traversal sequences 📋 ERROR

Storage `path:` expression contains URL-encoded sequences (`%2f`, `%2e`, `%00`) that decode to traversal characters. Path validation MUST occur after all decoding and normalization. Encoded traversal sequences bypass string-level `../` checks.

**Bad:**
```yaml
- storage:
    operation: get
    url: files
    path: ="uploads/" + user_input     # ERROR if user_input contains %2e%2e%2f
```

---

#### SA-STORAGE-18 -- Storage `path:` incorporates user-controlled input without validation 📋 ERROR

A `storage` step's `path:` expression incorporates flow input parameters, `EVENT.DATA.*`, or `CONTEXT.*` values without prior validation or allowlist checking. User-controlled paths without validation enable file access manipulation even within storage root boundaries. *(CWE-22)* Validate path components against an allowlist pattern before use.

**Warn:**
```yaml
- storage:
    operation: get
    url: files
    path: ="documents/" + user_filename   # WARN: user input in storage path without validation
```

**Good:**
```yaml
- assert:
    condition: "=user_filename.matches('^[a-zA-Z0-9_.-]+$') && !user_filename.contains('..')"
    message: "Invalid filename"
- storage:
    operation: get
    url: files
    path: ="documents/" + user_filename   # OK: validated filename
```

---

#### SA-STORAGE-19 -- `cacheHint:` on `mkdir` operation 📋 WARN

A `storage` step with `operation: mkdir` has a `cacheHint:` directive. `mkdir` has no applicable caching semantics — it creates a directory and returns only a path. The directive is silently ignored but indicates author confusion.

**Warn:**
```yaml
- storage:
    operation: mkdir
    url: backup_sftp
    path: "/backups/2026"
    cacheHint: "5m"              # WARN: cacheHint has no effect on mkdir
```

**Good:**
```yaml
- storage:
    operation: mkdir
    url: backup_sftp
    path: "/backups/2026"
    # No cacheHint — mkdir has no caching semantics
```

---

#### SA-STORAGE-20 -- Read-side `cacheHint:` properties on write operation 📋 WARN

A `storage` step performing a write operation (`put`, `delete`, `copy`, `move`) specifies read-side `cacheHint:` properties (`ttl`, `revalidation`, `staleWhileRevalidate`, `staleIfError`, `negative`, `negativeTtl`, `maxSize`, `priority`, `warm`, `varyBy`, `scope`). These properties apply only to read operations (`get`, `info`, `exists`, `list`).

**Warn:**
```yaml
- storage:
    operation: put
    url: s3_data
    path: "config/settings.json"
    data: =new_settings
    cacheHint:
      ttl: 5m                   # WARN: read-side property on write operation
      invalidation: EXACT        # OK: write-side property
```

**Good:**
```yaml
- storage:
    operation: put
    url: s3_data
    path: "config/settings.json"
    data: =new_settings
    cacheHint:
      invalidation: EXACT
      invalidatePaths: ["config/cache-key.json"]
```

---

#### SA-STORAGE-21 -- Write-side `cacheHint:` properties on read operation 📋 WARN

A `storage` step performing a read operation (`get`, `info`, `exists`, `list`) specifies write-side `cacheHint:` properties (`invalidation`, `invalidatePaths`). These properties apply only to write operations.

**Warn:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "config/settings.json"
    cacheHint:
      ttl: 5m                   # OK: read-side property
      invalidation: PREFIX       # WARN: write-side property on read operation
```

**Good:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "config/settings.json"
    cacheHint:
      ttl: 5m
      revalidation: CONDITIONAL
```

---

#### SA-STORAGE-22 -- `negativeTtl:` without `negative: true` 📋 WARN

A `cacheHint:` specifies `negativeTtl:` but `negative:` is absent or `false`. The `negativeTtl:` value has no effect without negative caching enabled.

**Warn:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "config/settings.json"
    cacheHint:
      ttl: 5m
      negativeTtl: 30s          # WARN: negativeTtl without negative: true
```

**Good:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "config/settings.json"
    cacheHint:
      ttl: 5m
      negative: true
      negativeTtl: 30s
```

---

#### SA-STORAGE-23 -- `staleWhileRevalidate:` with `revalidation: NEVER` 📋 WARN

A `cacheHint:` specifies `staleWhileRevalidate:` alongside `revalidation: NEVER`. Stale-while-revalidate implies background revalidation, which contradicts `NEVER` (serve from cache without revalidation until TTL expires).

**Warn:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "data/snapshot.json"
    cacheHint:
      ttl: 1h
      revalidation: NEVER
      staleWhileRevalidate: 5m   # WARN: contradicts revalidation: NEVER
```

**Good:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "data/snapshot.json"
    cacheHint:
      ttl: 1h
      revalidation: CONDITIONAL
      staleWhileRevalidate: 5m
```

---

#### SA-STORAGE-24 -- `warm: true` on streaming `get` with `onYield:` 📋 INFO

A `storage` step with `operation: get`, `onYield:`, and `cacheHint: { warm: true }`. Warm pre-fetching loads the full content into cache memory at flow load time, which may consume significant memory for large files that the author intended to stream.

**Info:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "data/large-export.csv"
    onYield: { chunk: =YIELD }
    cacheHint:
      ttl: 1h
      warm: true                 # INFO: warm + streaming may use significant memory
```

---

#### SA-STORAGE-25 -- `cacheHint:` on `async: true` read operation 📋 WARN

A `storage` step with a read operation (`get`, `info`, `exists`, `list`) has both `async: true` and `cacheHint:`. When `async: true`, the result is discarded (fire-and-forget), so caching the result serves no purpose.

**Warn:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "data/snapshot.json"
    async: true
    cacheHint: "5m"              # WARN: result discarded, caching pointless
```

**Good:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "data/snapshot.json"
    async: true
    # No cacheHint — async read discards result
```

---

#### SA-STORAGE-26 -- `readYourWrites: true` with `async: true` 📋 WARN

A `storage` step has both `cacheHint: { readYourWrites: true }` and `async: true`. Read-your-writes consistency requires the engine to track write completion and invalidate cache entries synchronously, which contradicts fire-and-forget semantics.

**Warn:**
```yaml
- storage:
    operation: put
    url: s3_data
    path: "config/settings.json"
    data: =new_settings
    async: true
    cacheHint:
      readYourWrites: true       # WARN: contradicts async: true
      invalidation: EXACT
```

**Good:**
```yaml
- storage:
    operation: put
    url: s3_data
    path: "config/settings.json"
    data: =new_settings
    async: true
    cacheHint:
      invalidation: EXACT        # Write-side invalidation is valid with async
```

---

#### SA-STORAGE-27 -- `scope: GLOBAL` without `varyBy:` 📋 INFO

A `cacheHint:` specifies `scope: GLOBAL` without a `varyBy:` expression. Global cache entries are shared across all flow instances (within the tenant boundary). Without `varyBy:`, all instances share the same cache entry for a given path, which may cause cross-context data leakage if the flow processes tenant-specific or user-specific data.

**Info:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "config/global-settings.json"
    cacheHint:
      ttl: 1h
      scope: GLOBAL              # INFO: no varyBy — all instances share this entry
```

**Good (when partitioning is needed):**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "config/global-settings.json"
    cacheHint:
      ttl: 1h
      scope: GLOBAL
      varyBy: =ENV.REGION        # Partition by region
```

---

#### SA-STORAGE-28 -- `cacheHint:` TTL exceeds 1 hour 📋 INFO

A `cacheHint:` specifies a `ttl:` value greater than 1 hour (3600 seconds / 3600000 milliseconds). Long TTLs increase the risk of serving stale data. This is informational — long TTLs are valid for rarely-changing data (e.g., configuration files).

**Info:**
```yaml
- storage:
    operation: get
    url: s3_data
    path: "config/settings.json"
    cacheHint:
      ttl: 24h                  # INFO: TTL exceeds 1 hour
```

---

#### SA-STORAGE-29 -- `invalidatePaths:` with user-controlled input 📋 WARN

A `cacheHint:` specifies `invalidatePaths:` containing CEL expressions that reference flow input parameters, `EVENT.DATA.*`, or `CONTEXT.*` values. User-controlled invalidation paths could be used to evict arbitrary cache entries within the tenant scope, causing cache pollution or denial-of-service through excessive cache misses.

**Warn:**
```yaml
- storage:
    operation: put
    url: s3_data
    path: "uploads/report.csv"
    data: =report_data
    cacheHint:
      invalidation: EXACT
      invalidatePaths:
        - ="cache/" + user_input  # WARN: user-controlled invalidation path
```

**Good:**
```yaml
- storage:
    operation: put
    url: s3_data
    path: "uploads/report.csv"
    data: =report_data
    cacheHint:
      invalidation: EXACT
      invalidatePaths:
        - "cache/reports-index.json"   # Static invalidation path
```

---

#### SA-STORAGE-30 -- `readYourWrites: true` on `transfer` operation 📋 WARN

A `storage` step with `operation: transfer` has `cacheHint: { readYourWrites: true }`. Transfer involves two different storage endpoints (source and target), making read-your-writes semantics ambiguous — it is unclear which endpoint's cache should be affected.

**Warn:**
```yaml
- storage:
    operation: transfer
    source: { url: s3_data, path: "data/export.csv" }
    target: { url: backup_sftp, path: "/backups/export.csv" }
    cacheHint:
      readYourWrites: true       # WARN: ambiguous on cross-storage transfer
      invalidation: EXACT
```

**Good:**
```yaml
- storage:
    operation: transfer
    source: { url: s3_data, path: "data/export.csv" }
    target: { url: backup_sftp, path: "/backups/export.csv" }
    cacheHint:
      invalidation: EXACT        # Clear: invalidate target-side cache entries
```

---

#### SA-STORAGE-31 -- Symlink TOCTOU in local storage operations 📋 WARN

Storage operation targets a `file://` backend AND the operation is `put`, `delete`, `move`, or `copy`. Local filesystem storage operations are subject to symlink TOCTOU (time-of-check-to-time-of-use) race conditions — between path validation and file access, a symlink could be created that redirects the operation outside the storage root. Engine must resolve symlinks atomically. Flags `file://` storage operations without `O_NOFOLLOW` or atomic path resolution, vulnerable to symlink race conditions.

> **Rationale:** CWE-367 (Time-of-check Time-of-use Race Condition). Symlink TOCTOU detection for local filesystem storage operations.

#### SA-STORAGE-32 -- `storage.url` CEL expression references user-controlled input without URL validation ✅ ERROR

`storage.url` is a CEL expression that references user-controlled input (flow input parameters, `EVENT.DATA`, `YIELD` values) without URL validation. User-controlled storage URLs enable Server-Side Request Forgery (SSRF) — an attacker can redirect storage operations to internal network endpoints, cloud metadata services, or arbitrary hosts. Engines MUST validate resolved URLs against scheme allowlists, private IP range denials, and DNS rebinding protections before opening any storage connection.

> **Rationale:** CWE-918 (Server-Side Request Forgery). Mirrors the SSRF prevention requirements applied to `request.url` (§4.3 of the specification).

---

### 1.21 SSH (SA-SSH-\*)

#### SA-SSH-1 -- `ssh.command` contains shell metacharacters ✅ ERROR

SSH commands are interpreted by the remote shell. Shell metacharacters (`|`, `>`, `<`, `&&`, `||`, `;`, `$()`, backticks, `\n`) in `command:` indicate an attempt to construct a shell pipeline, which bypasses the engine's POSIX escaping for `args:`. Use separate `ssh` steps or restructure the command.

---

#### SA-SSH-2 -- `ssh.command` not in `requires.SSH` allowlist ✅ ERROR

Every `ssh.command` must appear in the per-alias or per-host allowlist defined in `requires.SSH.<alias-or-host>`.

**Bad:**
```yaml
flowmarkup:
  requires:
    SSH:
      prod: [rsync, df]
  do:
    - ssh:
        host: prod
        command: curl    # ERROR: curl not in allowlist [rsync, df]
```

---

#### SA-SSH-3 -- SSH allowlist contains interpreter names ✅ ERROR

The `requires.SSH` allowlist contains interpreter names (`python3`, `python`, `node`, `ruby`, `bash`, `sh`, `zsh`, `fish`, `dash`, `ksh`, `csh`, `tcsh`, `perl`, `php`, `cmd`, `cmd.exe`, `powershell`, `pwsh`, `powershell.exe`). Same as SA-EXEC-10 — effectively grants arbitrary remote code execution. Shell interpreters are unconditionally rejected; scripting interpreters require engine-level acknowledgment.

---

#### SA-SSH-4 -- `requires: { SSH: INHERIT }` invalid ✅ ERROR

`INHERIT` is not valid on `requires:`. Flows MUST declare specific aliases or hostnames with command allowlists. `INHERIT` is only valid on `cap:` (the forwarding side).

---

#### SA-SSH-5 -- `cap: { SSH: INHERIT }` on `run` step 📋 WARN

Passing through caller's SSH capability to a sub-flow.

---

#### SA-SSH-6 -- `ssh` without `timeout:` 📋 WARN

Remote commands may hang indefinitely. Always set a timeout.

---

#### SA-SSH-7 -- `ssh.stdin` with `async: true` 📋 INFO

With `async: true`, stdin delivery is fire-and-forget; the remote process may not receive all data.

---

#### SA-SSH-8 -- `exitCode` in result AND `SshError` in `errors` 📋 WARN

When `exitCode` is mapped in `result:`, `SshError` is never auto-thrown. Listing `SshError` in `errors:` is dead code.

---

#### SA-SSH-9 -- `ssh.command` contains path separator 📋 WARN

Use bare executable names, not paths. Remote `PATH` should resolve the command.

---

#### SA-SSH-10 -- `ssh.args:` contains dynamically constructed values from untrusted input 📋 WARN

An `ssh` step has `args:` entries that incorporate flow input parameters, `EVENT.DATA.*`, or `CONTEXT.*` values without prior validation. Remote command arguments are escaped by the engine (POSIX single-quote escaping), but the remote command itself may interpret arguments in dangerous ways (e.g., `rsync --rsh` flag injection).

**Warn:**
```yaml
- ssh:
    host: prod
    command: rsync
    args: [=user_provided_path, '/backup/']   # WARN: user input in ssh args without validation
```

**Good:**
```yaml
- assert:
    condition: "=user_provided_path.matches('^[a-zA-Z0-9/_.-]+$')"
    message: "Invalid path"
- ssh:
    host: prod
    command: rsync
    args: ['--', =user_provided_path, '/backup/']   # OK: validated + end-of-options marker
```

---

#### SA-SSH-11 -- `ssh.env:` contains secret values without `SECRET.*` 📋 WARN

An `ssh` step passes sensitive-looking environment variable values (matching credential patterns: `*_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_CREDENTIAL`, `*_CREDENTIALS`, `*_API_KEY`, `*_APIKEY`, `*_AUTH`, `*_PRIVATE_KEY`, `*_CONN_STRING`, `*_CONNECTION_STRING`, `*_DSN`, `*_PASSPHRASE`, `*_PIN`) using `ENV.*` instead of `SECRET.*`. Use `SECRET.*` for proper opacity and mandatory redaction.

**Warn:**
```yaml
- ssh:
    host: prod
    command: deploy
    env:
      API_KEY: =ENV.API_KEY           # WARN: credential in ENV — use SECRET.*
```

**Good:**
```yaml
- ssh:
    host: prod
    command: deploy
    env:
      API_KEY: =SECRET.api_key        # OK: opaque SecretValue
```

---

#### SA-SSH-12 -- `ssh.env:` value derived from user-controlled input ✅ ERROR

`ssh.env` key or value derived from CEL expressions referencing user-controlled input (flow input parameters, `EVENT.DATA`, `YIELD` values) without validation against the denied environment variable list (`LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, `PATH`, `HOME`, `SHELL`, `IFS`, `CDPATH`, `ENV`, `BASH_ENV`, `PYTHONPATH`, `NODE_PATH`, `RUBYLIB`, `PERL5LIB`, `CLASSPATH`). Same rationale as SA-EXEC-13 but for SSH remote execution environments. Attacker-controlled environment variables can manipulate process behavior or inject credentials into remote process environments. Validate value content and restrict sensitive variable names.

> **Rationale:** CWE-426 (Untrusted Search Path). User-controlled `ssh.env` values can override security-critical environment variables on remote hosts.

---

#### SA-SSH-13 -- `ssh.host:` derived from user-controlled input 📋 ERROR

User-controlled SSH host enables connections to arbitrary hosts if runtime enforcement is incomplete. *(CWE-918)*

---

#### SA-SSH-14 -- `ssh.args:` value may begin with `-` (argument injection) 📋 WARN

`ssh.args:` value may begin with `-` (argument injection). Mirrors SA-EXEC-12 for SSH context. Flags injection vectors such as `--exec`, `--rsh`, `--upload-pack` on commands like `rsync` and `git`. *(CWE-88: Improper Neutralization of Argument Delimiters)*

---

### 1.22 XML (SA-XML-\*)

#### SA-XML-1 -- `xpath()`/`xpathAll()` on parsed MAP variable ✅ WARN

`xpath()` and `xpathAll()` operate on raw XML strings, not parsed MAPs. Calling these on a variable with `$kind: XML` or `$kind: MAP` will fail at runtime because the value has already been parsed into a MAP structure.

**Bad:**
```yaml
vars:
  data:
    $kind: XML
    $value: "=response.decode(XML)"
do:
  - set:
      name: "=data.xpath('/root/name')"    # WARN: data is MAP, not raw XML string
```

**Good:**
```yaml
vars:
  xml_raw:
    $kind: TEXT
    $value: =response
do:
  - set:
      name: "=xml_raw.xpath('/root/name')"  # Correct: raw string for XPath
```

---

#### SA-XML-2 -- `encode(XML)` on MAP with != 1 top-level key ✅ ERROR

`encode(XML)` requires the input MAP to have exactly one top-level key (the root element name). Zero or multiple keys produce an ambiguous root element.

**Bad:**
```yaml
- set:
    xml_out: "={'name': 'Alice', 'email': 'alice@example.com'}.encode(XML)"  # ERROR: 2 top-level keys
```

**Good:**
```yaml
- set:
    xml_out: "={'person': {'name': 'Alice', 'email': 'alice@example.com'}}.encode(XML)"  # 1 root key
```

---

#### SA-XML-3 -- `xpath()`/`xpathAll()` expression constructed from user input ✅ ERROR

xpath()/xpathAll() expression concatenates variables originating from input:, EVENT.DATA, RESULT, or any variable without $trusted: true annotation. Use xpathParam() for safe parameterized XPath queries, or prefer decode(XML) with MAP access to eliminate injection risk entirely.

**Error:**
```yaml
- set:
    name: "=xml_data.xpath('//user[@id=\"' + user_input + '\"]')"   # ERROR: XPath injection risk — unvalidated user input
```

**Good:**
```yaml
- assert:
    condition: "=user_input.matches('^[a-zA-Z0-9_-]+$')"
    message: "Invalid user ID format"
- set:
    name: "=xml_data.xpath('//user[@id=\"' + user_input + '\"]')"   # OK: validated input
```

---

#### SA-XML-4 -- `xpath()`/`xpathAll()` used where `decode(XML)` with MAP access would suffice 📋 INFO

`xpath()` and `xpathAll()` evaluate XPath 1.0 expressions, which lack parameterized queries. When accessing known, fixed paths in XML data, prefer `decode(XML)` with MAP property access — this avoids XPath injection risk entirely and produces simpler, more readable CEL expressions.

---

### 1.23 CSV (SA-CSV-\*)

#### SA-CSV-1 -- CSV resource or `parseAs: CSV` result contains formula-prefix characters 📋 WARN

CSV resource, `parseAs: CSV` result, or `encode(CSV)` output contains cell values starting with formula-prefix characters (`=`, `+`, `@`, `-`). When downstream consumers are spreadsheet applications, these values may be interpreted as formulas, enabling CSV injection attacks (CWE-1236). The engine MUST sanitize formula-prefix characters in CSV output (see ENGINE §5.4 item 46).

---

#### SA-CSV-2 -- String literal with formula-prefix character assigned to CSV-consumed variable 📋 WARN

String literal value starting with =, +, @, or - assigned to a variable or action parameter where the downstream consumer is a CSV/TSV encoder or a service known to produce spreadsheet output.

---

### 1.24 Sub-Flow / Run (SA-RUN-\*)

#### SA-RUN-1 -- `cap:` grants unavailable capability 📋 ERROR

Cannot escalate capabilities the calling flow doesn't have.

---

#### SA-RUN-2 -- Remote `run:` without `integrity:` ✅ ERROR

Remote sub-flow reference with no content verification. Without `integrity:`, the fetched flow content is not hash-verified and may have been tampered with. A compromised remote flow executes with the caller's granted capabilities — including secrets, exec, and network access. This is an ERROR because the risk of executing unverified remote code outweighs the convenience of omitting integrity hashes.

---

#### SA-RUN-3 -- *(Removed — consolidated into SA-RUN-2)*

---

#### SA-RUN-4 -- `cap: INHERIT` on remote `run:` without `integrity:` 📋 WARN

Grants full caller capabilities to a remote sub-flow without content verification. An unverified flow with inherited capabilities can access secrets, execute commands, and send requests.

**Good:** add `integrity: "sha256-..."` and use minimal `cap:`.

---

#### SA-RUN-5 -- Flow `requires:` not satisfied by caller 📋 ERROR

A sub-flow's declared `requires:` must be satisfied by the calling flow's effective capabilities.

---

#### SA-RUN-6 -- *(Removed — consolidated into SA-RUN-5)*

---

#### SA-RUN-7 -- *(Removed — consolidated into SA-RUN-5)*

---

#### SA-RUN-8 -- `async: true` with context write capability 📋 WARN

Context writes from async sub-flows are never propagated back to the caller.

---

#### SA-RUN-9 -- `flow: CURRENT` without condition guard 📋 WARN

Unconditional self-invocation always recurses -- infinite loop.

**Good:** add `condition: "CONTEXT.remaining > 0"`

---

#### SA-RUN-10 -- `?ref=` on non-git flow reference 📋 WARN

The `?ref=` query parameter is only meaningful for git-backed flow references.

---

#### SA-RUN-11 -- Mutable `?ref=` without `integrity:` 📋 WARN

A mutable ref (branch name or tag) can change. Pin with `integrity:` or use a commit SHA.

---

#### SA-RUN-12 -- Git-backed URL without `?ref=` 📋 INFO

Fetches from the repository's default branch. Content may change across restarts.

---

#### SA-RUN-13 -- `cap: { GLOBAL: WRITE }` or `READ_WRITE` on `run` step 📋 WARN

Granting global variable write access to a sub-flow. A sub-flow with GLOBAL write access can modify engine-wide shared state, affecting all other flows.

---

#### SA-RUN-14 -- `cap: { CONTEXT: WRITE }` or `READ_WRITE` on `run` step 📋 WARN

Granting context variable write access to a sub-flow. A sub-flow with CONTEXT write access can corrupt the execution chain's context (`correlation_id`, `tenant_id`, etc.).

---

#### SA-RUN-15 -- `cap: { SUBFLOWS: CROSS_ORIGIN }` on run step ✅ WARN

Granting `CROSS_ORIGIN` sub-flow capability creates a transitive trust chain — the sub-flow can invoke further sub-flows from any origin.

---

#### SA-RUN-16 -- SUBFLOWS object contradictory (`CROSS_ORIGIN: true`, `SAME_ORIGIN: false`) ✅ WARN

A SUBFLOWS capability object with `CROSS_ORIGIN: true` and `SAME_ORIGIN: false` is contradictory — `CROSS_ORIGIN` implies `SAME_ORIGIN`.

---

#### SA-RUN-17 -- *(Removed — consolidated into SA-RUN-15)*

---

#### SA-RUN-18 -- `async: true` with security-sensitive capabilities 📋 WARN

A sub-flow invoked with `async: true` has security-sensitive capabilities (`SECRET`, `EXEC`, `MAIL`, or `REQUEST`). Errors from fire-and-forget steps — including `MissingCapabilityError`, `SecretAccessError`, and `AuthenticationError` — are logged but not propagated to the caller. Security violations in async sub-flows are invisible to the calling flow.

> **Advisory:** Async sub-flows with `SECRET` or `EXEC` capabilities represent the highest-risk scenarios for invisible security violations. Flow authors should prefer synchronous invocation for these capabilities, or ensure comprehensive audit log monitoring is in place (see ENGINE.md §5.4 item 18).

**Warn:**
```yaml
- run:
    flow: "send-notification.flowmarkup.yaml"
    async: true
    cap:
      SECRET: [api_token]          # WARN: security-sensitive capability on async step
      REQUEST: INHERIT               # WARN: security-sensitive capability on async step
```

**OK:**
```yaml
# Non-security-sensitive async (metrics, cache warming)
- run:
    flow: "update-cache.flowmarkup.yaml"
    async: true                     # OK: no security-sensitive capabilities
```

---

#### SA-RUN-19 -- Remote `run:` with security-sensitive capabilities and no `integrity:` 📋 ERROR

A remote `run:` step grants security-sensitive capabilities (`SECRET`, `EXEC`, `SSH`, `MAIL`) via `cap:` AND has no `integrity:` hash. When a remote flow receives security-sensitive capabilities, content verification is mandatory. A MITM or DNS attack on the remote flow fetch yields arbitrary code execution with access to the caller's secrets.

**Bad:**
```yaml
- run:
    flow: "https://example.com/utils/send-email.flowmarkup.yaml"
    cap:
      SECRET: [smtp_password]        # ERROR: security-sensitive cap without integrity
      MAIL: true
```

**Good:**
```yaml
- run:
    flow: "https://example.com/utils/send-email.flowmarkup.yaml"
    integrity: sha256-abc123...
    cap:
      SECRET: [smtp_password]        # OK: integrity verified
      MAIL: true
```

---

#### SA-RUN-20 -- `async: true` sub-flow writes to shared `GLOBAL.*` keys 📋 WARN

An async sub-flow writes to `GLOBAL.*` keys that the parent flow or other concurrent async sub-flows also write. Multiple concurrent async sub-flows writing to the same `GLOBAL` key have no guaranteed ordering — last writer wins. This may cause data races.

**Warn:**
```yaml
- run:
    flow: "update-metrics.flowmarkup.yaml"
    async: true
    cap: { GLOBAL: WRITE }             # WARN: parent also writes GLOBAL.request_count
```

**Note:** `GLOBAL.*` is NOT snapshotted for async sub-flows (unlike `CONTEXT.*` which is deep-cloned). Async sub-flows read and write the live global store. The engine does not guarantee ordering of concurrent `GLOBAL` writes — last writer wins.

---

#### SA-RUN-21 -- Sub-flow with received capabilities and SUBFLOWS 📋 WARN

Sub-flow that receives capabilities via `cap:` also declares `requires: { SUBFLOWS: true }`. This sub-flow may transitively forward received capabilities to further sub-flows that the original caller never authorized. Consider using explicit `cap:` grants instead of `INHERIT`. *(CWE-269)*

---

### 1.25 Services (SA-SVC-\*)

#### SA-SVC-1 -- Alias conflicts with engine-provisioned service 📋 ERROR

A key in `flowmarkup.services:` that matches an engine-registered alias raises `ConfigurationError`.

---

#### SA-SVC-2 -- `call.operation:` literal not in `meta.operations` 📋 WARN

`operation:` is **required** on every `call` step. When a bare literal and the provider is known, the value must appear in the provider's operations list.

**Bad:**
```yaml
- call:
    service: db
    operation: upsert         # WARN: not in db's operations list
```

---

#### SA-SVC-3 -- `services.*.properties` references undeclared secret 📋 WARN

A string value in `properties:` references `SECRET.<name>` not in `requires.SECRET`.

---

#### SA-SVC-4 -- `services.*.properties` references flow/context/global variable 📋 ERROR

Properties are evaluated at lazy init before the flow body runs. Only `ENV.*`, `SECRET.*`, and literals are in scope.

---

#### SA-SVC-5 -- `requires.SERVICES` key matches flow-defined alias 📋 INFO

Valid but unusual -- `requires:` typically declares external dependencies.

---

#### SA-SVC-6 -- `cap.SERVICES` remapping value not in parent's caps 📋 ERROR

Granting a service the calling flow doesn't have is privilege escalation.

---

#### SA-SVC-7 -- *(Removed — consolidated into SA-SVC-6)*

---

#### SA-SVC-8 -- Write to `SERVICES.*` scope 📋 ERROR

The `SERVICES` binding is read-only.

---

#### SA-SVC-9 -- Inline CEL service call alias not declared ✅ WARN

> **See SA-CAP-1** (section 1.11). When a CEL expression contains `SERVICES.<alias>.<operation>(...)`, the alias should appear in `requires.SERVICES` or `services:`.

---

#### SA-SVC-10 -- Inline service call in loop condition or iteration source 📋 INFO

A direct service call in `while.condition:`, `forEach.items:`, etc. executes on every iteration with no retry or circuit-breaker protection.

**Good:** cache the result in a `call:` step with `retry:` before the loop.

---

#### SA-SVC-11 -- Non-conforming service provider ID format 📋 ERROR

Service provider ID does not match the required format: `^[a-z][a-z0-9]*([._-][a-z][a-z0-9]*)*$` (reverse-DNS-like, allows hyphens and underscores). Maximum length: 255 characters.

**Bad:**
```yaml
services:
  db:
    provider: MyCustomProvider        # ERROR: uppercase letters
```

**Good:**
```yaml
services:
  db:
    provider: progralink.clients.db.postgres    # OK: reverse-DNS format
```

---

### 1.26 Streaming / Yield (SA-YIELD-\*)

#### SA-YIELD-1 -- `yield` inside `finally:` ✅ WARN

The yield stream may already be closed when `finally:` runs.

---

#### SA-YIELD-2 -- `yields:` declared but no `yield` steps ✅ WARN

The `yields:` contract is dead code. Exception: `onYield: FORWARD` steps count as implicit producer.

---

#### SA-YIELD-3 -- `onYield` + `async: true` ✅ ERROR

Async fire-and-forget produces no streaming results.

---

#### SA-YIELD-4 -- `onYield` + `retry:` ✅ ERROR

Retrying a streaming step re-delivers yields; results are undefined.

---

#### SA-YIELD-5 -- `yield` inside `onTimeout:` handler ✅ ERROR

The handler runs concurrently and cannot safely access the yield channel.

---

#### SA-YIELD-6 -- `yield` inside `lock` body ✅ ERROR

Yielding while holding a lock causes a deadlock cycle: the consumer blocks on the lock, the producer blocks on backpressure.

---

#### SA-YIELD-7 -- `yield` steps without `yields:` declaration ✅ WARN

Callers cannot discover the streaming contract.

---

#### SA-YIELD-8 -- Form mismatch between `yield` and `yields:` 📋 ERROR

Single-value `yield: expr` requires `yields: { $kind: ... }`. Multi-value requires `yields: { params: ... }`.

---

#### SA-YIELD-9 -- `SECRET.*` in `yield` value expression ✅ ERROR

Secrets must not be yielded.

---

#### SA-YIELD-10 -- `yield` inside RACE branch ✅ ERROR

Losing branches are cancelled, but yields already sent are permanent and cannot be retracted.

**Bad:**
```yaml
- race:
    fast:
    - yield: 'fast_result'   # ERROR: if slow wins, this yield is already sent
    slow:
    - yield: 'slow_result'
```

**Good:** yield after RACE completes.

---

#### SA-YIELD-11 -- `onYield: FORWARD` without `yields:` on containing flow ✅ WARN

Forwarded values have no consumer.

---

#### SA-YIELD-12 -- `yield` inside `transaction: true` group 📋 INFO

Yields are buffered until commit and discarded on rollback. Correct but may surprise authors.

---

#### SA-YIELD-13 -- `onYield` on a step whose target never yields 📋 INFO

The handler will never fire if the target has no `yields:` declaration.

---

#### SA-YIELD-14 -- Target yields but caller discards 📋 WARN

A step invoking a target that declares `yields:`, with neither `onYield:` nor `$yields`, silently discards all yielded values.

---

#### SA-YIELD-15 -- `$yields` in output but target has no `yields:` 📋 INFO

The variable will receive an empty list.

---

#### SA-YIELD-16 -- `onYield:` and `$yields` in result on same step ✅ ERROR

Mutually exclusive consumption modes.

---

#### SA-YIELD-17 -- `$yields` in result + `async: true` ✅ ERROR

Async fire-and-forget produces no streaming results.

---

#### SA-YIELD-18 -- `$yields` in result + `retry:` ✅ ERROR

Retrying produces duplicate or partial entries in the materialized list.

---

#### SA-YIELD-19 -- `onYield:` on `mail` step ✅ ERROR

`mail` does not produce streaming results. `onYield:` is only valid on `call`, `run`, `exec`, `ssh`, and `request`.

---

#### SA-YIELD-20 -- `onYield: FORWARD` inside RACE branch ✅ ERROR

Same spurious-yield problem as SA-YIELD-10, but via the FORWARD shorthand.

---

#### SA-YIELD-21 -- `yield` inside `rollback:` handler ✅ ERROR

Rollback handlers compensate for external side effects during error recovery. `yield` in this context would produce values during a failure path, which is semantically invalid. Inside `transaction: true`, buffered yields are discarded on rollback, so any yield in a rollback handler would be lost.

**Bad:**
```yaml
- call:
    service: payments
    operation: charge
    rollback:
      - yield: =partial_result   # ERROR: yield inside rollback
      - call: { service: payments, operation: refund }
```

---

### 1.27 Rollback / Transaction (SA-ROLLBACK-\*)

#### SA-ROLLBACK-1 -- `rollback:` on `async: true` step 📋 ERROR

> **See SA-ASYNC-1** (section 1.16). Fire-and-forget has no error propagation; rollback unreachable.

---

#### SA-ROLLBACK-2 -- `rollback:` handler references undefined variables 📋 WARN

Variables not produced by the step's `result:` or enclosing scope may be undefined at rollback time.

---

#### SA-ROLLBACK-3 -- `transaction: true` inside `lock EXCLUSIVE` (same scope) 📋 WARN

Redundant buffering for `GLOBAL.*`/`CONTEXT.*` -- `lock EXCLUSIVE` already buffers those scopes.

---

#### SA-ROLLBACK-4 -- No-op `transaction: true` 📋 WARN

A `transaction: true` group (or root-level `transaction:`) with no variable writes and no `rollback:` blocks has no observable effect.

---

#### SA-ROLLBACK-5 -- `rollback:` without enclosing `transaction: true` 📋 INFO

Rollback handlers fire on error, but variables are NOT rolled back (no scope forking). External compensation only.

---

#### SA-ROLLBACK-6 -- `return` inside `transaction: true` group 📋 INFO/WARN

With `locking: PESSIMISTIC` (default): info. With `locking: OPTIMISTIC`: warn -- `ConflictError` at commit time causes `RolledBackError`.

---

#### SA-ROLLBACK-7 -- `rollback:` handler without `retry:` 📋 INFO

Rollback handlers should be resilient to transient failures. Add `retry:` for durability.

---

#### SA-ROLLBACK-8 -- `rollback:` in branches with cancellation risk 📋 WARN

Cancelled branches' registered rollback handlers are silently discarded. Applies to PARALLEL with `failPolicy: FAST` and RACE branches.

**Good:** use `failPolicy: COMPLETE` on the group.

---

#### SA-ROLLBACK-9 -- `onRollbackError:` without `rollback:` blocks 📋 WARN

`onRollbackError:` only affects rollback handlers; without any `rollback:` blocks, it is a no-op.

---

#### SA-ROLLBACK-10 -- `emit` inside `transaction: true` group 📋 INFO

Events are buffered until commit and discarded on rollback. Correct behavior, but external consumers may be surprised.

---

### 1.28 Transaction Isolation (SA-ISO-\*)

#### SA-ISO-1 -- `wait`/`waitFor`/`waitUntil` inside `transaction: true` 📋 WARN

Blocking steps extend lock hold duration (PESSIMISTIC) or delay conflict detection (OPTIMISTIC).

---

#### SA-ISO-2 -- Two concurrent transaction groups with overlapping access 📋 WARN

With PESSIMISTIC: serialized but slow. With OPTIMISTIC: `ConflictError` likely.

---

#### SA-ISO-3 -- Transaction group with dynamic variable access 📋 WARN

`global[key_var]` inside PESSIMISTIC transaction triggers scope-wide exclusive lock.

---

#### SA-ISO-4 -- `lock` directive inside `transaction: true` (or vice versa) 📋 WARN

Named locks and per-variable locks can form cross-mechanism deadlock cycles.

---

#### SA-ISO-5 -- Nested `lock` directives with reversed static name ordering 📋 WARN

Inner lock name lexicographically less than outer. Another flow acquiring in opposite order deadlocks.

**Good:** always acquire locks in lexicographic order.

---

#### SA-ISO-6 -- `locking:` without `transaction` 📋 WARN

`locking:` only applies when `transaction:` is set (on a `group:` or at the flow root); without `transaction:`, it is a no-op.

---

#### SA-ISO-7 -- Nested `transaction: true` with different `locking:` mode 📋 WARN

Inner transaction groups inherit the outer transaction's locking mode (whether the outer is a `group:` or the flow root's implicit group); the inner `locking:` is ignored.

---

#### SA-ISO-8 -- Scope-specific `transaction` with writes to non-forked scopes 📋 ERROR

`transaction: "GLOBAL"` forks only the global scope. Writes to other scopes are NOT rolled back. Applies to both `group:` and root-level `transaction:`.

**Bad:**
```yaml
- group:
    transaction: "GLOBAL"
    do:
      - set:
          GLOBAL.balance: GLOBAL.balance - amount    # rolled back ✓
          CONTEXT.last_op: 'debit'                   # ERROR: NOT rolled back ✗
```

Also triggers at the flow root:
```yaml
flowmarkup:
  transaction: "GLOBAL"
  do:
  - set:
      GLOBAL.balance: =GLOBAL.balance - amount    # rolled back ✓
      CONTEXT.last_op: debit                      # ERROR: NOT rolled back ✗
```

---

### 1.29 Events (SA-EMIT-\*, SA-EVENT-\*)

#### SA-EMIT-1 -- `emit.event` or `waitFor.event` not snake_case 📋 ERROR

Event types must match `^[a-z][a-z0-9_]*$`. They are static identifiers.

**Bad:** `event: "order_type + '_done'"` -- **Good:** `event: order_done`

---

#### SA-EMIT-2 -- Unmatched `waitFor` (LOCAL scope) 📋 WARN

A `waitFor` with no matching `emit` in the same flow may hang at runtime.

---

#### SA-EMIT-3 -- Unmatched `emit` (LOCAL scope) 📋 WARN

An `emit` with no matching `waitFor` in the same flow. The event is buffered but never consumed.

---

#### SA-EMIT-4 -- `waitFor` with `scope: GLOBAL`/`CONTEXT` and no `timeout:` 📋 WARN

> **Unbounded wait pattern.** Without a timeout, the flow may block indefinitely. See also SA-WAIT-1, SA-LOCK-1, SA-TIMEOUT-1.

---

#### SA-EVENT-1 -- emit references undeclared event type ✅ WARN

`emit` references an event type not declared in `events:`.

---

#### SA-EVENT-2 -- waitFor references undeclared event type ✅ WARN

`waitFor` references an event type not declared in `events:`.

---

#### SA-EVENT-3 -- emit.data missing required field ✅ ERROR

`emit.data` is missing a field declared as required in `events:`.

---

#### SA-EVENT-4 -- emit.data contains undeclared field ✅ WARN

`emit.data` contains a field not declared in the event contract.

---

#### SA-EVENT-5 -- waitFor.capture value references undeclared event data field ✅ WARN

`waitFor.capture` maps to a field not declared in the event contract.

---

#### SA-EVENT-6 -- trigger event type not declared in events: ✅ WARN

A `triggers:` entry with `event:` references an event type not in `events:`.

---

#### SA-EVENT-7 -- event declared but never referenced ✅ INFO

An event type is declared in `events:` but never used in `emit`, `waitFor`, or `triggers`.

---

#### SA-EVENT-8 -- EVENT.field instead of EVENT.DATA.field ✅ ERROR

A CEL expression accesses `EVENT.<key>` where key is not `TYPE`, `DATA`, or `SOURCE`. Common mistake: `EVENT.order_id` instead of `EVENT.DATA.order_id`.

**Bad:**
```yaml
- waitFor:
    event: order_placed
    condition: "EVENT.order_id == expected_id"   # ERROR: use EVENT.DATA.order_id
```

---

#### SA-EVENT-9 -- GLOBAL-scope `emit`/`waitFor` without `events:` declaration ✅ ERROR

A flow emits or listens for events with `scope: GLOBAL` but does not declare the event type in the flow-level `events:` block. GLOBAL-scope events are visible across all flow instances within the same tenant. Requiring explicit declaration prevents unauthorized flows from injecting events into the global event bus or eavesdropping on events intended for other flows.

**Bad:**
```yaml
flowmarkup:
  title: "Order Processor"
  # no events: declaration
  do:
    - emit:
        event: payment_received
        scope: GLOBAL              # ERROR: GLOBAL event not declared in events:
        data: { order_id: =order.id }
```

**Good:**
```yaml
flowmarkup:
  title: "Order Processor"
  events:
    payment_received:
      _notes_: "Emitted when payment is confirmed"
      data:
        order_id: STRING
  do:
    - emit:
        event: payment_received
        scope: GLOBAL              # OK: declared in events:
        data: { order_id: =order.id }
```

---

#### SA-EVENT-10 -- CONTEXT-scope `emit`/`waitFor` without `events:` declaration 📋 ERROR/WARN

SA-EVENT-10 (ERROR when flow declares requires: with SECRET, EXEC, SSH, or STORAGE capabilities; WARN otherwise): Undeclared CONTEXT-scope emit/waitFor. Flows with security-sensitive capabilities MUST declare all CONTEXT-scope event types to prevent eavesdropping.

A flow emits or listens for events with `scope: CONTEXT` but does not declare the event type in the flow-level `events:` block. CONTEXT-scope events are visible to the calling flow and its sub-flows within the same execution chain. When the flow holds security-sensitive capabilities (SECRET, EXEC, SSH, STORAGE), undeclared CONTEXT-scope events allow sub-flows to eavesdrop on parent coordination events or inject unexpected events, making this an ERROR. For flows without sensitive capabilities, this remains WARN since CONTEXT scope is narrower and undeclared CONTEXT events are common in simple parent-child flows.

**Warn:**
```yaml
flowmarkup:
  title: "Child Processor"
  # no events: declaration
  do:
    - waitFor:
        event: parent_ready
        scope: CONTEXT              # WARN: CONTEXT event not declared in events:
    - emit:
        event: child_done
        scope: CONTEXT              # WARN: CONTEXT event not declared in events:
        data: { status: =result }
```

**Good:**
```yaml
flowmarkup:
  title: "Child Processor"
  events:
    parent_ready:
      _notes_: "Received from parent when ready"
    child_done:
      _notes_: "Emitted when child processing completes"
      data:
        status: STRING
  do:
    - waitFor:
        event: parent_ready
        scope: CONTEXT              # OK: declared in events:
    - emit:
        event: child_done
        scope: CONTEXT              # OK: declared in events:
        data: { status: =result }
```

---

#### SA-EVENT-11 -- `waitFor` with `scope: GLOBAL` and no `source:` filter 📋 ERROR

A `waitFor` step with `scope: GLOBAL` and no `source:` filter accepts events from any flow instance. Any flow with the capability to emit GLOBAL-scope events of the matching type can inject events, potentially triggering unintended state transitions. *(CWE-345: Insufficient Verification of Data Authenticity)* This rule supersedes the former SA-EVENT-14 (consolidated).

**Error:**
```yaml
- waitFor:
    event: payment_received
    scope: GLOBAL                    # ERROR: no source: filter — any flow can inject events
```

**Good:**
```yaml
- waitFor:
    event: payment_received
    scope: GLOBAL
    source: =order_flow_handle       # OK: scoped to specific flow instance
```

---

#### SA-EVENT-12 -- CONTEXT-scope `waitFor` without `source:` in flow with sub-flows 📋 WARN

A `waitFor` step with `scope: CONTEXT` and no `source:` filter exists in a flow that invokes sub-flows via `run:`. When sub-flows are present, CONTEXT events from any sub-flow in the execution chain are accepted. Add `source:` filter to restrict to expected emitters.

**Warn:**
```yaml
do:
  - run:
      flow: "sub-process.flowmarkup.yaml"
      async: true
  - waitFor:
      event: process_complete
      scope: CONTEXT                   # WARN: no source: — sub-flow can inject this event
```

**Good:**
```yaml
do:
  - run:
      flow: "sub-process.flowmarkup.yaml"
      async: true
      result: { handle: =RESULT }
  - waitFor:
      event: process_complete
      scope: CONTEXT
      source: =handle                  # OK: scoped to specific sub-flow instance
```

---

#### SA-EVENT-13 -- `waitFor` with `scope: GLOBAL` and no `condition:` data validation 📋 WARN

A `waitFor` step with `scope: GLOBAL` accepts event data without validating the `EVENT.DATA` structure via a `condition:` expression. A malicious or buggy emitter can send events with unexpected data shapes, causing downstream steps to fail or behave incorrectly. When processing GLOBAL events that drive state transitions or business logic, add a `condition:` that validates expected data fields.

**Warn:**
```yaml
- waitFor:
    event: payment_received
    scope: GLOBAL
    source: =order_service            # OK: source filtered
    # No condition: — EVENT.DATA shape not validated                # WARN
    result: { amount: =EVENT.DATA.amount, order_id: =EVENT.DATA.order_id }
```

**Good:**
```yaml
- waitFor:
    event: payment_received
    scope: GLOBAL
    source: =order_service
    condition: "=has(EVENT.DATA.amount) && has(EVENT.DATA.order_id) && type(EVENT.DATA.amount) == double"
    result: { amount: =EVENT.DATA.amount, order_id: =EVENT.DATA.order_id }
```

---

#### SA-EVENT-14 -- *(Consolidated into SA-EVENT-11)*

This rule has been consolidated into SA-EVENT-11, which now fires at ERROR severity for all `waitFor` steps with `scope: GLOBAL` lacking a `source:` filter.

---

#### SA-EVENT-15 -- `emit.data:` payload size is unbounded 📋 WARN

`emit.data:` payload size is unbounded. Event data derived from user-controlled input or action results without size validation can cause memory exhaustion in event subscribers. Flow authors SHOULD validate event data size before emission or use `maxSize:` constraint on `emit.data:`. *(CWE-400: Uncontrolled Resource Consumption)*

---

#### SA-EMIT-5 -- `emit` inside unbounded loop without rate limiting 📋 WARN

An `emit` step inside a `while`, `repeat`, or `forEach` (without static bounds) risks exhausting the per-flow event quota. The engine MUST enforce per-flow-instance event emit quotas (recommended default: 256 events per scope per flow instance). Add `rateLimit:` on the enclosing action or use `completionCondition` to bound iteration.

---

### 1.30 Triggers (SA-TRIGGER-\*)

#### SA-TRIGGER-1 -- Cron/schedule trigger with required input params 📋 WARN

Cron triggers provide no input data. Required `input` params without defaults cause `ValidationError` at trigger time.

---

#### SA-TRIGGER-2 -- Event trigger without `condition:` filter on source or data fields 📋 WARN

Event trigger without condition: filter on EVENT.SOURCE or EVENT.DATA fields. Events from external sources should include conditions that validate expected source or payload structure.

---

#### SA-TRIGGER-3 -- Cron trigger on flow with security-sensitive capabilities 📋 WARN

Cron trigger on flow with EXEC, SSH, or SECRET capabilities — unmonitored secret usage and command execution risk.

---

#### SA-TRIGGER-4 -- Overlapping cron triggers 📋 WARN

Multiple cron triggers with overlapping schedules that could cause resource spikes.

---

#### SA-TRIGGER-5 -- Event trigger without scope specification ✅ ERROR

Event trigger without `scope:` specification — events from all scopes will match.

---

### 1.31 DAG (SA-DAG-\*)

#### SA-DAG-1 -- `dependsOn` cycle 📋 ERROR

A cycle in the `dependsOn` dependency graph is a static analysis error (deadlock at runtime).

---

#### SA-DAG-2 -- `dependsOn` references non-existent branch 📋 ERROR

Every branch name in `dependsOn` must exist as a key in `branches`.

---

#### SA-DAG-3 -- `dependsOn` self-reference 📋 ERROR

A branch must not depend on itself.

---

#### SA-DAG-4 -- Concurrent branches with `dependsOn` writing same variable 📋 WARN

Two branches with no transitive dependency path writing to the same variable is a data race.

---

#### SA-DAG-5 -- All branches have `dependsOn` (no root branches) 📋 WARN

At least one branch must be a root (no `dependsOn`) to start execution.

---

### 1.32 Lock (SA-LOCK-\*)

#### SA-LOCK-1 -- `lock` with shared/global scope and no `timeout:` 📋 WARN

> **Unbounded wait pattern.** Without a timeout, a contended `lock` may block indefinitely. See also SA-WAIT-1, SA-EMIT-4, SA-TIMEOUT-1.

**Good:** add `timeout: "5s"`

---

#### SA-LOCK-2 -- Nested `lock` with different names (potential deadlock) 📋 WARN

Locks nested in opposite order across concurrent code paths cause deadlock. Always acquire locks in consistent order.

---

#### SA-LOCK-3 -- Nested `lock` with same name (reentrant) 📋 INFO

Reentrant acquisition is a no-op -- outermost exit releases.

---

#### SA-LOCK-4 -- Blocking operations inside `lock` body 📋 WARN

`wait:`, `waitUntil:`, `waitFor:`, or a `call` with no `timeout:` inside a `lock` body starves other lock waiters.

---

#### SA-LOCK-5 -- `async: true` inside `lock` body 📋 WARN

The async action's effects happen after the lock is released -- they escape the lock boundary.

---

#### SA-LOCK-6 -- *(Removed — consolidated into SA-LOCK-5)*

---

#### SA-LOCK-7 -- `lock.name:` with `=` prefix on what should be a static string 📋 WARN

`lock.name` is a CEL expression field. With `=`, the value is evaluated as CEL.

**Bad:** `name: =balance` (evaluates variable)
**Good:** `name: balance` (literal) or `name: ="'file:' + file_path"` (dynamic)

---

#### SA-LOCK-8 -- Dynamic lock name (CEL expression, not literal) ✅ WARN

`lock.name` is a CEL expression (`=` prefixed). Dynamic lock names from user input can exhaust the lock registry, causing `ResourceExhaustedError`. Prefer static lock names or bounded sets. See R-SEC-9. The engine MUST enforce a maximum lock registry size (recommended default: 10,000).

---

#### SA-LOCK-9 -- Lock name derived from attacker-influenced fields 📋 ERROR

Lock name is derived from flow input parameters, `EVENT.DATA.*`, `CONTEXT.*`, or `GLOBAL.*` fields that may be attacker-influenced. Attacker-controlled lock names can exhaust the per-tenant lock registry (default: 10,000), causing denial of service. *(CWE-400)* Validate lock name components against bounded enums or use cardinality annotation to limit the number of distinct lock names.

---

#### SA-LOCK-10 -- CEL-derived lock name without validation 📋 WARN

`lock.name` is a CEL expression referencing user-controlled input. Attacker-controlled lock names can create many unique locks, exhausting the engine's lock registry. Validate name pattern (`[a-zA-Z0-9_.-]{1,256}`) to prevent lock registry pollution. `lock.name` derived from user input or CEL expression without pattern validation.

> **Rationale:** CWE-74 (Improper Neutralization of Special Elements in Output). Lock name validation prevents injection of malformed lock identifiers.

---

#### SA-LOCK-11 -- Multiple GLOBAL-scoped locks 📋 INFO

Flow acquires multiple GLOBAL-scoped locks. Document the acquisition order for cross-flow deadlock review. Flows sharing GLOBAL locks SHOULD acquire them in a consistent global order.

---

### 1.33 Control Flow (SA-CTRL-\*)

#### SA-CTRL-1 -- `return` inside PARALLEL/RACE branch 📋 WARN

`return` terminates the entire flow instance and cancels all other branches.

---

#### SA-CTRL-2 -- Branch `condition:` on group with `do:` ✅ ERROR

Per-branch `condition:` is only valid with `branches:`, not `do:`-form groups.

---

#### SA-CTRL-3 -- `failPolicy: COMPLETE` on non-parallel group 📋 WARN

`failPolicy` is only meaningful with `mode: PARALLEL`.

---

#### SA-CTRL-4 -- `onTimeout.after` >= group `timeout` 📋 WARN

If the non-interrupting timeout fires at or after the hard timeout, the handler can never execute.

---

#### SA-CTRL-5 -- `elseIf` conditions overlap 📋 INFO

If an `elseIf` condition is always true when reached, subsequent clauses are unreachable.

---

### 1.34 Flow-Level (SA-FLOW-\*)

#### SA-FLOW-1 -- Single-value output contract with map-form `return` ✅ ERROR

Same as CORE-15 but applies to any `return` form mismatch in the body.

---

#### SA-FLOW-2 -- `call` result form mismatches target contract 📋 WARN

Single-value output contract requires string-form `result:`; multi-param requires object-form.

> See also CORE-16.

---

#### SA-FLOW-3 -- `completionCondition` without `concurrent: true` 📋 WARN

`completionCondition` on a `forEach` is only meaningful when `concurrent: true`.

---

#### SA-FLOW-4 -- Flow missing `requires:` declaration 📋 ERROR

Every flow must declare a `requires:` block. Without `requires:`, the flow's capability requirements are implicit and cannot be validated by the engine or static analysis tools. This is a mandatory declaration for security hardening.

**Bad:**
```yaml
flowmarkup:
  title: "Order Processor"
  do:
    - log: "Processing"             # ERROR: no requires: declaration
```

**Good:**
```yaml
flowmarkup:
  title: "Order Processor"
  requires: {}                      # OK: explicit empty requires (no capabilities needed)
  do:
    - log: "Processing"
```

---

#### SA-FLOW-5 -- *(Reserved for flow-level naming/documentation rules)*

---

#### SA-FLOW-6 -- *(Reserved for flow-level naming/documentation rules)*

---

#### SA-FLOW-7 -- *(Reserved for flow-level naming/documentation rules)*

---

#### SA-FLOW-8 -- Flow document exceeds maximum size 📋 ERROR

Flow document size exceeds the engine's configured maximum (default: 1 MB, configurable maximum: 10 MB). Large flow documents cause excessive memory consumption during parsing and validation. *(CWE-400)*

---

#### SA-FLOW-9 -- Flow step count exceeds maximum 📋 ERROR

Flow contains more steps than the engine's configured maximum (default: 10,000). Excessive step counts cause memory and CPU exhaustion during validation and execution. *(CWE-400)*

---

#### SA-FLOW-10 -- Flow control flow nesting depth exceeds maximum 📋 ERROR

Flow contains nested control flow directives (`if`/`forEach`/`group`/`try`/`while`/`repeat`/`switch`) exceeding the maximum depth (default: 32). Deeply nested control flow causes stack overflow risk during execution. *(CWE-400)*

---

### 1.35 CEL Expressions (SA-EXPR-\*, SA-CEL-\*)

#### SA-EXPR-1 -- `length()` instead of `size()` ✅ ERROR

CEL uses `size()` for string/collection length. `length()` is undefined.

**Bad:** `prompt.length() > 0` -- **Good:** `prompt.size() > 0`

---

#### SA-EXPR-2 -- `sort()` instead of `sortBy(x, expr)` ✅ ERROR

No bare `sort()` function. Use `sortBy(x, x)` for natural ordering.

---

#### SA-EXPR-3 -- `take(n)` instead of `first(n)` ✅ ERROR

No `take()` function. Use `first(n)`.

---

#### SA-EXPR-4 -- `${...}` template syntax ✅ ERROR

`${...}` is NOT supported. Use `=` prefix for CEL or `{{ }}` for templates.

---

#### SA-EXPR-5 -- `items.filter(x, pred).sum()` without map 📋 WARN

`filter()` returns objects, not numbers. First `map()` to extract the numeric field.

**Bad:** `orders.filter(x, x.active).sum()`
**Good:** `orders.filter(x, x.active).map(x, x.amount).sum()`

---

#### SA-EXPR-6 -- `reduce` with wrong fold expression 📋 WARN

The fold body must use `acc` to accumulate. Using only `x` ignores the accumulator.

---

#### SA-CEL-1 -- CEL expression uses a forbidden introspection name 📋 ERROR

A CEL expression contains a member-access or function-call whose name appears on the introspection denylist (Java reflection, JavaScript prototypes), or a `call.operation:` field resolves to a denied name.

Denylist includes the language-specific entries in FLOWMARKUP-ENGINE-CROSSLANG.md CL-7. The engine MUST apply denylist checks to the full member access chain, not just the final name. Example: `obj.x.__proto__` is denied because `__proto__` appears in the chain, even though `x` is allowed.

**Bad:**
```yaml
- set:
    cls: =some_obj.getClass()         # ERROR: getClass is denied
- call:
    service: db
    operation: getClass               # ERROR: forbidden operation name
```

---

#### SA-CEL-2 -- *(Removed)*

---

#### SA-CEL-3 -- CEL expression exceeds AST depth limit ✅ ERROR

The CEL expression's abstract syntax tree depth exceeds the engine's configured maximum (recommended default: 32). Deeply nested expressions risk stack overflow during evaluation and are difficult to maintain.

**Bad:**
```yaml
- set:
    result: =a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.aa.bb.cc.dd.ee.ff.gg
```

---

#### SA-CEL-7 -- CEL expression AST node count exceeds engine limit ✅ ERROR

CEL expression AST node count exceeds engine limit. Default: 256 nodes. Expressions with depth ≤32 but excessive width (thousands of chained operations) can cause O(n) or worse evaluation costs. *(CWE-400)*

---

#### SA-CEL-8 -- `s.matches(pattern)` with user-controlled pattern 📋 WARN

`s.matches(pattern)` where `pattern` is derived from user-controlled input. Dynamically constructed regex patterns can cause ReDoS (Regular Expression Denial of Service) even when static patterns are validated by SA-REGEX-1. Flow authors MUST use pre-defined patterns or apply `regexQuote()` to user input before inclusion in patterns. *(CWE-1333: Inefficient Regular Expression Complexity)*

---

#### SA-CEL-4 -- CEL collection operation on potentially unbounded input ✅ WARN

A CEL collection operation (`flatMap`, `sortBy`, `groupBy`, `reduce`, `distinctBy`) operates on input that is not statically bounded. The engine MUST enforce a maximum collection size (recommended default: 10,000 elements), raising `ResourceExhaustedError` when exceeded.

---

#### SA-CEL-5 -- Nested collection expansion (quadratic/cubic risk) ✅ WARN

A CEL expression chains expansion operations (`flatMap`, nested `map` producing lists) that may produce combinatorial output. Example: `list.flatMap(x, other_list.map(y, ...))` produces `O(n*m)` elements.

---

#### SA-CEL-6 -- Unbounded string output from CEL expression ✅ ERROR

A CEL expression uses string concatenation (`+`), `string.repeat()`, `join()`, or `format()` on collections of unbounded size without a preceding size check. Unbounded string construction in CEL can exhaust engine memory. The runtime enforces a 1 MB default limit, but static analysis should flag likely violations early. Applies to CEL expressions in `set:`, `request.body:`, or `log.message:` that use unbounded string concatenation, `string.join()`, or format on user-controlled collection without `maxItems` guard.

> **Rationale:** CWE-400 (Uncontrolled Resource Consumption). String output size limit for CEL expressions operating on unbounded collections.

---

### 1.36 Regex (SA-REGEX-\*)

#### SA-REGEX-1 -- `$format` or `matches()` pattern contains nested quantifiers 📋 ERROR

A regex pattern used in `$format` validation or `s.matches(regex)` contains nested quantifiers (e.g., `(a+)+`, `(a*)*`, `(a+)*`). Nested quantifiers cause catastrophic backtracking on PCRE/Java Pattern engines. The engine MUST use a linear-time regex engine (RE2 or equivalent) for all regex evaluation. PCRE features (backreferences, lookaheads, lookbehinds) are NOT supported in FlowMarkup regex contexts. Patterns using unsupported features MUST raise `ValidationError` at load time.

---

#### SA-REGEX-2 -- Pattern uses PCRE-only features 📋 ERROR

A regex pattern in `$format` or `matches()` uses backreferences (`\1`, `(?P=name)`), lookaheads (`(?=...)`, `(?!...)`), lookbehinds (`(?<=...)`, `(?<!...)`), or other PCRE-only features not supported by RE2. FlowMarkup mandates linear-time regex evaluation via RE2 or equivalent. *(CWE-1333: Inefficient Regular Expression Complexity)*

---

#### SA-EXPR-7 -- Dot-access to `$`-prefixed metadata property 📋 ERROR

A CEL expression accesses `.$type`, `.$name`, `.$kind`, `.$size`, or `.$value` on a variable or RESOURCES handle. Use the `meta()` macro instead.

**Bad:** `=RESOURCES.config.$name` -- **Good:** `=meta(RESOURCES.config).name`

---

#### SA-EXPR-8 -- `meta()` called on non-variable expression 📋 ERROR

The `meta()` macro argument must be a variable identifier or `RESOURCES.*` select expression.

**Bad:** `=meta(42).type` -- **Bad:** `=meta(a + b).kind` -- **Good:** `=meta(my_var).type` -- **Good:** `=meta(RESOURCES.config).name`

---

### 1.37 Step-Level (SA-STEP-\*)

#### SA-STEP-1 -- `retry.delay` integer ✅ INFO

`retry.delay` accepts duration literals (`"2s"`), integer milliseconds, or CEL expressions.

---

#### SA-STEP-2 -- `retry.errors` contains `CircuitOpenError` 📋 WARN

Retrying when the circuit is OPEN hits the same open breaker immediately.

**Good:** handle `CircuitOpenError` in `catch:` with a backoff.

---

#### SA-STEP-3 -- `=` prefix on a string literal value 📋 WARN

`=pending` means the CEL variable `pending`, not the string `"pending"`.

**Bad:** `status: =pending` (evaluates variable)
**Good:** `status: pending` (string literal)

---

#### SA-STEP-4 -- `condition:` is a constant literal 📋 WARN

A `condition:` with literal `true` or `false` wastes a branch and may indicate a logic error.

---

#### SA-STEP-5 -- `timeout:` value is zero or negative ✅ ERROR

Always expires immediately, making the step always fail with `TimeoutError`.

---

#### SA-STEP-6 -- Unreachable steps after unconditional `return`/`throw`/`break`/`continue` 📋 WARN

Steps after an unconditional terminating directive are dead code.

**Bad:**
```yaml
- return: result
- log: 'This never runs'    # WARN: unreachable
```

---

### 1.38 Annotations (SA-META-\*)

Step annotations (`_id_`, `_label_`, `_notes_`, `_meta_`) are valid on steps only -- NOT on the flow root.

#### SA-META-1 -- `_id_` not in snake_case 📋 WARN

`_id_` should be `snake_case` (e.g., `fetch_order`).

---

### 1.39 Version Migration (SA-VER-\*)

#### SA-VER-1 -- `onVersionChange:` without `version:` ✅ WARN

`MIGRATION.OLD_VERSION` and `MIGRATION.NEW_VERSION` will always be null.

---

#### SA-VER-2 -- *(Reserved for version format validation rules)*

---

#### SA-VER-3 -- Blocking step inside `onVersionChange:` ✅ ERROR

`wait`, `waitFor`, `waitUntil` stall the engine's resume machinery indefinitely.

---

#### SA-VER-4 -- `async: true` inside `onVersionChange:` ✅ ERROR

Fire-and-forget is incompatible with the synchronous migration lifecycle.

---

#### SA-VER-5 -- `yield` inside `onVersionChange:` ✅ ERROR

No active stream exists during the migration lifecycle.

---

#### SA-VER-6 -- `onVersionChange:` writes to `GLOBAL.*`/`CONTEXT.*` or modifies `$secret` variables 📋 ERROR

Migration handlers (`onVersionChange:`) MUST NOT write to `GLOBAL.*` or `CONTEXT.*` scopes, and MUST NOT modify variables annotated with `$secret: true`. Migration handlers run during checkpoint resume where shared-scope writes could corrupt other instances' state, and secret modifications could escalate privileges across flow versions. *(CWE-269: Improper Privilege Management)*

---

#### SA-VER-7 -- Action step inside `onVersionChange:` 📋 ERROR

Migration handlers (`onVersionChange:`) MUST NOT execute `exec`, `ssh`, `request`, `mail`, `storage`, `call`, or `run` action steps. Migration handlers run during checkpoint resume where capabilities may differ between flow versions. Only `set:`, `log:`, `assert:`, `emit:`, control flow directives (`if`, `switch`, `forEach`), and `return`/`throw` are permitted. *(CWE-269: Improper Privilege Management)*

---

### 1.40 Codec (SA-CODEC-\*)

#### SA-CODEC-1 -- `encode`/`decode`/`parse` format mismatch 📋 WARN

FORMAT constant does not apply to the receiver type.

---

#### SA-CODEC-2 -- `decode`/`parse` result used in incompatible context 📋 WARN

The decoded result type doesn't support the subsequent operation.

---

### 1.41 Idempotency (SA-IDEMP-\*)

#### SA-IDEMP-1 -- Non-deterministic `idempotencyKey` expression ✅ ERROR

idempotencyKey: expression contains uuid(), random(), now(), or any non-deterministic function. Idempotency keys MUST be derived deterministically from input parameters to ensure deduplication correctness.

**Bad:** `idempotencyKey: "='event:' + input.id + ':' + now()"`
**Good:** `idempotencyKey: "='event:' + input.id"`

---

### 1.42 Functions (SA-FN-\*)

#### SA-FN-1 -- Function name shadows a built-in CEL function ✅ WARN

The user's definition takes precedence within the flow.

---

#### SA-FN-2 -- Function directly calls itself (self-recursion) ✅ ERROR

FlowMarkup functions are pure macros; recursion is not supported.

---

#### SA-FN-3 -- Mutual recursion cycle in the call graph ✅ ERROR

Two or more functions that call each other (directly or transitively) form a cycle.

---

#### SA-FN-4 -- Function parameter name shadows a `vars:` or `const:` key ✅ WARN

The parameter wins inside the function body. Valid but may be confusing.

---

#### SA-FN-5 -- Function defined but never called (dead code) ✅ WARN

A function in `functions:` never referenced anywhere in the flow.

---

#### SA-FN-6 -- Function called with wrong number of arguments ✅ ERROR

Every call site must pass exactly the number of arguments declared in `params:`.

---

#### SA-FN-7 -- Function taint propagation 📋 ERROR

Function parameter receives `$secret: true` or `$exportable: false` variable, and the function's return value is used without corresponding taint annotation. Taint MUST propagate through function boundaries.

---

### 1.43 Examples (SA-EX-\*)

These rules fire only when `examples:` is declared at flow level.

#### SA-EX-1 -- Example input key not declared in flow.input ✅ ERROR

An example's `input:` must only reference declared params.

---

#### SA-EX-2 -- Required input param absent from example input: ✅ WARN

Required params (no `$default:`) missing from an example make it incomplete.

---

#### SA-EX-3 -- Example output key not declared in flow.output ✅ ERROR

An example's `output:` must only reference declared output params.

---

#### SA-EX-4 -- Required output param absent from example output: ✅ WARN

Required output params missing from an example make it incomplete.

---

### 1.44 Nullable (SA-NULL-\*)

#### SA-NULL-1 -- `$nullable: false` on typedVar whose `$value` is null ✅ WARN

Static contradiction -- the variable cannot be initialized without violating its own non-nullable contract.

**Bad:**
```yaml
vars:
  job_id:
    $kind: =UUID
    $nullable: false
    $value: null         # WARN: contradicts $nullable: false
```

---

#### SA-NULL-2 -- `$nullable: false` on optional input without `$default:` ✅ WARN

When the caller omits the parameter, the engine supplies `null`, which violates the non-nullable declaration.

---

#### SA-NULL-3 -- `$nullable: true` with `$default: null` (or `$value: null`) ✅ INFO

Either alone is sufficient to indicate nullable + null default. Having both is redundant:
- `$default: null` implies `$nullable: true`.
- `$nullable: true` on an input param without `$default:` implies `$default: null` (param becomes optional).
- `$nullable: true` on a `vars:`/`set:` declaration without `$value:` implies `$value: null`.

---

#### SA-NULL-4 -- Property/method/index access on declared-nullable variable without null guard ✅ WARN

Dereferencing a nullable variable (`x.field`, `x.size()`, `x[0]`) without a null guard raises `ValidationError` at runtime if `x` is `null`.

**Bad:**
```yaml
vars:
  user:
    $kind: MAP
    $nullable: true
do:
- log: =user.name          # WARN: user is nullable, .name may throw
```

**Good:**
```yaml
- if:
    condition: =user != null
    then:
    - log: =user.name        # OK: guarded by parent if:
```

**Also good:**
```yaml
- log: =user != null ? user.name : "anonymous"   # OK: expression-local ternary guard
```

Guard patterns that suppress SA-NULL-4: (1) expression-local ternary `x != null ? x.field : fallback`, (2) same-step `condition: =x != null`, (3) parent `if: { condition: =x != null, then: [...] }`.

**Limitations:** Only the first identifier in a chain is checked (e.g., `order.items.first().name` checks `order` but not intermediate nullable fields). Result bindings (`result:`) have unknown nullability — only variables with explicit `$nullable` declarations or auto-derived nullability are checked. Inverted guards (`if: =x == null ... else: [x.field]`) are not tracked.

---

#### SA-NULL-5 -- `forEach.items` references a declared-nullable variable without guard ✅ WARN

`forEach` requires a non-null iterable. If `items:` references a nullable variable, a `null` value raises `ValidationError` at runtime.

**Bad:**
```yaml
vars:
  order_items:
    $kind: ARRAY
    $nullable: true
do:
- forEach:
    items: =order_items        # WARN: order_items is nullable
    do:
    - log: =item.name
```

**Good:**
```yaml
- if:
    condition: =order_items != null
    then:
    - forEach:
        items: =order_items    # OK: guarded by parent if:
        do:
        - log: =item.name
```

---

#### SA-NULL-6 -- Nullable variable passed to non-nullable required action param 📋 WARN

Passing a nullable variable to a required action parameter that does not accept `null` may cause a runtime type error. **Deferred** — requires cross-flow action contract resolution.

---

#### SA-NULL-7 -- Template `{{ }}` property-dereferences a nullable variable without guard ✅ WARN

Template interpolation auto-stringifies bare `null` to `"null"` (safe), but property access on `null` inside `{{ }}` still throws `ValidationError`.

**Bad:**
```yaml
vars:
  user:
    $kind: MAP
    $nullable: true
do:
- log: "Hello {{user.name}}"   # WARN: user is nullable, .name may throw in template
```

**Good:**
```yaml
- log:
    message: "Hello {{user.name}}"
    condition: =user != null   # OK: guarded by condition:
```

---

#### SA-NULL-8 -- `switch.value` on nullable without `null` match key or `default:` ✅ WARN

If `switch.value` references a nullable variable and `match:` has no `null` key and no `default:` branch, a `null` value at runtime produces no match and silently falls through.

**Bad:**
```yaml
vars:
  status:
    $kind: STRING
    $nullable: true
do:
- switch:
    value: =status
    match:
      active:
      - log: "Active"
      pending:
      - log: "Pending"
      # WARN: status is nullable but no null key and no default:
```

**Good:**
```yaml
- switch:
    value: =status
    match:
      active:
      - log: "Active"
      pending:
      - log: "Pending"
      null:
      - log: "No status"
    default:
    - log: "Unknown"
```

---

### 1.45 Finally (SA-FINALLY-\*)

#### SA-FINALLY-1 -- `return` inside `finally:` 📋 ERROR

`return` inside `finally:` suppresses the original error or return value.

---

#### SA-FINALLY-2 -- `break`/`continue` inside `finally:` 📋 ERROR

May suppress pending errors or skip cleanup logic.

---

#### SA-FINALLY-3 -- `throw` inside `finally:` 📋 WARN

Replaces the original error or return value.

---

### 1.46 forEach (SA-FOREACH-\*)

#### SA-FOREACH-1 -- `concurrent: true` with `maxConcurrency: 1` 📋 INFO

Effectively serializes iteration -- `concurrent: true` provides no parallelism benefit.

---

#### SA-FOREACH-2 -- `return` inside concurrent `forEach` 📋 WARN

`return` terminates the entire flow instance and cancels all concurrent iterations. Consider `completionCondition` instead.

---

#### SA-FOREACH-3 -- Missing `maxItems` on user-controlled iteration 📋 WARN

`forEach.items` references user-controlled input (flow input parameters, action results from external sources) AND `forEach.maxItems` is not specified. Unbounded iteration on attacker-controlled collections can exhaust engine resources.

> **Rationale:** CWE-400 (Uncontrolled Resource Consumption). `forEach` over user-controlled collection without explicit `maxItems` limit.

---

### 1.47 Switch (SA-SWITCH-\*)

#### SA-SWITCH-1 -- `switch` without `default:` 📋 WARN

Switch without `default:` or `null:` case completes silently when no case matches. Add `default:` to handle unexpected values.

---

#### SA-SWITCH-2 -- `{{ }}` template in `switch.match` ✅ ERROR

Match values are static literals. Template expressions cannot be used for static matching.

**Bad:**
```yaml
- switch:
    value: =status
    match:
      "active_{{region}}":        # ERROR: template in match key
        - log: "regional"
```

---

### 1.48 YAML Syntax (SA-YAML-\*)

#### SA-YAML-1 -- `switch.match` key is a YAML 1.2.2 auto-typed value ✅ WARN

`match:` keys that are YAML 1.2.2 Core Schema auto-typed values (booleans, nulls, floats) are non-string types. Because `switch` uses strict equality with no type coercion (see §2.1), a boolean key `true` will **not** match the string `"true"`. This is usually an authoring mistake — the key should be quoted.

Auto-typed values that trigger this warning: `true`, `True`, `TRUE`, `false`, `False`, `FALSE`, `null`, `Null`, `NULL`, `.inf`, `.Inf`, `.INF`, `-.inf`, `-.Inf`, `-.INF`, `+.inf`, `+.Inf`, `+.INF`, `.nan`, `.NaN`, `.NAN`.

Integer and float keys (e.g., `1`, `3.14`) do NOT trigger this warning — numeric matching is a valid use case.

**Bad:**
```yaml
- switch:
    value: =feature_flag
    match:
      true:                       # WARN: boolean key, not string "true"
        - log: "Enabled"
      false:
        - log: "Disabled"
```

**Good:**
```yaml
- switch:
    value: =feature_flag
    match:
      "true":                     # quoted — matches string "true"
        - log: "Enabled"
      "false":
        - log: "Disabled"
```

---

#### SA-YAML-2 -- YAML merge key (`<<`) prohibited in flow definitions ✅ ERROR

Flow definition YAML contains the merge key (`<<`) in any mapping. The YAML merge key can silently override security-critical properties (`requires`, `cap`, `integrity`, `tls`) by injecting values from anchored nodes. Specifically detects merge key injection that overrides security-relevant fields (`requires:`, `cap:`, `$secret`, `$declassify`).

**Pre-parse timing requirement:** SA-YAML-2 MUST be evaluated before any YAML-to-object deserialization — the engine MUST scan the raw YAML text for `<<` merge keys before parsing the document into an object model. Post-parse detection is insufficient because the merge has already been applied and the overridden values are indistinguishable from explicitly authored values. The engine MUST use YAML 1.2 parsing semantics (not YAML 1.1) — YAML 1.1 features such as implicit type coercion (`yes`/`no` → boolean, `0o77` → octal) and the merge key itself are deprecated in YAML 1.2. If the engine's YAML parser defaults to YAML 1.1, it MUST be configured for YAML 1.2 Core Schema behaviour. *(CWE-502: Deserialization of Untrusted Data)*

> **Rationale:** CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes). Merge key (`<<`) injection detection targeting security-relevant fields.

---

#### SA-YAML-3 -- YAML anchor/alias nesting depth exceeds engine limit ✅ ERROR

YAML anchor (`&`) / alias (`*`) nesting depth exceeds engine limit (default: 10). Prevents billion-laughs-style expansion attacks during flow definition parsing. The engine MUST reject documents where alias expansion would exceed the configured total-expansion-size limit. *(CWE-400: Uncontrolled Resource Consumption)*

---

### 1.49 Loop (SA-LOOP-\*)

#### SA-LOOP-1 -- `while` with constant `true` condition and no `break` 📋 WARN

Infinite loop.

---

#### SA-LOOP-2 -- `repeat` with constant `false` `until:` and no `break` 📋 WARN

`until: =false` never terminates.

---

#### SA-LOOP-3 -- `while`/`repeat` without `maxIterations` and user-controlled condition 📋 WARN

`while`/`repeat` loops where `maxIterations` is not explicitly set and the loop condition references user-controlled input (flow `input:` parameters, `CONTEXT.*`, `GLOBAL.*`, or action results without validation). Default `maxIterations` (100,000) may be insufficient for attacker-controlled bounds. *(CWE-770: Allocation of Resources Without Limits or Throttling)*

---

### 1.50 Timeout (SA-TIMEOUT-\*)

#### SA-TIMEOUT-1 -- `waitFor` with `scope: GLOBAL` and no `timeout:` 📋 WARN

> **Unbounded wait pattern.** Global events may never arrive. See also SA-WAIT-1, SA-EMIT-4, SA-LOCK-1.

---

### 1.51 Wait (SA-WAIT-\*)

#### SA-WAIT-1 -- `waitUntil` without `timeout:` 📋 WARN

> **Unbounded wait pattern.** Without a timeout, the step blocks until the target timestamp with no upper bound. See also SA-EMIT-4, SA-LOCK-1, SA-TIMEOUT-1.

---

### 1.52 Defaults (SA-DEF-\*)

#### SA-DEF-1 -- `defaults.circuitBreaker` without `name:` ✅ WARN

All action steps in scope share a single unnamed circuit breaker. Almost certainly unintentional.

**Good:** name each breaker per-step or use a named const reference.

---

#### SA-DEF-2 -- Permanent error types in `defaults.retry.onErrors` ✅ ERROR

`defaults.retry.onErrors` includes permanent error types (`ValidationError`, `BadRequestError`, `AuthenticationError`, `AccessDeniedError`, `MissingCapabilityError`, `NotFoundError`, `ConfigurationError`, `AssertionError`, `AddressError` — see FLOWMARKUP-ERRORS.md "Permanent error types").

---

#### SA-DEF-3 -- Invalid `defaults.timeout` value ✅ ERROR

`defaults.timeout` of 0 or negative value.

---

#### SA-DEF-4 -- `defaults.rateLimit` WAIT without timeout 📋 WARN

`defaults.rateLimit` with WAIT strategy and no timeout.

---

#### SA-DEF-5 -- `defaults.circuitBreaker` integer or string shorthand ✅ ERROR

Integer shorthand (`defaults.circuitBreaker: 5`) has no step context for `_id_` name derivation. String shorthand (`defaults.circuitBreaker: "5/name"`) assigns a single name to all descendant steps, causing unintended circuit breaker sharing (same issue as SA-DEF-1). Use object form or a CEL reference (`=MY_BREAKER`) in defaults.

**Bad:**
```yaml
# Integer shorthand — no _id_ context in defaults:
defaults:
  circuitBreaker: 5

# String shorthand — all descendants share one breaker:
defaults:
  circuitBreaker: "5/payment"
```

**Good:**
```yaml
# CEL reference from const::
defaults:
  circuitBreaker: =STANDARD_BREAKER

# Object form:
defaults:
  circuitBreaker: { name: payment, threshold: 5 }
```

---

### 1.53 Resources (SA-RES-\*)

#### SA-RES-1 -- `RESOURCES.*` referenced without `requires.RESOURCES` ✅ WARN

> **See SA-CAP-1** (section 1.11). The `RESOURCES` binding is deny-by-default.

---

#### SA-RES-2 -- `RESOURCES` used as a write target variable name ✅ ERROR

`RESOURCES` is a system-provided read-only binding.

**Bad:**
```yaml
- set:
    RESOURCES: some_value   # ERROR: shadows read-only binding
```

---

#### SA-RES-3 -- `$name` in resource declaration is an invalid filename ✅ ERROR

Literal strings must be valid filenames: no path separators, no null bytes, not `.` or `..`.

**Bad:** `$name: ../etc/passwd`
**Good:** `$name: config.json`

---

### 1.54 Runtime Scope (SA-RT-\*)

#### SA-RT-1 -- `RUNTIME.*` referenced without `requires: { RUNTIME: true }` ✅ WARN

> **See SA-CAP-1** (section 1.11). The `RUNTIME` scope is deny-by-default.

---

#### SA-RT-2 -- Write target starts with `RUNTIME.` ✅ ERROR

The `RUNTIME` scope is engine-populated and strictly read-only.

**Bad:**
```yaml
- set:
    RUNTIME.OS.NAME: Linux          # ERROR: write to read-only scope
```

---

### 1.55 Kind Validation (SA-KIND-\*)

#### SA-KIND-1 -- Unknown `$kind` constant 📋 ERROR

`$kind` value is not a recognized kind constant (`STRING`, `TEXT`, `MARKDOWN`, `BINARY`, `NUMBER`, `INTEGER`, `BOOLEAN`, `ARRAY`, `MAP`, `DIRECTORY`, `ANY`), not a compound shorthand (`JSON`, `YAML`, `XML`, `CSV`, `TSV`), not a format-validated subtype (`URI`, `URL`, `EMAIL`, `FILENAME`, `IP`, `UUID`), and not a CEL expression (no `=` prefix).

**Bad:**
```yaml
vars:
  data:
    $kind: HASHMAP    # ERROR: unknown kind constant
```

---

#### SA-KIND-2 -- `$kind` and `$type` inconsistency 📋 WARN

When both `$kind` and `$type` are specified, the `$type` MIME type should be compatible with the `$kind` category. For example, `$kind: BINARY` with `$type: text/plain` is likely an error.

---

#### SA-KIND-3 -- `$kind` omitted and cannot be inferred 📋 ERROR

In a `paramDef` (input/output parameter declaration), `$kind` is omitted and no inference source is present — no `$schema`, no literal `$default`, and no `$enum`. The engine cannot determine the structural kind.

**Bad:**
```yaml
input:
  data:
    $type: application/json      # ERROR: no $kind, no $schema, no $default, no $enum
```

**Good:**
```yaml
input:
  data: {$kind: MAP, $type: application/json}
  order: {$schema: Order}                       # MAP inferred from schema
  priority: {$enum: [low, high]}                # STRING inferred from $enum
  limit: {$default: 10}                         # INTEGER inferred from $default
```

> **Note:** This rule applies only to `paramDef`. In `typedVar` (vars/const), `$kind` remains always required.

---

#### SA-KIND-4 -- Inferred `$kind` from `$default` conflicts with `$schema` type 📋 WARN

When both `$schema` and a literal `$default` are present on a `paramDef`, the `$kind` inferred from the YAML type of `$default` differs from the `$kind` inferred from the schema's root `type`. The `$schema`-inferred kind takes precedence, but the conflict suggests a likely authoring error.

**Bad:**
```yaml
input:
  config:
    $schema: AppConfig           # schema type: object → MAP
    $default: "fallback"         # WARN: string default → STRING, but schema says MAP
```

---

### 1.56 Format Validation (SA-FORMAT-\*)

#### SA-FORMAT-1 -- `$format` on non-string kind 📋 ERROR

`$format` provides regex validation and is only applicable to string-like kinds (`STRING`, `TEXT`, `MARKDOWN`). Using `$format` on `NUMBER`, `BOOLEAN`, `ARRAY`, `MAP`, `BINARY`, etc. is an error.

**Bad:**
```yaml
vars:
  count:
    $kind: NUMBER
    $format: "^\\d+$"    # ERROR: $format not applicable to NUMBER
```

---

#### SA-FORMAT-2 -- `$format` is not a valid regex 📋 ERROR

The `$format` value must be a syntactically valid regular expression. Invalid regex patterns are rejected at load time.

**Bad:**
```yaml
vars:
  code:
    $kind: STRING
    $format: "[invalid"    # ERROR: unclosed character class
```

---

### 1.57 Charset Validation (SA-CHARSET-\*)

#### SA-CHARSET-1 -- `$charset` on BINARY kind 📋 ERROR

`$charset` declares a character encoding for text content. Binary data has no character encoding, so `$charset` on `$kind: BINARY` is an error.

**Bad:**
```yaml
vars:
  raw_data:
    $kind: BINARY
    $charset: utf-8       # ERROR: binary data has no character encoding
    $value: =meta(RESOURCES.file).value
```

---

#### SA-CHARSET-2 -- `$charset` combined with `$encoding` 📋 ERROR

`$encoding` declares how binary data is stored as text in YAML (BASE64, HEX, etc.) and produces `byte[]` at init time. `$charset` declares how to decode external bytes to text. The two are mutually exclusive — `$encoding` already produces raw bytes, charset doesn't apply to the result.

**Bad:**
```yaml
vars:
  encoded_text:
    $kind: STRING
    $encoding: BASE64
    $charset: shift_jis    # ERROR: $encoding produces byte[], $charset doesn't apply
    $value: aGVsbG8=
```

---

### 1.58 SA-TEST rules (test files only)

SA-TEST rules apply to `.flowmarkup-test.yaml` files. The full catalog with descriptions is in **[spec/FLOWMARKUP-TESTING.md](spec/FLOWMARKUP-TESTING.md)**.

| Rule | Severity | Short description |
|---|---|---|
| SA-TEST-1 | ERROR | `flow:` path does not resolve |
| SA-TEST-2 | ERROR | `input:` provides undeclared parameter |
| SA-TEST-3 | ERROR | `input:` missing required parameter |
| SA-TEST-4 | WARN | Mock registered but never matched |
| SA-TEST-5 | WARN | Duplicate test name |
| SA-TEST-6 | ERROR | `expect.output:` references undeclared output field |
| SA-TEST-7 | ERROR | `expect.steps:` references unknown `_id_` |
| SA-TEST-8 | WARN | `focus:` target not found |
| SA-TEST-9 | ERROR | `expect.throws:` references unknown error type |
| SA-TEST-10 | WARN | Test has no `expect:` block |
| SA-TEST-11 | ERROR | `vars:` overrides undeclared variable |
| SA-TEST-12 | WARN | `${{ fixtures.<name> }}` references undefined fixture |
| SA-TEST-13 | WARN | `${{ mocks.<name> }}` references undefined mock |
| SA-TEST-14 | ERROR | `expect.yields:` without flow `yields:` contract |
| SA-TEST-15 | WARN | Flow `throws:` types not tested |
| SA-TEST-16 | ERROR | Action step in hook under `mode: UNIT` |
| SA-TEST-17 | ERROR | `expect.trace:` references unknown `_id_` |
| SA-TEST-18 | ERROR | `breakpoints:` references unknown `_id_` |
| SA-TEST-19 | WARN | Tautological invariant |
| SA-TEST-20 | ERROR | `migration:` without `onVersionChange:` |
| SA-TEST-21 | WARN | `migration.from:` == `migration.to:` with no hash change |
| SA-TEST-22 | WARN | `replay:` version mismatch |
| SA-TEST-23 | ERROR | `matrix:` rows have inconsistent keys |
| SA-TEST-24 | WARN | `calledBefore:`/`calledAfter:` references unknown `_id_` |
| SA-TEST-25 | ERROR | `contracts:` path does not resolve |
| SA-TEST-26 | WARN | `faults.services.probability:` outside [0.0, 1.0] |
| SA-TEST-27 | WARN | `repeat:` with `faults:` but no `seed:` |
| SA-TEST-28 | ERROR | `snapshot:` path has wrong extension |
| SA-TEST-29 | WARN | `mode: INTEGRATION` with no real services |
| SA-TEST-30 | WARN | Stateful mock has unreachable state |
| SA-TEST-31 | ERROR | `spy: true` in `mode: UNIT` — no real service to pass through to |
| SA-TEST-32 | ERROR | `resources:` injects a resource not declared in flow's `requires: { RESOURCES: ... }` |
| SA-TEST-33 | ERROR | `mode: UNIT` and flow requires RESOURCES but test provides no `resources:` for a required resource |
| SA-TEST-34 | ERROR | `services:` overrides a service alias not declared in the flow's `services:` |
| SA-TEST-35 | WARN | `idempotency.preSeed:` key does not match the flow's `idempotencyKey:` pattern |
| SA-TEST-36 | ERROR | `idempotency:` present but flow has no `idempotencyKey:` |
| SA-TEST-37 | WARN | `assertCapabilities:` on sub-flow mock but the `run` step has no `cap:` |
| SA-TEST-38 | WARN | Breakpoint has no `when:` condition and targets a step with `condition:` (may hang if step is skipped) |
| SA-TEST-39 | WARN | `expect.resources:` references a resource name not in `resources:` injection |
| SA-TEST-40 | WARN | `allowReal:` in `mode: INTEGRATION` — `allowReal:` is only meaningful in `mode: UNIT` |
| SA-TEST-41 | ERROR | `trigger:` and `input:` are mutually exclusive on the same test case |
| SA-TEST-42 | ERROR | `trigger.event:` does not match any entry in the flow's `triggers:` |
| SA-TEST-43 | WARN | `expect.events:` references an event type not in the flow's `events:` declaration |
| SA-TEST-44 | WARN | INTEGRATION mode tests without `environment:` field |
| SA-TEST-45 | WARN | Mock definitions bypassing capability enforcement |
| SA-TEST-46 | ERROR | `${{ }}` expressions referencing user-controlled values (CWE-94) |
| SA-TEST-47 | ERROR | `$deepMerge` nulling security-critical keys |
| SA-TEST-48 | ERROR | `securityParity: false` detected |
| SA-TEST-49 | ERROR | `allowReal:` wildcards and security-sensitive commands |
| SA-TEST-50 | WARN | Integration-mode hooks must respect capability boundaries |
| SA-TEST-51 | WARN | Fault injection tests must use `repeat: >= 10` |
| SA-TEST-52 | WARN | Test secret value matches credential pattern (length > 20 or JWT/base64/connection string) |

---

### 1.59 Environment Variables (SA-ENV-\*)

#### SA-ENV-1 -- `ENV.*` reference matches common credential pattern ✅ ERROR

An `ENV.*` reference whose name matches a common credential naming pattern is a static analysis error. Credentials MUST use `SECRET.*` instead of `ENV.*` — `SECRET.*` provides mandatory redaction, opacity, and pluggable backend support. `ENV.*` values are plain strings that can be logged, returned, emitted, and interpolated without any redaction.

Credential patterns: `*_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_CREDENTIAL`, `*_CREDENTIALS`, `*_API_KEY`, `*_APIKEY`, `*_AUTH`, `*_PRIVATE_KEY`, `*_CONN_STRING`, `*_CONNECTION_STRING`, `*_DSN`, `*_PASSPHRASE`, `*_PIN`.

**Bad:**
```yaml
- request:
    url: 'https://api.example.com'
    auth:
      bearer: =ENV.API_KEY       # ERROR: credential pattern — use SECRET.api_key
```

**Good:**
```yaml
- request:
    url: 'https://api.example.com'
    auth:
      bearer: =SECRET.api_key    # opaque, redacted, capability-gated
```

---

#### SA-ENV-2 -- `set:` assignment from `ENV.*` credential pattern ✅ ERROR

Assigning an `ENV.*` value that matches a credential pattern (`*_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_CREDENTIAL`, `*_CREDENTIALS`, `*_API_KEY`, `*_APIKEY`, `*_AUTH`, `*_PRIVATE_KEY`, `*_CONN_STRING`, `*_CONNECTION_STRING`, `*_DSN`, `*_PASSPHRASE`, `*_PIN`) to a flow variable via `set:` is a static analysis error. Copying credential values into flow variables makes them loggable, returnable, and emittable — bypassing the redaction guarantees that `SECRET.*` provides.

**Bad:**
```yaml
- set:
    api_key: =ENV.API_KEY           # ERROR: credential copied to loggable variable
    db_pass: =ENV.DB_PASSWORD       # ERROR: credential copied to loggable variable
```

**Good:**
```yaml
# Use SECRET.* instead of ENV.* for credentials
- request:
    url: "https://api.example.com"
    auth:
      bearer: =SECRET.api_key      # opaque, redacted, never assigned to variable
```

---

#### SA-ENV-3 -- Any `ENV.*` usage 📋 WARN

Any reference to `ENV.*` emits a warning. `ENV.*` values are plain strings — they provide NO security guarantees: no opacity, no mandatory redaction at the application level, no taint tracking. Values can be logged, returned, emitted, and interpolated freely. If an `ENV.*` value may contain credentials, connection strings, or other sensitive material, use `SECRET.*` instead, which provides mandatory redaction, opacity, and pluggable backend support.

This rule complements SA-ENV-1 and SA-ENV-2 (which catch known credential patterns) by providing a general nudge for all `ENV.*` usage, including patterns like `*_URI` and `*_URL` that are too broad for ERROR-level rules. The engine MUST support configurable custom credential patterns at the engine level, so operators can extend SA-ENV-1 detection beyond the built-in list.

**Warn:**
```yaml
- set:
    base_url: =ENV.SERVICE_URL        # WARN: ENV values are not redacted — use SECRET.* for credentials
- log: "Region: {{ENV.AWS_REGION}}"   # WARN: ENV values are not redacted — use SECRET.* for credentials
```

---

#### SA-ENV-4 -- `ENV.*` value assigned to variable without `$exportable: false` when the ENV value matches a URI-with-credentials pattern or JWT pattern 📋 WARN

An `ENV.*` value whose name does not match a credential pattern (SA-ENV-1) but whose runtime value may contain embedded credentials (URI-with-credentials like `postgres://user:pass@host/db`, or JWT tokens) is assigned to a flow variable without `$exportable: false`. The variable can be logged, returned, or emitted, potentially leaking embedded credentials.

**Warn:**
```yaml
- set:
    db_url: =ENV.DATABASE_URL           # WARN: may contain credentials in URI — add $exportable: false
```

**Good:**
```yaml
flowmarkup:
  vars:
    db_url:
      $kind: STRING
      $exportable: false
  do:
    - set:
        db_url: =ENV.DATABASE_URL       # OK: non-exportable
```

---

#### SA-ENV-5 -- `ENV.*` credential pattern used in `return:` value 📋 ERROR

`ENV.*` reference matching credential pattern used in `return:` value. Credential patterns: keys containing PASSWORD, SECRET, TOKEN, API_KEY, PRIVATE_KEY, CREDENTIAL, AUTH (case-insensitive).

**Bad:**
```yaml
- return: =ENV.DB_PASSWORD                    # ERROR: credential pattern in return value
```

---

#### SA-ENV-6 -- `ENV.*` credential pattern used in `emit.data:` value 📋 ERROR

`ENV.*` reference matching credential pattern used in `emit.data:` value. Same credential pattern matching as SA-ENV-5.

**Bad:**
```yaml
- emit:
    event: config_ready
    data: { token: =ENV.API_TOKEN }           # ERROR: credential pattern in emit data
```

---

#### SA-ENV-7 -- `ENV.*` credential pattern used in `log:` expression 📋 ERROR

`ENV.*` reference matching credential pattern used in `log:` expression. Same credential pattern matching as SA-ENV-5.

**Bad:**
```yaml
- log: "Auth token: {{ENV.AUTH_TOKEN}}"       # ERROR: credential pattern in log
```

---

#### SA-ENV-8 -- `ENV.*` credential pattern in URL/query/host/args context 📋 ERROR

`ENV.*` reference matching credential patterns (`*_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_API_KEY`, `*_AUTH`, `*_PRIVATE_KEY`, `*_CONN_STRING`, `*_DSN`) used in `request.url:`, `request.query:`, `ssh.host:`, or `exec.args:` context. Credential-pattern ENV values in these contexts leak to server logs, proxy logs, process listings, or network intermediaries. Use `SECRET.*` instead. *(CWE-522: Insufficiently Protected Credentials)*

---

### 1.60 Cancel (SA-CANCEL-\*)

#### SA-CANCEL-1 -- `cancel:` references an identifier that is not a FlowHandle 📋 ERROR

The `cancel:` directive requires a FlowHandle identifier (produced by `handle:` on a `run:` step with `async: true`). Referencing a regular variable, const, or undefined name is a static error.

**Bad:**
```yaml
- set:
    my_var: "some_value"
- cancel: my_var                     # ERROR: my_var is not a FlowHandle
```

**Good:**
```yaml
- run:
    flow: "worker.flowmarkup.yaml"
    async: true
    handle: worker_handle
- cancel: worker_handle              # OK: worker_handle is a FlowHandle
```

---

### 1.61 Context Access (SA-CTX-\*)

#### SA-CTX-1 -- `CONTEXT.*` access to key not declared in `requires.CONTEXT` 📋 ERROR

When `requires: { CONTEXT: [...] }` uses the per-key form, accessing a CONTEXT key not in the declared list is an error. Mirrors SA-GLOBAL-2 for GLOBAL scope.

**Bad:**
```yaml
flowmarkup:
  requires:
    CONTEXT: [correlation_id]
  do:
    - log: "Tenant: {{CONTEXT.tenant_id}}"   # ERROR: tenant_id not declared
```

**Good:**
```yaml
flowmarkup:
  requires:
    CONTEXT: [correlation_id, tenant_id]
  do:
    - log: "Tenant: {{CONTEXT.tenant_id}}"   # OK: declared
```

---

#### SA-CTX-2 -- `CONTEXT.*` write to key not in write-list (when object form used) 📋 ERROR

When `requires.CONTEXT` uses the object form with explicit read/write lists, writing to a key not in the write-list is an error.

**Bad:**
```yaml
flowmarkup:
  requires:
    CONTEXT:
      read: [correlation_id, tenant_id]
      write: [correlation_id]
  do:
    - set:
        CONTEXT.tenant_id: "new_tenant"   # ERROR: tenant_id is read-only
```

---

#### SA-CTX-3 -- `CONTEXT.*` read-modify-write without `lock:` 📋 WARN

A multi-step read-modify-write pattern where `CONTEXT.*` is read in one step and written in a subsequent step without being enclosed in a `lock:` directive. Same pattern as SA-GLOBAL-3 but for the CONTEXT scope.

**Warn:**
```yaml
- set:
    count: =CONTEXT.request_count          # step A reads CONTEXT
- set:
    CONTEXT.request_count: =count + 1      # step B writes CONTEXT — WARN: multi-step RMW without lock
```

**Good:**
```yaml
- lock:
    name: ctx_counter
    scope: CONTEXT
    timeout: 5s
    do:
      - set:
          count: =CONTEXT.request_count
      - set:
          CONTEXT.request_count: =count + 1   # OK: inside lock
```

---

#### SA-CTX-4 -- Array-form `CONTEXT: [...]` grants read-write but flow body only reads 📋 INFO

When `requires.CONTEXT` uses the array form (which grants read-write access) but static analysis detects no writes to any declared CONTEXT key in the flow body, this rule suggests narrowing to the object form with explicit `read:` list for least-privilege.

**Info:**
```yaml
flowmarkup:
  requires:
    CONTEXT: [tenant_id]           # INFO: no CONTEXT writes detected; consider read-only form
  do:
    - log: "Tenant: {{CONTEXT.tenant_id}}"
```

**Good:**
```yaml
flowmarkup:
  requires:
    CONTEXT: { read: [tenant_id] }  # OK: explicit read-only
  do:
    - log: "Tenant: {{CONTEXT.tenant_id}}"
```

---

### 1.62 DNS (SA-DNS-\*)

#### SA-DNS-1 -- Remote flow URL without DNS pinning requirement note 📋 WARN

A `run:` step references a remote flow by URL (e.g., `https://...`) without an `integrity:` hash. DNS resolution of the remote host is subject to DNS rebinding or spoofing attacks. Consider adding `integrity:` to verify the fetched content, or document the DNS trust assumptions.

---

### 1.63 Flow Handle (SA-HANDLE-\*)

#### SA-HANDLE-1 -- `handle:` declared but never used in `source:` or `cancel:` 📋 WARN

A `handle:` binding on a `run:` step is declared but never referenced in a `source:` filter or `cancel:` directive. The handle is dead code.

**Warn:**
```yaml
- run:
    flow: "worker.flowmarkup.yaml"
    async: true
    handle: worker_handle           # WARN: never used in source: or cancel:
```

---

#### SA-HANDLE-2 -- `handle:` name conflicts with `vars:`, `const:`, or `forEach.as:` binding 📋 ERROR

The `handle:` identifier must not shadow an existing variable, constant, or loop binding name.

**Bad:**
```yaml
flowmarkup:
  vars:
    worker_handle: null
  do:
    - run:
        flow: "worker.flowmarkup.yaml"
        async: true
        handle: worker_handle       # ERROR: conflicts with vars: name
```

---

#### SA-HANDLE-3 -- FlowHandle used at output boundary (log, return, emit, yield) 📋 ERROR

A FlowHandle is an opaque runtime reference that must not be exposed via `log:`, `return:`, `emit:`, or `yield:`. FlowHandles contain internal engine state and are not serializable.

**Bad:**
```yaml
- run:
    flow: "worker.flowmarkup.yaml"
    async: true
    handle: worker_handle
- log: "Handle: {{worker_handle}}"    # ERROR: FlowHandle at output boundary
- return: { handle: =worker_handle }  # ERROR: FlowHandle at output boundary
```

---

#### SA-HANDLE-4 -- `cancel:` on a synchronous (non-async) `run:` step handle 📋 WARN

A `cancel:` references a handle from a `run:` step that is not `async: true`. Synchronous `run:` steps block until completion, so by the time `cancel:` is reached, the sub-flow has already finished.

**Warn:**
```yaml
- run:
    flow: "worker.flowmarkup.yaml"
    handle: worker_handle           # synchronous — blocks until done
- cancel: worker_handle             # WARN: run already completed
```

---

### 1.64 Taint Tracking (SA-TAINT-\*)

#### SA-TAINT-1 -- `$secret: true` variable used at output boundary 📋 ERROR

A variable declared with `$secret: true` has the same output restrictions as `SECRET.*` values. Using it in `log:`, `return:`, `emit.data:`, `yield:`, `request.url:`, `throw.message:`, `assert:`, `switch.value:`, `forEach.items:`, `throw.data:`, `mail.body:`, `mail.subject:`, `mail.to:`, `mail.cc:`, or `mail.bcc:` is a secret leak.

**Bad:**
```yaml
flowmarkup:
  vars:
    api_key:
      $kind: STRING
      $secret: true
  do:
    - call:
        service: auth
        operation: getToken
        result: { api_key: =RESULT.token }
    - log: "Key: {{api_key}}"              # ERROR: $secret variable in log
```

---

#### SA-TAINT-2 -- `$exportable: false` variable used at output boundary 📋 ERROR

A variable declared with `$exportable: false` must not cross output boundaries. It may be used internally and passed to action `params:`, but must not appear in `log:`, `return:`, `emit.data:`, `yield:`, `request.url:`, `throw.message:`, `assert:`, `switch.value:`, `forEach.items:`, `mail.body:`, `mail.subject:`, `mail.to:`, `mail.cc:`, or `mail.bcc:`.

**Bad:**
```yaml
flowmarkup:
  vars:
    internal_token:
      $kind: STRING
      $exportable: false
  do:
    - set:
        internal_token: =ENV.INTERNAL_TOKEN
    - return: { token: =internal_token }   # ERROR: $exportable: false variable in return
```

---

#### SA-TAINT-3 -- `$secret: true` with explicit `$exportable: false` (redundant) 📋 INFO

A variable declared with both `$secret: true` and `$exportable: false` has a redundant annotation. `$secret: true` implies non-exportable. The explicit `$exportable: false` is harmless but unnecessary.

**Info:**
```yaml
vars:
  token:
    $kind: STRING
    $secret: true
    $exportable: false      # INFO: redundant — $secret implies non-exportable
```

---

#### SA-TAINT-4 -- Action provider declares output field as sensitive, but receiving `set:` target lacks annotation 📋 ERROR

Action provider declares output field as sensitive: true, but receiving set: target lacks $exportable: false or $secret: true annotation. When no sensitive: declaration exists on the provider output, this rule remains WARN for auth/credential-related operations.

**Error:**
```yaml
- call:
    service: auth
    operation: getToken
    result: { access_token: =RESULT.token }   # ERROR: auth result without $exportable: false
```

**Good:**
```yaml
flowmarkup:
  vars:
    access_token:
      $kind: STRING
      $exportable: false
  do:
    - call:
        service: auth
        operation: getToken
        result: { access_token: =RESULT.token }   # OK: variable is non-exportable
```

---

#### SA-TAINT-5a -- CEL expression references `$secret: true` variable, but `set:` target lacks `$secret: true` 📋 ERROR

CEL expression references `$secret: true` variable, but `set:` target lacks `$secret: true`. Derived values from secret sources MUST preserve secret taint.

**Error:**
```yaml
vars:
  api_secret:
    $kind: STRING
    $secret: true
do:
  - set:
      derived_key: ="prefix_" + api_secret   # ERROR: derived from $secret: true source, but derived_key lacks $secret: true
```

**Good:**
```yaml
vars:
  api_secret:
    $kind: STRING
    $secret: true
  derived_key:
    $kind: STRING
    $secret: true                             # Secret taint propagated to derived variable
```

---

#### SA-TAINT-5b -- CEL expression references `$exportable: false` variable, but `set:` target lacks `$exportable: false` 📋 ERROR

CEL expression references `$exportable: false` variable, but `set:` target lacks `$exportable: false`. Without propagating `$exportable: false`, derived values can be logged, returned, or emitted — a concrete data leak path. *(CWE-200)*

**Error:**
```yaml
vars:
  auth_token:
    $kind: STRING
    $exportable: false
do:
  - set:
      token_header: ="Bearer " + auth_token   # ERROR: derived from non-exportable source, but token_header has no taint
  - log: "Header: {{token_header}}"            # Leak! token_header contains auth_token value
```

**Good:**
```yaml
vars:
  auth_token:
    $kind: STRING
    $exportable: false
  token_header:
    $kind: STRING
    $exportable: false                         # Taint propagated to derived variable
```

---

#### SA-TAINT-6 -- `$sanitized: true` without verified sanitization provenance 📋 ERROR

A variable declares `$sanitized: true` but the value's data-flow trace does not show it passed through a recognised sanitization function (`htmlSanitize()`, `htmlEscape()`, `regexQuote()`) or a service declared as a sanitizer. When `$sanitizedBy` is present, the engine validates at load time that the named function/service was applied. When `$sanitized: true` is used without `$sanitizedBy` and no built-in sanitization is detected in the data flow, this rule fires at ERROR severity. *(CWE-79: Cross-Site Scripting)*

---

#### SA-TAINT-7 -- `set:` target receives secret-derived CEL result without `$secret: true` 📋 ERROR

`set:` target receives the result of a CEL expression containing a `$secret: true` operand but does not itself declare `$secret: true`. Detection: For every `set:` step, resolve CEL expression operands. If any operand references a variable with `$secret: true` annotation, verify the `set:` target also declares `$secret: true`. If not, emit ERROR. Rationale: Prevents accidental declassification of secret-derived values.

---

#### SA-TAINT-8 -- `$declassify: true` used to override secret taint requirement 📋 ERROR

`set:` target declares `$declassify: true` to explicitly acknowledge receipt of secret-derived data without `$secret: true` annotation. `$declassify` removes the entire secret taint, which may allow secret-derived values to reach output boundaries. The `$declassify` annotation MUST include a `via:` field naming a function from the normative safe-transform list (`sha256`, `sha384`, `sha512`, `hmac`, `bcrypt`, `argon2`, `scrypt`, `sha3_256`, `sha3_512`, `blake2b`, `blake2s`). When the engine's `declassify.policy` is `DENY` (the default), this rule fires at ERROR. When `declassify.policy` is `AUDIT_REQUIRED`, the engine MUST emit a structured audit event for every `$declassify` resolution. *(CWE-200, CWE-778)*

---

#### SA-TAINT-9 -- `$exportable: false` variable used in request to non-allowlisted or dynamic URL 📋 WARN

`$exportable: false` variable used in `request.body:` value where the request target is not in the flow's declared `requires.REQUEST` allowlist or where the target is a CEL expression (dynamic URL). Potential exfiltration path.

**Multi-secret `$declassify` lineage check:** When a `$declassify` annotation references multiple source secrets (e.g., a derived value combining `SECRET.a` and `SECRET.b`), SA-TAINT-9 MUST additionally verify that ALL source secrets are listed in the `$declassify.sources` array. If any source secret is missing from the lineage declaration, the `$declassify` annotation is incomplete and MUST be treated as invalid — the variable retains full taint from all sources. SA-TAINT-9a (ERROR) MUST fire when a `$declassify` annotation omits one or more source secrets from its lineage. This prevents partial declassification where an author declassifies a composite value by listing only one of its secret sources.

---

#### SA-TAINT-9a -- Multi-secret `$declassify` lineage incomplete 📋 ERROR

A `$declassify` expression references multiple secret sources, but `$declassify.sources` does not list all contributing secrets. Incomplete lineage prevents the audit trail from recording which secrets contributed to the declassified value. *(CWE-200, CWE-778)*

---

#### SA-TAINT-10 -- `$exportable: false` variable passed to `call.params:` 📋 INFO

`$exportable: false` variable passed to `call.params:`. Informational — the action provider receives the value.

---

#### SA-TAINT-11 -- Action result may carry secret-derived data without taint annotation 📋 WARN

An action step's output (`RESULT.*`) is derived from a secret-tainted input (e.g., a request that sends a secret value and receives a response), and the result is used in a subsequent step without `$secret` annotation. When a secret is sent to an external service (e.g., as a request header), the response may echo or derive from that secret. The taint system should track this transitive relationship.

> **Rationale:** CWE-200 (Exposure of Sensitive Information). Action result used in subsequent CEL expression where any input is tainted (secret-derived) but output lacks `$secret` annotation.

---

#### SA-TAINT-12 -- Secret/non-exportable variable written to `GLOBAL.*` scope 📋 WARN

Variable with `$secret: true` or `$exportable: false` written to `GLOBAL.*` scope. Taint annotations do not propagate across flow boundaries through `GLOBAL.*` — a subsequent flow reading the GLOBAL key will receive an untainted value, breaking the taint chain. Flow authors MUST NOT write secret-derived or non-exportable values to GLOBAL scope. *(CWE-200: Exposure of Sensitive Information)*

---

#### SA-TAINT-13 -- `$declassify` used inside `defaults:` block 📋 ERROR

`$declassify: true` found on a variable definition inside a `defaults:` block. Declassification is an explicit, per-step decision — applying it as a blanket default undermines the taint model by silently removing secret taint from every step that inherits the default. Flow authors MUST NOT place `$declassify` in `defaults:` blocks. *(CWE-266: Incorrect Privilege Assignment)*

**Bad:**
```yaml
defaults:
  auth_result:
    $declassify: true
    via: sha256
```

**Good:**
```yaml
do:
  - set:
      auth_result:
        $declassify: true
        via: sha256
        value: =SECRET.token
```

---

### 1.65 Call Action (SA-CALL-\*)

---

#### SA-CALL-1 -- `call` step missing required `operation:` ✅ ERROR

Every `call` step MUST specify an `operation:` field — the method to invoke on the service (`service.operation(params)`).

**Bad:**
```yaml
- call:
    service: payment
    params:
      amount: =total
```

**Good:**
```yaml
- call:
    service: payment
    operation: charge
    params:
      amount: =total
```

---

#### SA-CALL-2 -- `operation:` is not a valid identifier ✅ ERROR

A bare (non-CEL) `operation:` value must be a valid programming-language identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$`. CEL expressions (prefixed with `=`) are not checked statically.

**Bad:**
```yaml
- call:
    service: payment
    operation: "charge-card"    # hyphens not allowed in identifiers
```

**Good:**
```yaml
- call:
    service: payment
    operation: charge_card
```

---

#### SA-CALL-3 -- `call` params contain string-concatenated query with user input 📋 WARN

A `call` step passes a `params:` value that constructs a query string by concatenating user-controlled input (flow input parameters, `EVENT.DATA.*`, `CONTEXT.*`). This pattern is vulnerable to SQL injection (CWE-89), NoSQL injection, or LDAP injection depending on the service backend. Use parameterized queries via the service contract instead. Detection also covers `{{ }}` template interpolation and CEL `format()` calls containing user-controlled variables when the param name matches database/query patterns.

**Warn:**
```yaml
- call:
    service: database
    operation: query
    params:
      sql: ="SELECT * FROM users WHERE id = '" + user_id + "'"   # WARN: SQL injection risk
```

**Good:**
```yaml
- call:
    service: database
    operation: query
    params:
      sql: "SELECT * FROM users WHERE id = :id"
      bindings: { id: =user_id }                                  # OK: parameterized query
```

---

### 1.66 Set Directive (SA-SET-\*)

---

#### SA-SET-1 -- `condition` key in `set:` body ✅ WARN

`set:` does not support the `condition:` step-level guard — its body keys are variable names. A `condition` key creates/overwrites a variable named `condition` instead of guarding execution. Use `if:` wrapper for conditional `set:`.

**Bad:**
```yaml
- set:
    condition: =!has(priority)
    priority: normal
# Creates a variable named 'condition' with value of =!has(priority)
```

**Good:**
```yaml
- if:
    condition: =!has(priority)
    then:
    - set:
        priority: normal
```

---

### 1.67 Parallel Shorthand (SA-PARALLEL-\*)

#### SA-PARALLEL-1 -- `parallel:`/`race:` shorthand has no branch names ✅ ERROR

All keys inside `parallel:` or `race:` are config keys — no actual branch was defined. At least one branch is required.

Config keys are: `_id_`, `_label_`, `_notes_`, `_meta_`, `condition`, `timeout`, `onTimeout`, `failPolicy`, `transaction`, `onRollbackError`, `locking`, `defaults`. Any key that is **not** in this set is treated as a branch name.

**Bad:**
```yaml
- parallel:
    timeout: 30s
    failPolicy: COMPLETE
    # ERROR: only config keys — no branches
```

**Good:**
```yaml
- parallel:
    timeout: 30s
    failPolicy: COMPLETE
    fetch_inventory:
    - call:
        service: warehouse
        operation: check_stock
    charge_payment:
    - call:
        service: billing
        operation: charge
```

#### SA-PARALLEL-2 -- `parallel:`/`race:` branch name collides with a config key ✅ ERROR

A branch name inside `parallel:` or `race:` is identical to one of the reserved config key names: `timeout`, `failPolicy`, `defaults`, `transaction`, `onRollbackError`, `locking`, `onTimeout`, `condition`, `_id_`, `_label_`, `_notes_`, or `_meta_`. Because the shorthand parser treats these keys as config options rather than branch names, the branch would be silently ignored. Use the full `group:` form with explicit `branches:` when a branch must carry one of these names.

> **Note:** The JSON schema structurally prevents these collisions — annotation keys and config keys are explicit `properties` in the `parallelStep`/`raceStep` schema, while branch names fall under `additionalProperties`. SA-PARALLEL-2 provides defense-in-depth for implementations that do not use strict JSON Schema validation.

---

### 1.68 Race (SA-RACE-\*)

#### SA-RACE-1 -- RACE branches with side-effectful actions without `rollback:` handlers 📋 WARN

RACE branches containing side-effectful actions (`call`, `request`, `exec`, `ssh`, `mail`, `storage`, `emit`) without corresponding `rollback:` handlers. When one RACE branch wins, losing branches are cancelled, but their side effects may have already committed. Without `rollback:` handlers, these effects cannot be compensated.

---

### 1.69 Params (SA-PARAM-\*)

#### SA-PARAM-5 -- Null-value shorthand with unassigned matching variable 📋 WARN

Null-value shorthand (`params: { key: }`) resolves to the local variable with the same name. When that variable has not been explicitly assigned in the current scope, the shorthand silently passes `null`, which is likely unintentional.

---

## Part 2: Runtime Checks

Rules that require execution context or behavioral understanding.

---

### 2.1 Concurrency (SA-CONC-\*)

These checks detect data races, deadlocks, and ordering hazards that manifest at runtime. Some can be detected statically when the concurrent structure is visible; others require execution-time observation.

#### SA-CONC-1 -- `onTimeout:` handler writes to variables also written by main body ✅ ERROR

The handler runs concurrently by definition -- any shared write is an uncontrolled data race.

**Bad:**
```yaml
- group:
    timeout: "30s"
    onTimeout:
      after: "10s"
      do:
        - set:
            status: 'timeout'    # ERROR: races with main body's status writes
    do:
      - call:
          service: SERVICES.worker
          result: { status: =RESULT.status }
```

---

#### SA-CONC-2 -- `rateLimit strategy: WAIT` inside `lock` body 📋 WARN

The rate limiter may block while holding the lock, starving other lock waiters.

---

#### SA-CONC-3 -- `LOCAL.*` writes inside `lock SHARED` in concurrent context 📋 WARN

`mode: SHARED` protects `GLOBAL.*`/`CONTEXT.*` reads but does NOT protect flow-local variable writes.

---

#### SA-CONC-4 -- `emit` inside `lock` body (non-transaction) 📋 INFO

The event is dispatched immediately while the lock is held. A concurrent `waitFor` handler acquiring the same lock will deadlock.

---

#### SA-CONC-5 -- `return` inside `transaction: true` with `locking: OPTIMISTIC` 📋 WARN

`ConflictError` at commit time causes `RolledBackError` instead of the expected return value.

---

#### SA-CONC-6 -- RACE group with unreachable dependent branches 📋 INFO

If a root branch wins before a prerequisite completes, branches with `dependsOn:` are never activated.

---

#### SA-CONC-7 -- Async sub-flow writing `GLOBAL.*` variables also read by caller 📋 WARN

The ordering between the async sub-flow's writes and the parent's reads is non-deterministic.

---

#### SA-CONC-8 -- `lock EXCLUSIVE` body writes `LOCAL.*` without `transaction: true` 📋 INFO

`lock EXCLUSIVE` buffers/rolls back `GLOBAL.*`/`CONTEXT.*` writes on error, but `LOCAL.*` writes are immediate and not rolled back.

---

### 2.2 Runtime Errors (Non-Catchable)

These errors occur before or outside the flow body and cannot be handled by `catch:` clauses.

| Error | Trigger | Notes |
|---|---|---|
| `SchemaLoadError` | External schema file not found or malformed | Load time |
| `SchemaResolutionError` | `$ref` cannot be resolved | Load time |
| `FlowVersionError` | Flow content changed at resume, no `onVersionChange:` declared | Pre-execution |
| `MigrationError` | `onVersionChange:` handler threw, or step cursor invalid | Pre-execution |
| `ConfigurationError` | Service alias conflict, invalid provider, missing required config | Load time |

### 2.3 Runtime Errors (Catchable)

These errors occur during flow execution and can be caught with `catch:`:

| Error | Trigger | Typical Handling |
|---|---|---|
| `TimeoutError` | Step or group exceeded `timeout:` | `catch:` with retry or fallback |
| `HttpError` | `request` received 4xx/5xx (when status not mapped) | `catch:` with `ERROR.DATA.status` check |
| `ExecError` | `exec` process exited non-zero (when exitCode not mapped) | `catch:` with `ERROR.DATA.exitCode` |
| `CircuitOpenError` | Circuit breaker OPEN | `catch:` -- do not retry; back off |
| `ConflictError` | Optimistic transaction serialization conflict | Propagates as `RolledBackError` |
| `RolledBackError` | All rollback handlers ran successfully | `ERROR.CAUSE` = original error |
| `RollbackFailedError` | Some rollback handlers failed | `ERROR.DATA.failed` = [{label, error}] |
| `DeadlockError` | Runtime deadlock detected in lock wait-for graph | Engine aborts one participant |
| `RateLimitError` | Rate limit exceeded with `strategy: REJECT` | `catch:` |
| `ValidationError` | Input/output contract violation | Permanent -- do not retry |
| `AuthenticationError` | Identity unverifiable (HTTP 401 equivalent) | Permanent -- credential refresh needed |
| `AccessDeniedError` | Operation not permitted (HTTP 403 equivalent) | Permanent -- escalate permissions |
| `MissingCapabilityError` | Flow lacks required capability | Permanent -- requires capability grant |
| `NotFoundError` | Referenced resource, flow, or service not found | Permanent -- check references |
| `BadRequestError` | Malformed action input | Permanent -- fix the request |
| `ConnectionError` | Network connection failed | Transient -- retry with backoff |
| `TLSError` | TLS handshake failure, certificate error | Check certificate chain |
| `AddressError` | Invalid email address (`mail` action) | Permanent -- fix address |
| `SmtpError` | SMTP server rejected message | May be transient or permanent |
| `EncodeError` | Value out of range for encode format | Fix the data |
| `ParseError` | Malformed JSON/YAML/CSV/TSV in `decode()`/`parse()` | Fix upstream data source |
| `AssertionError` | `assert:` condition evaluated to false | Logic error -- do not retry |
| `DuplicateInvocationError` | Idempotency key already exists | Permanent -- duplicate request |
| `StorageError` | Storage operation failed (remote I/O, permission, quota) | Permanent -- check storage config |
| `StoragePathError` | Path traversal or invalid path | Permanent -- fix the path |
| `SshError` | Remote command exited non-zero | Check command/args |
| `UnsupportedProviderError` | Engine does not support the referenced provider | Engine configuration needed |

---

## Quick Reference: SA Rules

| Rule ID | Severity | Summary |
|---|---|---|
| **Core Semantic (CORE-\*)** | | |
| CORE-1 | ERROR | Input completeness -- required params not resolved |
| CORE-2 | WARN | Output reachability -- variable may be undefined on some paths |
| CORE-3 | WARN | Error coverage -- uncaught declared errors |
| CORE-4 | WARN | Type compatibility -- producer/consumer mismatch |
| CORE-5 | ERROR | Flow output fulfillment -- required output not set |
| CORE-6 | ERROR | Loop scope -- break/continue outside loop |
| CORE-7 | ERROR | Return output keys -- unknown or missing keys |
| CORE-8 | WARN | Throw error type -- unhandled throw |
| CORE-9 | INFO | Step label uniqueness |
| CORE-10 | WARN | Group branch variable conflicts -- parallel data race |
| CORE-11 | WARN | Concurrent forEach variable writes |
| CORE-12 | INFO | Assert reachability |
| CORE-13 | ERROR | Variable name format -- must be snake_case |
| CORE-14 | WARN | Error data key validation |
| CORE-15 | ERROR | Single/multi-param output contract consistency |
| CORE-16 | WARN | Call output form vs target contract |
| **Action Contract (AC-\*)** | | |
| AC-1 | ERROR | Required input params present |
| AC-2 | WARN | Output required params mapped |
| AC-3 | WARN | Errors subset of action declaration |
| **Identity (SA-ID-\*)** | | |
| SA-ID-1 | ERROR | Duplicate `_id_` within flow |
| **Data Elements & Init (SA-CONST/INIT/READONLY-\*)** | | |
| SA-CONST-1 | ERROR | Write to readonly data element |
| SA-CONST-2 | ERROR | Same name in const: and vars: |
| SA-INIT-1 | ERROR | const: CEL refs flow-local name |
| SA-INIT-2 | ERROR | vars: CEL refs another vars: name |
| SA-READONLY-1 | INFO | `$readonly: true` in `vars:` — suggest `const:` |
| SA-READONLY-2 | INFO | Redundant `$readonly` on read-only scope |
| **Expression Prefix (SA-INTERP/QUOTE-\*)** | | |
| SA-INTERP-1 | ERROR | `=` combined with `{{ }}` |
| SA-INTERP-2 | ERROR | `{{ }}` on boolean/numeric field |
| SA-QUOTE-1 | WARN | Missing `=` prefix on expression field |
| SA-QUOTE-2 | ERROR | `=` on carved-out field |
| **Result Binding (SA-RESULT-\*)** | | |
| SA-RESULT-1 | ERROR | Object-form result value missing `=` |
| SA-RESULT-2 | WARN | RESULT.* outside result: context |
| SA-RESULT-3 | ERROR | RESULT.* as write target |
| SA-RESULT-4 | WARN | List-form result key not in declared output |
| **Error Hierarchy (SA-ERR-\*)** | | |
| SA-ERR-1 | ERROR | Circular error inheritance |
| SA-ERR-2 | ERROR | Unknown $parent: reference |
| SA-ERR-3 | WARN | Unreachable catch key |
| SA-ERR-4 | ERROR | Error type self-parent |
| SA-ERR-5 | WARN | default catch-all before specific types |
| SA-ERR-6 | WARN | catch: on async: true step |
| SA-ERR-7 | ERROR | catch: combined with rollback: |
| SA-ERR-8 | ERROR | Nested hierarchy parent naming |
| SA-ERR-9 | ERROR | throws: includes caught error type |
| SA-ERR-10 | WARN | Unguarded child-specific ERROR.DATA field access in parent catch |
| SA-ERR-11 | WARN | ERROR.CAUSE chain depth in catch handlers (CWE-674) |
| **Variables (SA-VAR-\*)** | | |
| SA-VAR-1 | ERROR | Variable name not snake_case |
| SA-VAR-2 | ERROR | ENV.* write target |
| SA-VAR-3 | ERROR | Reserved prefixes in vars: |
| SA-VAR-4 | ERROR | Reserved variable name `event` |
| SA-VAR-5 | ERROR | CONTEXT.* in vars: |
| SA-VAR-6 | WARN | Missing = on expression-like value |
| SA-VAR-7 | WARN | camelCase variable name |
| SA-VAR-8 | WARN | Undeclared data element reference |
| **Type System (SA-TYPE-\*)** | | |
| SA-TYPE-1 | ERROR | Type name not PascalCase |
| SA-TYPE-2 | ERROR | $ref does not resolve |
| SA-TYPE-3 | ERROR | Circular $ref |
| SA-TYPE-4 | WARN | Property not in schema |
| SA-TYPE-5 | WARN | Required field missing in typed value |
| SA-TYPE-6 | WARN | Enum value not in schema |
| SA-TYPE-7 | INFO | $schema: on primitive with no constraints |
| SA-TYPE-8 | ERROR | $schema: and $enum: both present |
| SA-TYPE-9 | ERROR | External schema file not found |
| SA-TYPE-10 | ERROR | Type name in $schema: not in types: |
| SA-TYPE-11 | ERROR | http:// schema URL without integrity: |
| SA-TYPE-12 | WARN | https:// schema URL without integrity: |
| SA-TYPE-13 | ERROR | `$ref` URL targets private/internal IP range |
| SA-TYPE-14 | ERROR | `$ref` schema fetch redirect to private/internal IP range |
| SA-TYPE-15 | ERROR | `$ref` resolution depth exceeds 32 levels |
| **Secrets (SA-SECRET-\*)** | | |
| SA-SECRET-1..12 | ERROR | Various secret escape vectors |
| SA-SECRET-13 | WARN | SECRET.* without requires.SECRET (see SA-CAP-1) |
| SA-SECRET-14 | WARN | requires.SECRET lists secrets not used in flow body |
| SA-SECRET-15..17 | ERROR | Wrong type access, URL/args leaks |
| SA-SECRET-18 | ERROR | SECRET.* in request.query: — use headers instead |
| SA-SECRET-19 | ERROR | Inline secret definition in `cap:` — not supported |
| SA-SECRET-20 | ERROR | SECRET.* in `{{ }}` interpolation |
| SA-SECRET-21 | WARN | SECRET.* in exec.env: — /proc/PID/environ exposure |
| SA-SECRET-22 | ERROR | Function parameter receives SECRET.* value |
| SA-SECRET-23 | WARN | SECRET.* access in unbounded loop without rate limit |
| SA-SECRET-24 | ERROR | $declassify audit requirement (CWE-778, CWE-200) |
| SA-SECRET-25 | ERROR | hmac() key in $declassify not a SECRET.* reference (CWE-328) |
| SA-SECRET-26 | ERROR | SECRET.* in request.body/query/url (CWE-200, CWE-598) |
| SA-SECRET-27 | ERROR | Action provider error schema includes reflected input fields |
| **Log Injection (SA-LOG-\*)** | | |
| SA-LOG-1 | ERROR | Log injection via user-controlled input (CWE-117) |
| **Capabilities (SA-CAP-\*)** | | |
| SA-CAP-1 | WARN | Deny-by-default scope without requires: declaration |
| SA-CAP-2 | ERROR | INHERIT capability forwarding to unverified remote flow |
| SA-CAP-3 | ERROR | INHERIT when explicit enumeration is possible |
| SA-CAP-4 | ERROR | EXEC/SSH INHERIT to flow without integrity: verification |
| SA-CAP-5 | WARN | cap: INHERIT forwards unused capabilities to sub-flow |
| **Global Variable Access (SA-GLOBAL-\*)** | | |
| SA-GLOBAL-1 | ERROR | GLOBAL.* write without per-key declaration |
| SA-GLOBAL-2 | ERROR | GLOBAL.* access to undeclared key |
| SA-GLOBAL-3 | WARN | GLOBAL.* multi-step read-modify-write without lock: |
| **Circuit Breaker (SA-CB-\*)** | | |
| SA-CB-1 | ERROR | CB on async: true (see SA-ASYNC-1) |
| SA-CB-2 | WARN | CB name missing = on expression |
| SA-CB-3 | INFO | CB scope: LOCAL in concurrent context |
| SA-CB-4 | WARN | CB errors with permanent types |
| SA-CB-5 | INFO | CB + rollback: interaction |
| SA-CB-6 | WARN | Flow-level CB scope: LOCAL |
| SA-CB-7 | INFO | CB threshold: 1 |
| SA-CB-8 | WARN | CB vs retry errors mismatch |
| SA-CB-9 | WARN | CB inside lock body |
| SA-CB-10 | INFO | CB + condition: interaction |
| SA-CB-11 | WARN | Short CB name with scope: GLOBAL |
| SA-CB-12 | WARN | Dynamic CB name (CEL expression) |
| SA-CB-13 | ERROR | Integer CB shorthand without _id_ on step |
| **Rate Limiting (SA-RL-\*)** | | |
| SA-RL-1 | WARN | rateLimit WAIT without timeout: |
| SA-RL-2 | ERROR/WARN | rateLimit on async: true (see SA-ASYNC-1) |
| SA-RL-3 | WARN | Flow-level rateLimit scope: LOCAL |
| SA-RL-4 | INFO | rateLimit timeout: with strategy: REJECT |
| **Retry (SA-RETRY-\*)** | | |
| SA-RETRY-1 | ERROR | onErrors and nonRetryable both present |
| SA-RETRY-2 | WARN | Retrying permanent error types |
| SA-RETRY-3 | INFO | retry: =expr CEL reference form |
| **Async (SA-ASYNC-\*)** | | |
| SA-ASYNC-1 | WARN/ERROR | async: true with incompatible properties (consolidated) |
| **Exec (SA-EXEC-\*)** | | |
| SA-EXEC-1 | WARN | exec without requires.EXEC (see SA-CAP-1) |
| SA-EXEC-2 | ERROR | exec.command not in allowlist |
| SA-EXEC-3 | WARN | exec.command contains path separator |
| SA-EXEC-4 | WARN | exec without timeout: |
| SA-EXEC-5 | WARN | exitCode mapped + ExecError in errors |
| SA-EXEC-6 | INFO | exec.stdin with async: true |
| SA-EXEC-7 | WARN | exec.command contains shell metacharacters |
| SA-EXEC-8 | ERROR | requires.EXEC: INHERIT invalid |
| SA-EXEC-9 | WARN | cap.EXEC: INHERIT on run step |
| SA-EXEC-10 | ERROR | EXEC allowlist contains interpreter names |
| SA-EXEC-11 | WARN | exec args from untrusted input without validation |
| SA-EXEC-12 | WARN | exec args may start with `-` (argument injection) |
| SA-EXEC-13 | ERROR | User-controlled env values in exec.env (CWE-426) |
| **Mail (SA-MAIL-\*)** | | |
| SA-MAIL-1 | ERROR | No recipient |
| SA-MAIL-2 | ERROR | No mail capability |
| SA-MAIL-3 | WARN | No requires.MAIL (see SA-CAP-1) |
| SA-MAIL-4 | ERROR/WARN | async + incompatible props (see SA-ASYNC-1) |
| SA-MAIL-5 | INFO | No body: and no attachments: |
| SA-MAIL-6 | ERROR | `smtp.tls: NONE` — MUST be rejected |
| SA-MAIL-7 | *(Removed)* | verifyTLS removed — TLS always enabled |
| SA-MAIL-8..10 | WARN/INFO | Content/header/email warnings |
| SA-MAIL-11 | ERROR | Unquoted email address |
| SA-MAIL-12 | INFO | cap.MAIL: true — suggest restrictions |
| SA-MAIL-13 | WARN | mail with explicit from: |
| SA-MAIL-14 | INFO | body.html: with `{{ }}` interpolation — auto-escaped |
| SA-MAIL-15 | ERROR | `htmlRaw()` traces to untrusted source — use htmlSanitize() |
| SA-MAIL-16 | ERROR | `htmlRaw()` used outside `body.html:` context |
| SA-MAIL-17 | ERROR | `from:` address derived from user input (CWE-183) |
| SA-MAIL-18 | WARN | `cap: { MAIL: INHERIT }` on `run` step |
| SA-MAIL-19 | ERROR | `requires: { MAIL: INHERIT }` invalid |
| SA-MAIL-20 | WARN/ERROR | User-controlled mail recipients with unrestricted MAIL capability (CWE-183) |
| SA-MAIL-21 | ERROR | Mail subject: CR/LF injection from user input (CWE-93) |
| **HTTP Request (SA-REQ-\*)** | | |
| SA-REQ-1..4 | INFO/WARN | Request validation rules |
| SA-REQ-5 | *(Removed)* | verifyTLS removed — TLS always enabled |
| SA-REQ-6..8 | INFO/WARN | Request validation rules |
| SA-REQ-9 | WARN | cap.REQUEST: INHERIT on run step |
| SA-REQ-10 | ERROR | requires.REQUEST: INHERIT invalid |
| SA-REQ-11 | ERROR | request auth: over http:// scheme (CWE-319) |
| SA-REQ-12 | ERROR | request without requires.REQUEST (deny-by-default) |
| SA-REQ-13 | WARN | parseAs with async: true |
| SA-REQ-14 | INFO | RESULT.body.decode() where parseAs suffices (engine-level) |
| SA-REQ-15 | ERROR | SSRF via CEL-computed URL (CWE-918) |
| SA-REQ-16 | ERROR | CRLF injection in custom request headers (CWE-113) |
| SA-REQ-17 | WARN | Dynamic operation name detection (CWE-94) |
| SA-REQ-18 | ERROR | User-controlled header key names (CWE-113) |
| SA-REQ-19 | ERROR | Static request.url targeting private/internal IP ranges (CWE-918) |
| SA-REQ-20 | WARN | Unbounded request body size (CWE-400) |
| SA-REQ-21 | WARN | Request follows redirects without explicit followRedirects: (CWE-601) |
| **Storage (SA-STORAGE-\*)** | | |
| SA-STORAGE-1 | WARN | storage without requires.STORAGE (see SA-CAP-1) |
| SA-STORAGE-2 | ERROR | storage.url not in requires.STORAGE aliases/URL patterns |
| SA-STORAGE-3 | ERROR | storage.operation not in allowed operations |
| SA-STORAGE-4 | ERROR | requires.STORAGE: INHERIT invalid |
| SA-STORAGE-5 | WARN | cap.STORAGE: INHERIT on run step |
| SA-STORAGE-6 | ERROR | copy/move across different URL authorities |
| SA-STORAGE-7 | ERROR | transfer with top-level url:/path:/data:/parseAs: |
| SA-STORAGE-8 | WARN | storage delete/move (destructive) |
| SA-STORAGE-9 | WARN | storage without timeout: |
| SA-STORAGE-10 | WARN | storage put with async: true |
| SA-STORAGE-11 | INFO | transfer where source = target URL authority |
| SA-STORAGE-12 | ERROR | storage put with parseAs: |
| SA-STORAGE-13 | WARN | storage get + parseAs + async: true |
| SA-STORAGE-14 | WARN | Local storage provider in use |
| SA-STORAGE-15 | ERROR | url: contains userinfo component |
| SA-STORAGE-16 | WARN | Both url: path and path: field non-trivial |
| SA-STORAGE-17 | ERROR | path: contains URL-encoded traversal sequences |
| SA-STORAGE-18 | ERROR | path: incorporates user-controlled input without validation (CWE-22) |
| SA-STORAGE-19 | WARN | cacheHint: on mkdir (no applicable semantics) |
| SA-STORAGE-20 | WARN | Read-side cacheHint: properties on write operation |
| SA-STORAGE-21 | WARN | Write-side cacheHint: properties on read operation |
| SA-STORAGE-22 | WARN | negativeTtl: without negative: true |
| SA-STORAGE-23 | WARN | staleWhileRevalidate: with revalidation: NEVER |
| SA-STORAGE-24 | INFO | warm: true on streaming get (onYield:) |
| SA-STORAGE-25 | WARN | cacheHint: on async: true read operation |
| SA-STORAGE-26 | WARN | readYourWrites: true with async: true |
| SA-STORAGE-27 | INFO | scope: GLOBAL without varyBy: |
| SA-STORAGE-28 | INFO | ttl: exceeds 1 hour |
| SA-STORAGE-29 | WARN | invalidatePaths: with user-controlled input |
| SA-STORAGE-30 | WARN | readYourWrites: true on transfer operation |
| SA-STORAGE-31 | WARN | Symlink TOCTOU in local storage operations (CWE-367) |
| SA-STORAGE-32 | ERROR | storage.url CEL expression references user-controlled input without URL validation (CWE-918) |
| **SSH (SA-SSH-\*)** | | |
| SA-SSH-1 | ERROR | ssh.command contains shell metacharacters |
| SA-SSH-2 | ERROR | ssh.command not in requires.SSH allowlist |
| SA-SSH-3 | ERROR | SSH allowlist contains interpreter names |
| SA-SSH-4 | ERROR | requires.SSH: INHERIT invalid |
| SA-SSH-5 | WARN | cap.SSH: INHERIT on run step |
| SA-SSH-6 | WARN | ssh without timeout: |
| SA-SSH-7 | INFO | ssh.stdin with async: true |
| SA-SSH-8 | WARN | exitCode mapped + SshError in errors |
| SA-SSH-9 | WARN | ssh.command contains path separator |
| SA-SSH-10 | WARN | ssh args from untrusted input without validation |
| SA-SSH-11 | WARN | ssh env contains credentials via ENV instead of SECRET |
| SA-SSH-12 | ERROR | User-controlled SSH env values in ssh.env (CWE-426) |
| SA-SSH-13 | ERROR | ssh.host: derived from user-controlled input (CWE-918) |
| SA-SSH-14 | WARN | ssh.args: argument injection via leading `-` (CWE-88) |
| **Sub-Flow (SA-RUN-\*)** | | |
| SA-RUN-1 | ERROR | cap: grants unavailable capability |
| SA-RUN-2 | ERROR | Remote run: without integrity: |
| SA-RUN-4 | WARN | cap: INHERIT on remote run: without integrity: |
| SA-RUN-5 | ERROR | requires: not satisfied |
| SA-RUN-8 | WARN | async with context write |
| SA-RUN-9 | WARN | CURRENT without condition guard |
| SA-RUN-10..12 | WARN/INFO | Git ref warnings |
| SA-RUN-13 | WARN | GLOBAL write capability on run step |
| SA-RUN-14 | WARN | CONTEXT write capability on run step |
| SA-RUN-15 | WARN | `cap: { SUBFLOWS: CROSS_ORIGIN }` on run step |
| SA-RUN-16 | WARN | SUBFLOWS object contradictory |
| SA-RUN-18 | WARN | async: true with security-sensitive capabilities |
| SA-RUN-19 | ERROR | Remote run: with security-sensitive cap and no integrity: |
| SA-RUN-20 | WARN | async: true sub-flow writes to shared GLOBAL.* keys |
| SA-RUN-21 | WARN | Sub-flow with received capabilities and SUBFLOWS (CWE-269) |
| **Services (SA-SVC-\*)** | | |
| SA-SVC-1 | ERROR | Alias conflicts with engine service |
| SA-SVC-2 | WARN | operation: not in meta.operations |
| SA-SVC-3 | WARN | properties refs undeclared secret |
| SA-SVC-4 | ERROR | properties refs flow variable |
| SA-SVC-5 | INFO | requires key matches flow-defined alias |
| SA-SVC-6 | ERROR | cap escalation |
| SA-SVC-8 | ERROR | Write to SERVICES.* |
| SA-SVC-9 | WARN | Inline CEL alias not declared (see SA-CAP-1) |
| SA-SVC-10 | INFO | Inline service call in loop |
| SA-SVC-11 | ERROR | Non-conforming service provider ID format |
| **Call Action (SA-CALL-\*)** | | |
| SA-CALL-1 | ERROR | `call` step missing required `operation:` |
| SA-CALL-2 | ERROR | `operation:` is not a valid identifier |
| SA-CALL-3 | WARN | String-concatenated query with user input (injection risk) |
| **Set Directive (SA-SET-\*)** | | |
| SA-SET-1 | WARN | `condition` key in `set:` body — likely guard, not variable |
| **Parallel Shorthand (SA-PARALLEL-\*)** | | |
| SA-PARALLEL-1 | ERROR | `parallel:`/`race:` has only config keys — no branches |
| SA-PARALLEL-2 | ERROR | `parallel:`/`race:` branch name collides with a config key |
| **Race (SA-RACE-\*)** | | |
| SA-RACE-1 | WARN | RACE branches with side-effectful actions without rollback: handlers |
| **Params (SA-PARAM-\*)** | | |
| SA-PARAM-5 | WARN | Null-value shorthand with unassigned matching variable |
| **XML (SA-XML-\*)** | | |
| SA-XML-1 | WARN | xpath()/xpathAll() on parsed MAP variable |
| SA-XML-2 | ERROR | encode(XML) on MAP with != 1 top-level key |
| SA-XML-3 | ERROR | xpath()/xpathAll() concatenates untrusted variables (input:/EVENT.DATA/RESULT) |
| SA-XML-4 | INFO | xpath()/xpathAll() where decode(XML) with MAP access would suffice |
| **CSV (SA-CSV-\*)** | | |
| SA-CSV-1 | WARN | Formula-prefix characters in CSV output (CSV injection) |
| SA-CSV-2 | WARN | String literal with formula-prefix assigned to CSV-consumed variable |
| **Streaming (SA-YIELD-\*)** | | |
| SA-YIELD-1 | WARN | yield inside finally: |
| SA-YIELD-2 | WARN | yields: declared but no yield steps |
| SA-YIELD-3 | ERROR | onYield + async: true |
| SA-YIELD-4 | ERROR | onYield + retry: |
| SA-YIELD-5 | ERROR | yield inside onTimeout: |
| SA-YIELD-6 | ERROR | yield inside lock body |
| SA-YIELD-7 | WARN | yield steps without yields: |
| SA-YIELD-8 | ERROR | Form mismatch yield vs yields: |
| SA-YIELD-9 | ERROR | SECRET.* in yield |
| SA-YIELD-10 | ERROR | yield inside RACE branch |
| SA-YIELD-11 | WARN | FORWARD without yields: |
| SA-YIELD-12 | INFO | yield inside transaction: true |
| SA-YIELD-13 | INFO | onYield on non-yielding target |
| SA-YIELD-14 | WARN | Target yields but caller discards |
| SA-YIELD-15 | INFO | $yields but target has no yields: |
| SA-YIELD-16 | ERROR | onYield: and $yields on same step |
| SA-YIELD-17 | ERROR | $yields + async: true |
| SA-YIELD-18 | ERROR | $yields + retry: |
| SA-YIELD-19 | ERROR | onYield: on mail step |
| SA-YIELD-20 | ERROR | FORWARD inside RACE branch |
| SA-YIELD-21 | ERROR | yield inside rollback: handler |
| **Rollback (SA-ROLLBACK-\*)** | | |
| SA-ROLLBACK-1 | ERROR | rollback: on async: true (see SA-ASYNC-1) |
| SA-ROLLBACK-2 | WARN | rollback: refs undefined vars |
| SA-ROLLBACK-3..4 | WARN | Redundant/no-op transaction |
| SA-ROLLBACK-5 | INFO | rollback: without transaction: true |
| SA-ROLLBACK-6 | INFO/WARN | return inside transaction |
| SA-ROLLBACK-7 | INFO | rollback: without retry: |
| SA-ROLLBACK-8 | WARN | rollback: with cancellation risk |
| SA-ROLLBACK-9 | WARN | onRollbackError: without rollback: |
| SA-ROLLBACK-10 | INFO | emit inside transaction |
| **Transaction Isolation (SA-ISO-\*)** | | |
| SA-ISO-1..8 | WARN/ERROR | Various transaction/lock interaction rules |
| **Events (SA-EMIT/EVENT-\*)** | | |
| SA-EMIT-1..5 | ERROR/WARN | Event naming, matching, timeout, rate rules |
| SA-EVENT-1..9 | ERROR/WARN/INFO | Event contract validation rules |
| SA-EVENT-10 | ERROR/WARN | CONTEXT-scope emit/waitFor without events: (ERROR with sensitive caps) |
| SA-EVENT-11 | ERROR | GLOBAL waitFor without source: filter (CWE-345) |
| SA-EVENT-12 | WARN | CONTEXT waitFor without source: in flow with sub-flows |
| SA-EVENT-13 | WARN | GLOBAL waitFor without condition: data validation |
| SA-EVENT-14 | *(Consolidated into SA-EVENT-11)* | |
| SA-EVENT-15 | WARN | emit.data: payload size is unbounded (CWE-400) |
| **Triggers (SA-TRIGGER-\*)** | | |
| SA-TRIGGER-1 | WARN | Cron with required input params |
| SA-TRIGGER-2 | WARN | Event trigger without condition: filter on source/data |
| SA-TRIGGER-3 | WARN | Cron trigger on flow with EXEC, SSH, or SECRET capabilities |
| SA-TRIGGER-4 | WARN | Overlapping cron triggers — resource spike risk |
| SA-TRIGGER-5 | ERROR | Event trigger without scope: specification |
| **DAG (SA-DAG-\*)** | | |
| SA-DAG-1..5 | ERROR/WARN | Dependency graph validation |
| **Lock (SA-LOCK-\*)** | | |
| SA-LOCK-1..5, 7 | WARN/INFO | Lock timeout, ordering, reentrance |
| SA-LOCK-8 | WARN | Dynamic lock name (CEL expression) |
| SA-LOCK-9 | ERROR | Lock name from attacker-influenced fields (CWE-400) |
| SA-LOCK-10 | WARN | CEL-derived lock name without validation (CWE-74) |
| SA-LOCK-11 | INFO | Multiple GLOBAL-scoped locks — document acquisition order |
| **Control Flow (SA-CTRL-\*)** | | |
| SA-CTRL-1..5 | WARN/INFO/ERROR | Flow control structure validation |
| **Flow-Level (SA-FLOW-\*)** | | |
| SA-FLOW-1..3 | ERROR/WARN | Output contract and completion rules |
| SA-FLOW-4 | ERROR | Flow missing requires: declaration |
| SA-FLOW-8 | ERROR | Document exceeds 1 MB size limit |
| SA-FLOW-9 | ERROR | Flow exceeds 10,000 step limit |
| SA-FLOW-10 | ERROR | Flow nesting exceeds 32 levels |
| **CEL Expressions (SA-EXPR/CEL-\*)** | | |
| SA-EXPR-1..8 | ERROR/WARN | CEL API misuse, `$`-prefixed dot-access, `meta()` argument validation |
| SA-CEL-1 | ERROR | Forbidden introspection name |
| SA-CEL-3 | ERROR | CEL expression exceeds AST depth limit |
| SA-CEL-7 | ERROR | CEL expression AST node count exceeds engine limit (CWE-400) |
| SA-CEL-8 | WARN | `s.matches(pattern)` with user-controlled pattern (CWE-1333) |
| SA-CEL-4 | WARN | CEL collection operation on unbounded input |
| SA-CEL-5 | WARN | Nested collection expansion (quadratic/cubic risk) |
| SA-CEL-6 | ERROR | Unbounded string output from CEL expression (CWE-400) |
| **Regex (SA-REGEX-\*)** | | |
| SA-REGEX-1 | ERROR | Nested quantifiers in regex pattern |
| SA-REGEX-2 | ERROR | PCRE-only features in regex pattern (CWE-1333) |
| **Step-Level (SA-STEP-\*)** | | |
| SA-STEP-1..6 | INFO/WARN/ERROR | Retry delay, unreachable steps, constants |
| **Annotations (SA-META-\*)** | | |
| SA-META-1 | WARN | _id_ naming |
| **Version (SA-VER-\*)** | | |
| SA-VER-1, SA-VER-3..5 | WARN/ERROR | Migration handler rules |
| SA-VER-6 | ERROR | onVersionChange: writes to GLOBAL.*/CONTEXT.* or modifies $secret variables (CWE-269) |
| SA-VER-7 | ERROR | Action steps in onVersionChange handler |
| **Codec (SA-CODEC-\*)** | | |
| SA-CODEC-1..2 | WARN | Encode/decode format mismatch |
| **Idempotency (SA-IDEMP-\*)** | | |
| SA-IDEMP-1 | ERROR | Non-deterministic idempotencyKey |
| **Functions (SA-FN-\*)** | | |
| SA-FN-1..6 | WARN/ERROR | Function naming, recursion, arity |
| SA-FN-7 | ERROR | Function taint propagation — taint MUST propagate through boundaries |
| **Examples (SA-EX-\*)** | | |
| SA-EX-1..4 | ERROR/WARN | Example contract validation |
| **Nullable (SA-NULL-\*)** | | |
| SA-NULL-1..2 | WARN | Nullable contradictions |
| SA-NULL-3 | INFO | Redundant `$nullable: true` + `$default: null` |
| SA-NULL-4..5,7..8 | WARN | Null dereference detection |
| SA-NULL-6 | WARN | Nullable → non-nullable param (specified, deferred) |
| **Finally (SA-FINALLY-\*)** | | |
| SA-FINALLY-1..3 | ERROR/WARN | Return/break/throw inside finally: |
| **forEach (SA-FOREACH-\*)** | | |
| SA-FOREACH-1..2 | INFO/WARN | Concurrency and return in forEach |
| SA-FOREACH-3 | WARN | Missing maxItems on user-controlled iteration (CWE-400) |
| **Switch (SA-SWITCH-\*)** | | |
| SA-SWITCH-1 | WARN | Missing default |
| SA-SWITCH-2 | ERROR | Template in match |
| **YAML Syntax (SA-YAML-\*)** | | |
| SA-YAML-1 | WARN | `switch.match` key is YAML auto-typed value |
| SA-YAML-2 | ERROR | YAML merge key (`<<`) prohibited in flow definitions (CWE-915) |
| SA-YAML-3 | ERROR | YAML anchor/alias nesting depth exceeds engine limit (CWE-400) |
| **Loop (SA-LOOP-\*)** | | |
| SA-LOOP-1..2 | WARN | Infinite loop detection |
| SA-LOOP-3 | WARN | while/repeat without maxIterations and user-controlled condition (CWE-770) |
| **Timeout (SA-TIMEOUT-\*)** | | |
| SA-TIMEOUT-1 | WARN | Unbounded waitFor without timeout |
| **Wait (SA-WAIT-\*)** | | |
| SA-WAIT-1 | WARN | Unbounded waitUntil without timeout |
| **Defaults (SA-DEF-\*)** | | |
| SA-DEF-1 | WARN | defaults.circuitBreaker without name: |
| SA-DEF-2 | ERROR | Permanent error types in defaults.retry.onErrors |
| SA-DEF-3 | ERROR | defaults.timeout of 0 or negative value |
| SA-DEF-4 | WARN | defaults.rateLimit WAIT strategy without timeout |
| SA-DEF-5 | ERROR | defaults.circuitBreaker integer or string shorthand |
| **Resources (SA-RES-\*)** | | |
| SA-RES-1 | WARN | RESOURCES.* without requires (see SA-CAP-1) |
| SA-RES-2 | ERROR | RESOURCES as write target |
| SA-RES-3 | ERROR | Invalid filename in $name |
| **Kind Validation (SA-KIND-\*)** | | |
| SA-KIND-1 | ERROR | Unknown $kind constant |
| SA-KIND-2 | WARN | $kind and $type inconsistency |
| SA-KIND-3 | ERROR | $kind omitted and cannot be inferred |
| SA-KIND-4 | WARN | Inferred $kind from $default conflicts with $schema type |
| **Format Validation (SA-FORMAT-\*)** | | |
| SA-FORMAT-1 | ERROR | $format on non-string kind |
| SA-FORMAT-2 | ERROR | $format is not a valid regex |
| **Charset Validation (SA-CHARSET-\*)** | | |
| SA-CHARSET-1 | ERROR | `$charset` on BINARY kind |
| SA-CHARSET-2 | ERROR | `$charset` combined with `$encoding` |
| **Runtime Scope (SA-RT-\*)** | | |
| SA-RT-1 | WARN | RUNTIME.* without requires (see SA-CAP-1) |
| SA-RT-2 | ERROR | Write to RUNTIME.* |
| **Environment Variables (SA-ENV-\*)** | | |
| SA-ENV-1 | ERROR | ENV.* reference matches credential pattern |
| SA-ENV-2 | ERROR | ENV.* credential pattern assigned via set: |
| SA-ENV-3 | WARN | Any ENV.* usage (not redacted — use SECRET.* for credentials) |
| SA-ENV-4 | WARN | ENV.* URI-with-credentials or JWT assigned without $exportable: false |
| SA-ENV-5 | ERROR | ENV.* credential pattern in return: value |
| SA-ENV-6 | ERROR | ENV.* credential pattern in emit.data: value |
| SA-ENV-7 | ERROR | ENV.* credential pattern in log: expression |
| SA-ENV-8 | ERROR | ENV.* credential pattern in URL/query/host/args context (CWE-522) |
| **Cancel (SA-CANCEL-\*)** | | |
| SA-CANCEL-1 | ERROR | cancel: references non-FlowHandle identifier |
| **Context Access (SA-CTX-\*)** | | |
| SA-CTX-1 | ERROR | CONTEXT.* access to key not declared in requires.CONTEXT |
| SA-CTX-2 | ERROR | CONTEXT.* write to key not in write-list |
| SA-CTX-3 | WARN | CONTEXT.* multi-step read-modify-write without lock: |
| SA-CTX-4 | INFO | Array-form CONTEXT: [...] grants read-write but flow body only reads |
| **DNS (SA-DNS-\*)** | | |
| SA-DNS-1 | WARN | Remote flow URL without DNS pinning requirement note |
| **Flow Handle (SA-HANDLE-\*)** | | |
| SA-HANDLE-1 | WARN | handle: declared but never used |
| SA-HANDLE-2 | ERROR | handle: name conflicts with vars/const/forEach.as |
| SA-HANDLE-3 | ERROR | FlowHandle used at output boundary |
| SA-HANDLE-4 | WARN | cancel: on synchronous (non-async) run: step |
| **Taint Tracking (SA-TAINT-\*)** | | |
| SA-TAINT-1 | ERROR | $secret: true variable at output boundary |
| SA-TAINT-2 | ERROR | $exportable: false variable at output boundary |
| SA-TAINT-3 | INFO | $secret: true with redundant $exportable: false |
| SA-TAINT-4 | ERROR | Action provider sensitive output assigned without annotation (WARN for auth-pattern fallback) |
| SA-TAINT-5a | ERROR | Derived variable from $secret: true source lacks $secret: true |
| SA-TAINT-5b | ERROR | Derived variable from $exportable: false source lacks $exportable: false (CWE-200) |
| SA-TAINT-6 | ERROR | $sanitized: true without verified sanitization provenance (CWE-79) |
| SA-TAINT-7 | ERROR | set: target receives secret-derived CEL result without $secret: true |
| SA-TAINT-8 | ERROR | $declassify: true override of secret taint requirement (CWE-200, CWE-778) |
| SA-TAINT-9 | WARN | $exportable: false variable in request to non-allowlisted/dynamic URL |
| SA-TAINT-9a | ERROR | Multi-secret $declassify lineage incomplete — source secret(s) missing from $declassify.sources |
| SA-TAINT-10 | INFO | $exportable: false variable passed to call.params: |
| SA-TAINT-11 | WARN | Action result taint propagation from secret-derived input (CWE-200) |
| SA-TAINT-12 | WARN | Secret/non-exportable variable written to GLOBAL.* scope (CWE-200) |
| SA-TAINT-13 | ERROR | $declassify used inside defaults: block (CWE-266) |
| **Concurrency (SA-CONC-\*)** | | |
| SA-CONC-1 | ERROR | onTimeout: handler data race |
| SA-CONC-2..8 | WARN/INFO | Various concurrency hazards |
| **Testing (SA-TEST-\*)** | | |
| SA-TEST-1..51 | ERROR/WARN | Test file validation rules (see FLOWMARKUP-TESTING.md) |
| SA-TEST-52 | WARN | Test secret credential pattern detection (see TESTING.md) |

### Removed Rules

The following rules have been removed from the active rule set. Inline placeholders are retained for cross-reference stability.

| Removed Rule | Disposition | Target |
|---|---|---|
| SA-RUN-3 | Consolidated | SA-RUN-2 |
| SA-RUN-6 | Consolidated | SA-RUN-5 |
| SA-RUN-7 | Consolidated | SA-RUN-5 |
| SA-RUN-17 | Consolidated | SA-RUN-15 |
| SA-SVC-7 | Consolidated | SA-SVC-6 |
| SA-LOCK-6 | Consolidated | SA-LOCK-5 |
| SA-MAIL-7 | Removed | verifyTLS eliminated -- TLS always enabled |
| SA-REQ-5 | Removed | verifyTLS eliminated -- TLS always enabled |
| SA-CEL-2 | Removed | (see inline placeholder for details) |
