Skip to content

ADR 0004 — Orchestrator: scoped_context per scope (lexical layers & shadowing)

Status: Proposed (concrete orchestrator direction — not implemented yet)
Date: 2026-05-02
Depends on: ADR 0003 — Workflow scope concept
Related: ADR 0002 — Scoped context plan (variables), Workflow YAML — Scoped context


Context

Today, scoped_context exists only on the workflow document. It declares variable slots and optional init values merged into the single workflow run context (current behaviour).

The intended model treats scoped_context as “context attached to a scope”: any compositional scope (starting with parallel lanes, later possibly other step containers) may carry the same declarative shape as workflow-level scoped_context, but scoped to that lexical unit.

Product requirement (example)

  • At workflow (or parent scope) “workspace”, variable test is declared string.
  • Inside a nested scope, authors must be able to declare test again as a different type (e.g. int)—a new binding in the inner scope, not a conflicting mutation of the outer declaration.

That implies:

  1. Lexical layers of context (stack or overlay), not one flat mutable map for nested scopes.
  2. Shadowing: inner declaration hides outer for name resolution while executing steps inside that scope.
  3. Exit behaviour: leaving the scope drops inner bindings so outer test is visible again (unless explicitly exported via outputs — out of scope for this ADR).

Parallel scopes must not share one mutable context map if shadowing and type discipline are to hold; per-lane isolation becomes mandatory for type: parallel (aligns with ADR 0003).


Decision

1. Unified schema

Reuse the existing ScopedContext shape (provider + variables[] with name, type, optional init) for:

Attachment point Meaning
WorkflowDoc.scoped_context Workflow-wide declarative slots (already implemented).
ParallelScope.scoped_context (new) Declarative slots for that lane only (first delivery target).
Future: other scope-bearing nodes Same schema where the product adds nested scopes.

Naming: Keep the field name scoped_context everywhere so YAML, registry metadata, and designer stay aligned—only the parent object changes (workflow vs scopes[] entry).

2. Shadowing rules

  • Within one scoped_context block: unique variable name (existing rule).
  • Across nested scopes: same name allowed with different type / init — inner wins for resolution while executing inside the inner scope.
  • Reserved prefixes (_scoped., __) remain global rules.

3. Resolution order (runtime)

When resolving a context key for a step inside a scope:

  1. Innermost active scope’s scoped_context init / runtime updates (if any).
  2. Enclosing scopes, outward.
  3. Workflow scoped_context and trigger/initial_context.

(Exact algorithm: overlay chain or immutable stack; implementation detail, but must be deterministic.)

4. Parallel (type: parallel)

  • Each lane (ParallelScope) that runs concurrently must execute with its own context view (fork/copy before concurrent entry, plus lane’s scoped_context merge).
  • No cross-lane reads/writes of lane-local shadowed keys.

5. Variables provider (stateless operations)

Variable values and types are owned by the orchestrator run context (ADR 0008). The variables provider exposes stateless variables.* operations only; there is no variables.set seed or per-run bucket.

  • Parallel lane scoped_context merges into the lane context view (already implemented).
  • service_call steps pass operand values in the POST body; orchestrator sends X-Scoped-Declarations for type-aware operations.
  • No provider seed for any scope level.

Decision for phase 1: orchestrator context merge + shadowing + isolation for parallel lanes — done. Provider remains stateless; type checks use _scoped.declarations + operation kind.


Consequences

  • Pydantic: extend ParallelScope with optional scoped_context: ScopedContext | None.
  • Runner: refactor _exec_parallel / _exec_substeps to pass a scope-aware context handle (not raw shared dict) for lanes; workflow body stays single-threaded sequential unless entering parallel.
  • Dashboard / designer: optional nested editor for scopes[].scoped_context after schema ships.
  • Docs: clarify that “scoped_context” = declarative context on a scope; workflow-level is simply the root scope.

Implementation phases (suggested)

Phase Scope Deliverable
A Models + validation ParallelScope.scoped_context in WorkflowDoc; validate; YAML docs + examples
B Runtime (no provider) Per-lane context fork; merge lane scoped_context; shadowing; exit pop
C Type-aware operations Orchestrator operand checks + X-Scoped-Declarations on variables.* calls (ADR 0008)
D Designer Nested table UX for parallel scopes

Example (illustrative YAML)

name: demo
invocation: { type: http }
scoped_context:
  provider: { service_name: variables.service }
  variables:
    - name: test
      type: string
      init: { value: "outer" }

steps:
  - id: fork
    type: parallel
    scopes:
      - name: lane_a
        scoped_context:
          provider: { service_name: variables.service }
          variables:
            - name: test
              type: number
              init: { value: 0 }
        steps:
          - id: in_lane
            type: log
            message: "lane sees int test, not outer string"
    # outputs_map etc. unchanged

(Exact parallel wiring omitted for brevity.)


Out of scope (this ADR)

  • Changing workflow_call semantics.
  • Non-parallel nested scopes until a follow-up ADR defines containers (e.g. explicit sequence with scoped_context).
  • Strong static typing at runtime: orchestrator enforces declared type vs variables.* operation for payload_from: context:<name> bindings; provider validates request body shape. See ADR 0008.

See also