Skip to content

ADR-057: Content-Driven Lifecycle DSL β€” Primitives, Phases, and scenarioFunctionsΒΆ

Attribute Value
Status Proposed
Date 2026-06-13
Deciders Architecture Team
Extends ADR-049 (Unified Workflow DSL), ADR-044 (ScenarioEngine service)
Related ADRs ADR-034, ADR-038, ADR-047, ADR-055, ADR-058 (data-flow scopes)
Supersedes the inline-tasks body shape of ADR-049 Β§2.1 and the ServerlessWorkflow task-type list of ADR-044 Β§2.8

1. ContextΒΆ

ADR-049 unified the orchestration shape: a lifecycle.yaml declares phases, each with an engine (pipeline = native LCM steps, workflow = SE job) and gating. ADR-044 introduced the SE as a separate service with an @scenario registry and sketched a ServerlessWorkflow-inspired DSL with jq. But the body of a job β€” the actual unit-of-work vocabulary the SE runs β€” is described three incompatible ways across the docs, and none of them can express the real content we must ship.

The fidelity bar is the published lablet deployment/lds/content/LAB-1.1.1/ (legacy RCUv1 XML). Its four phases exercise primitives that have no home in the current DSL:

  • post_init (sb_post_init.xml): pause, run a command on a device and capture output, push a content file to the POD over a PAT port (tScp), regex-gate a captured value into a flag (tVerify), and control-node operations (bounce_interface, cmlctl --action stop).
  • pre_collect (sb_pre_collect.xml): wipe devices via the control node, bounce interfaces, and run the candidate's own solution (py_deploy.py, run-playbook.sh) over a serial console with venv activate/deactivate.
  • grade (grade.xml): collect show output (verify subject='commandOutput') and grade a captured variable with a regex check (verify subject='parse', mode, issue_replace), across 10 points / 9 subsections.
  • Connector model (pod.xml): per-device unit-template/connector (Telnet, UnixSSH via PAT 5052, serial, control node) with prompts, timeouts, and credentials.

Three structural inconsistencies block authoring (and block AI generation):

  1. LifecyclePhaseJob vs scenarioFunction is unreconciled. Is a job a fixed Collect→Evaluate→Report triad (generic-pattern.md), or a free task DAG (ADR-044 §2.8)? Authors do not know whether they compose primitives or fill a template.
  2. No closed primitive set. tScp, tVerify gating, control-node ops, candidate-solution exec, pauses, and conditionals have no primitive. Examples LAB-0.1/0.2 can only express collect + regex grading.
  3. Overloaded terminology. JobDefinition / workflow / scenario / scenarioFunction / step handler / task / native step are used interchangeably.

This ADR resolves all three by defining one closed primitive set (scenarioFunctions) and one job body shape (a declarative step DAG that composes them). The companion ADR-058 defines the data-flow scopes that the with/capture/when fields reference.

2. DecisionΒΆ

2.1 Two layers, one vocabularyΒΆ

flowchart TB
    subgraph CodeLayer["Code layer β€” TRUSTED, versioned, in SE (@scenario registry)"]
        SF["scenarioFunction\npause Β· exec Β· copy Β· collect Β· evaluate.regex Β· report.*\ncml.bounce_interface Β· cml.wipe Β· cml.power Β· cml.lab_start/stop/resolve"]
    end
    subgraph ContentLayer["Content layer β€” SANDBOXED, declarative, AI-generatable (PAv1)"]
        LC["lifecycle.yaml\nphases -> jobs (orchestration: CPA + SE seam)"]
        JD["jobs/<name>.yaml\nJobDefinition = ordered steps DAG"]
        ST["step\nid Β· uses Β· target Β· with Β· capture Β· when Β· on_error Β· timeout Β· stage"]
    end
    LC -->|"definition: name@version"| JD
    JD --> ST
    ST -->|"uses: scenarioFunction@version"| SF

    classDef code fill:#1e3a5f,color:#fff;
    classDef content fill:#0d9488,color:#fff;
    class SF code;
    class LC,JD,ST content;
Concept Defined by Mutable by authors? Persisted as
scenarioFunction Code β€” an @scenario(name, version) class in scenario-engine/scenarios/ No (PR + version bump) the in-memory SE registry (ADR-044 Β§2.9)
JobDefinition Content β€” PAv1/jobs/<name>.yaml, a step DAG Yes (authoring / LLM) a content_package ResourceDefinition (ADR-051)
Job Runtime β€” one execution of a JobDefinition bound to a phase β€” an untimed ResourceInstance (ADR-050)

The resolution of Q1. A scenarioFunction is the code-defined, trusted primitive. A LifecyclePhaseJob (henceforth just JobDefinition, the content artifact, and Job, its runtime) is a content-defined phase body that composes scenarioFunctions into a DAG. Authors and LLMs write only declarative wiring β€” they never write imperative code, never add a primitive. This keeps content sandboxed, validatable, and generatable.

2.2 The closed scenarioFunction primitive setΒΆ

The vocabulary is closed and orthogonal β€” small enough for an LLM to hold in context, complete enough to express LAB-1.1.1. Adding a primitive is a code change with a version bump, never a content change.

uses: Stage Purpose Key with: inputs capture: outputs Legacy origin
pause@v1 setup Wait/settle seconds β€” tPause
exec@v1 setup Run command/script on a connector, capture output, gate command | script, suppress_error? stdout, ok, error tExecute, tExecuteBatch
copy@v1 setup Push a content file to the POD host source (content ref), dest, via_port? ok tScp
cml.bounce_interface@v1 setup Bounce an interface via the control node device, interface, serial_port ok bounce_interface
cml.wipe@v1 setup Wipe devices via the control node devices[] ok cmlctl --action wipe
cml.power@v1 setup Start/stop a node or ext-conn node, action (start|stop) ok cmlctl --action stop
cml.lab_resolve@v1 setup Resolve/import the lab topology definition_id lab_id, title, nodes (native)
cml.lab_start@v1 setup Start the lab and poll to convergence lab_id lab_state, poll_count (native)
cml.lab_stop@v1 setup Stop the lab lab_id ok (native)
collect@v1 collect Run a show command on a device, capture output command, match? output verify subject='commandOutput'
evaluate.regex@v1 evaluate Regex check a captured var β†’ pass/fail + issue source, regex, mode (positive|negative), flags[]?, issue? passed, issue? verify subject='parse', tVerify
report.score@v1 report Assemble a ScoreReport from graded items items[], report_class? report_ref reportClass='LabletReport'
report.readiness@v1 report Assemble a ReadinessReport checks[] report_ref (Initialization)

Notes:

  • evaluate.regex is the single check primitive and absorbs the legacy tVerify gate. In a grading stage its passed/issue feed report.score; in a setup stage its captured passed flag feeds a later step's when: (this is exactly tVerify … set='file.OK' β†’ if='file.OK'). One primitive, two uses β€” no separate "verify-gate" type.
  • Control-node operations are first-class cml.* scenarioFunctions, not raw shell on a magic cmlctl-0 device. The author names the operation; SE owns the mechanics, the cml_password (resolved from runtime_env.*, never hard-coded β€” see ADR-058), and the serial-port wiring.
  • Candidate-solution execution (py_deploy.py, run-playbook.sh) is just exec@v1 with a script on the workstation_serial connector β€” no special primitive needed.
  • The report.* primitive actually emitted is selected by the job's process_type (Β§2.5).

2.3 The connector model (target binding)ΒΆ

pod.xml's unit-template/connector becomes a declarative PAv1/connectors.yaml. Each entry is a named connector a step selects with target:. Prompts, timeouts, transports, serial/PAT ports, and credentials are resolved from runtime_env.* (ADR-058) β€” the connector file declares the shape, the runtime supplies the facts.

# PAv1/connectors.yaml  β€” derived 1:1 from RCUv1/pod.xml
apiVersion: pav1
kind: ConnectorModel
metadata:
  name: LAB-1.1.1
spec:
  connectors:
    - name: rtr01
      class: cisco_common            # CiscoCommon
      transport: telnet              # serial console reached over Telnet
      prompt: "${ runtime_env.devices.rtr01.prompt }"
      enable_password: "${ runtime_env.devices.rtr01.enable_password }"
      port: "${ runtime_env.devices.rtr01.serial_port }"
    - name: workstation_22
      class: unix
      transport: ssh                 # UnixSSH via PAT
      via_port: "${ runtime_env.devices.workstation.pat_port }"   # 5052 -> 22
      username: "${ runtime_env.devices.workstation.username }"
      password: "${ runtime_env.devices.workstation.password }"
    - name: workstation_serial
      class: unix
      transport: telnet              # serial console
      port: "${ runtime_env.devices.workstation.serial_port }"
    - name: control_node
      class: control                 # cmlctl-0 control node β€” used only by cml.* primitives
      transport: telnet
      port: "${ runtime_env.control_node.serial_port }"

cml.* primitives implicitly target the control connector β€” the author never targets it by hand.

2.4 The JobDefinition body β€” one declarative step DAGΒΆ

A JobDefinition is an ordered list of steps. There is no inline-tasks block in lifecycle.yaml (superseding ADR-049 Β§2.1's inline body) and no free ServerlessWorkflow task-type zoo (superseding ADR-044 Β§2.8). The single step shape is:

- id: <unique-in-job>            # required β€” stable id, also the capture namespace
  uses: <scenarioFunction>@<ver> # required β€” must exist in the SE registry
  target: <connector-name>       # optional β€” omitted for pause/report/cml.* (implicit)
  with: { <input>: <value|expr> }# inputs; values may be ${ jq } over the scopes
  capture: { <var>: <output-ref> }# write named outputs into vars.* (see ADR-058)
  when: "${ <jq-bool-expr> }"    # optional gating; step is skipped if false
  on_error: { action: fail|continue|retry, retries?: <n>, backoff?: <s> }
  timeout: <seconds>             # optional per-step timeout
  stage: setup|collect|evaluate|report  # optional grouping (default: setup)

stage is a soft grouping, not a control structure: it labels a step for report assembly and documents the Collect→Evaluate→Report intent. SE executes steps in document order, honouring when and on_error; a step may read any vars.* captured by an earlier step (sequential data-flow). Parallelism is deferred — the legacy content is sequential, and a DAG executor can be added later without changing the step shape.

Q1 closure restated. A phase's body is a list of scenarioFunction calls, not a fixed triad and not an open task language. The Collect→Evaluate→Report triad survives as the stage convention and as process_type-driven report selection — it is the recommended ordering, enforced softly by the schema (a Grading job SHOULD contain collect → evaluate.* → report.score), never a rigid template the author must fill.

Worked example β€” the gate pattern. The legacy tVerify set='file.OK' / if='file.OK' flag (check a result, then conditionally run later steps) becomes capture: on an evaluate.regex@v1 step feeding a downstream when:. From LAB-0.1/PAv1/jobs/post_init.yaml:

- id: verify_package           # was tVerify (set="file.OK", if="CMD1.OK")
  uses: evaluate.regex@v1
  when: "${ vars.cmd1_ok }"     # only check if the ls step succeeded
  with:
    source: "${ vars.files }"   # the captured `ls` output
    regex: "desktop_package\\.tgz"
    mode: positive
  capture: { passed: file_ok }  # was set="file.OK"

- id: unpack                    # was tExecute (if="file.OK")
  uses: exec@v1
  target: workstation_22
  when: "${ vars.file_ok }"     # gated on the verify above
  with: { command: "tar -C /home/cisco/Desktop/tasks/ -xzf …/desktop_package.tgz" }

The same primitive (evaluate.regex@v1) serves both roles: a gate in a setup stage (its passed flag drives a when:) and a graded check in an evaluate stage (it feeds report.score). See LAB-1.1.1 for the full 1:1 port.

2.5 process_type ↔ report, and the legacy-phase mappingΒΆ

process_type is the job's intent; it selects the terminal report.* primitive and the report class. This reconciles process_type (ADR-055 / generic-pattern) with the step DAG.

process_type Typical stages Terminal primitive Report
Initialization setup β†’ collect β†’ evaluate report.readiness@v1 ReadinessReport
Grading setup β†’ collect β†’ evaluate report.score@v1 ScoreReport
Change setup β†’ collect β†’ evaluate report.change@v1 ChangeReport
Submission setup β†’ collect report.submission@v1 SubmissionReport
Archive setup β€” ArchiveReport

Legacy phase β†’ new phase + process_type (the missing mapping, now documented):

Legacy RCUv1 phase New lifecycle.yaml phase JobDefinition process_type
init (implicit) instantiate native steps + cml.lab_resolve/cml.lab_start Initialization
post_init (sb_post_init.xml) post_init jobs/post_init.yaml (setup-heavy) Initialization
pre_collect (sb_pre_collect.xml) grade (setup stage) jobs/grade.yaml steps stage: setup Grading
grade.xml verify commandOutput grade (collect stage) jobs/grade.yaml steps stage: collect Grading
grade.xml verify parse + report grade (evaluate+report) jobs/grade.yaml steps stage: evaluate/report Grading

pre_collect is not a separate phase β€” it is the setup stage of the grade job (it prepares the lab so the collect stage can read it). One job, four stages, one process_type.

2.6 Canonical PAv1 layout (converges the three competing layouts)ΒΆ

There is ONE content layout (resolving the ADR-044 Β§1.3 / LAB-0.1 / LAB-0.2 divergence). A single-part lablet uses the top level directly; a multi-part session repeats the per-part subtree under parts/ (each part is the single-part shape):

PAv1/
β”œβ”€β”€ manifest.yaml          # definition metadata + pod_type (single-part)  OR
β”‚                          #   kind: SessionDefinition + parts[] (multi-part)
β”œβ”€β”€ lifecycle.yaml         # phases -> { native_steps_by_pod_type, jobs[] }   (CPA + SE seam)
β”œβ”€β”€ connectors.yaml        # connector model (Β§2.3)                          (runtime_env binding)
β”œβ”€β”€ topology/
β”‚   β”œβ”€β”€ devices.json       # instance config (instance_type, ami, disk…)     (LCM instantiate)
β”‚   └── ports.json         # per-device serial/vnc/pat ports                 (LCM ports_alloc)
β”œβ”€β”€ jobs/                  # JobDefinitions β€” the step DAGs (Β§2.4)            (SE)
β”‚   β”œβ”€β”€ post_init.yaml
β”‚   └── grade.yaml
β”œβ”€β”€ grading/
β”‚   └── rubric.yaml        # EvaluationRuleset β€” graded items + checks + points (SE evaluate)
β”œβ”€β”€ reports/
β”‚   └── score_report.yaml  # ProcessReportSpec β€” report shape                (SE report)
└── files/                 # packaged payloads pushed by copy@v1 (desktop_package.tgz)
  • Single canonical lifecycle shape: phases[].{native_steps_by_pod_type, jobs[]}. The single-part case uses the cml_on_aws (or none) entry of native_steps_by_pod_type; the multi-part case applies the same jobs[] per part under part_workflow. This subsumes LAB-0.1's native_steps (now native_steps_by_pod_type.cml_on_aws) and LAB-0.2's part_workflow β€” they are the same shape at two scopes.
  • jobs[] always reference a JobDefinition file (definition: <name>@<version> β†’ jobs/<name>.yaml). The step DAG never lives inline in lifecycle.yaml. This kills the "inline tasks vs separate files" inconsistency: orchestration is in lifecycle.yaml, bodies are in jobs/.
  • grading/rubric.yaml and reports/score_report.yaml are referenced by the evaluate/report steps (the rubric supplies the items[]; the report spec supplies the report_class/shape).

2.7 AI-generation contract + sync-time validation (Q4)ΒΆ

A JSON Schema set is published from lcm_core at src/core/lcm_core/schemas/:

Schema file Validates
lifecycle.schema.json PAv1/lifecycle.yaml (phases, native steps, job refs, gating)
job-definition.schema.json PAv1/jobs/*.yaml (the step DAG: id/uses/target/with/capture/when/on_error/timeout/stage)
connector-model.schema.json PAv1/connectors.yaml
evaluation-ruleset.schema.json PAv1/grading/rubric.yaml
process-report-spec.schema.json PAv1/reports/*.yaml
scenario-functions.catalog.json generated from the SE @scenario registry β€” each primitive's input_schema/output_schema

Validation runs at content sync (ADR-023): both CPA and SE load these schemas. A step's with: is validated against the referenced scenarioFunction's input_schema, and its capture: keys against the output_schema, from scenario-functions.catalog.json. An invalid package fails the sync (no partial ingestion, per ADR-049 Β§2.3). This makes a phase/step/task machine-validatable the moment it is authored β€” the precondition for reliable LLM generation.

What an LLM is given / emits:

LLM input LLM output
Lab brief (objectives, grading rubric prose) PAv1/jobs/*.yaml (step DAGs)
Topology (devices.json, ports.json) PAv1/connectors.yaml
Connector model (transports, prompts) PAv1/grading/rubric.yaml
scenario-functions.catalog.json (the closed vocabulary + each primitive's I/O schema) PAv1/lifecycle.yaml phase→job bindings
The 4 data-flow scopes (ADR-058) PAv1/reports/score_report.yaml

The LLM selects from a closed primitive set and wires scopes; it never invents a primitive or writes code. The generated package is then schema-validated before it can sync.

2.8 Composition & reuse β€” considered alternatives and the deferred CompositeScenarioΒΆ

An earlier draft, SPEC-001 (scenario-definition-format), proposed a fundamentally different authoring model: authors write whole Scenarios β€” typed, versioned, parameterised units with their own task vocabulary (collect / parse / scenario) β€” and a scenario may invoke another scenario (action: scenario, D16) and iterate (for_each, D18). That model makes content-defined composition a first-class authoring primitive. ADR-057 Β§2.1–2.4 deliberately took the opposite cut: composition is over a closed set of code primitives, and a JobDefinition is a flat step DAG with no author-defined, callable, parameterised sub-unit. This section records why, what the cut costs, and the bounded extension that recovers the upside without re-opening the sandbox.

Three models on the tableΒΆ

A β€” Author-defined Scenarios (SPEC-001) B β€” Closed primitives + flat DAG (this ADR, Β§2.1–2.4) C β€” Hybrid: closed primitives + CompositeScenario
Unit authors write A parameterised, versioned Scenario with its own task language A flat step DAG wiring code primitives A flat DAG plus an optional content-defined, parameterised CompositeScenario of closed primitives only
New behaviour added by Authoring (a new scenario is new behaviour) Code PR + version bump (closed set) Code PR for primitives; authoring only re-composes existing primitives
Reuse / DRY Strong β€” a library of scenarios None β€” duplication is copied per device/step Strong β€” composites are reused like primitives
Iteration for_each (D18) Not expressible (gap) for_each step modifier (adopted from D18)
Sandbox boundary Soft β€” authored logic is the execution surface Hard β€” only trusted code executes Hard β€” composites are pure wiring; no new execution surface
Validatable / LLM-target Harder β€” a recursive DSL with its own scoping Easiest β€” closed vocabulary, flat schema Bounded β€” one extra schema (composite), same I/O-schema validation as a primitive

Why B is the baseline (and the real cost)ΒΆ

B wins on exactly the properties ADR-057 exists to protect: a closed, orthogonal vocabulary an LLM can hold in context; a hard sandbox (only trusted code executes β€” authored content is pure wiring); and sync-time validatability (Β§2.7). Model A re-introduces the precise failure mode the ADR set out to kill β€” authored logic as the execution surface β€” with a recursive DSL (own scoping, own filters, own iteration) that is far harder to validate and to generate reliably.

The cost of B is real and already visible in the golden port. In LAB-1.1.1 the collect stage hand-writes near-identical step pairs that differ only by device β€” c_rtr01_lo / c_rtr02_lo, c_sw01_vlan / c_sw02_vlan, c_rtr01_ntp / c_rtr02_ntp β€” and the rubric repeats the same Loopback0 up/up check per device. A two-router lab is tolerable; an eight-node multi-part exam is copy-paste at scale, with the attendant drift/maintenance risk. B trades reuse for safety, and pays for it in duplication.

Notably, B already concedes the principle: the evaluate stage does not spell out one evaluate.regex step per rubric row β€” a single ruleset-driven evaluate step expands grading/rubric.yaml into N checks (LAB-1.1.1 Β§evaluate). That is constrained iteration over content in everything but name. The hybrid simply generalises this already-accepted mechanism to the collect/setup stages.

Decision: adopt B now; specify C as a deferred, opt-in extensionΒΆ

We keep Model B as the normative v1 (the closed primitive set, the flat step DAG, Β§2.1–2.7). We do not adopt Model A. We specify β€” but defer building β€” Model C, a bounded hybrid that recovers A's reuse/iteration upside while preserving B's sandbox and validatability:

  1. CompositeScenario β€” a new content kind (PAv1/composites/<name>.yaml), distinct from the code scenarioFunction. It declares typed parameters (input schema) and export (output schema) and a body that is the same step DAG of Β§2.4, but whose steps may uses: only closed primitives or other composites β€” never imperative code. It is invoked from any step via a uniform call site:
- id: check_lo0
  uses: composite:check_interface_up_up@v1     # resolves to PAv1/composites/check_interface_up_up.yaml
  target: rtr01
  with:  { interface: Loopback0, ip: "${ runtime_env.devices.rtr01.lo0_ip }" }
  capture: { interface_name: rtr01_lo_name }   # promoted from the composite's `export`

The validator resolves uses: to either a scenarioFunction (catalogue, Β§2.7) or a CompositeScenario (synced content) and checks with/capture against its I/O schema identically. Composites run in an isolated vars.* frame (params in, export out) per ADR-058 Β§2.6 β€” never imperative code, never a new primitive, never write access to a trusted scope.

  1. for_each β€” a step/composite modifier (adopted from SPEC-001 D18) that runs a step once per element of a list, binding a loop var into vars.* (with dot-notation for object lists). It collapses the c_rtr01_* / c_rtr02_* duplication into one for_each over a device list and is the natural superset of the rubric-driven expansion B already does.

Guardrails carried over from SPEC-001 D16/D18 (so the sandbox stays hard):

  • Composites compose only the closed primitive set (+ other composites) β€” no new execution surface; the trusted-code boundary of Β§2.1 is unchanged.
  • Max nesting depth 3; circular references detected at sync/registration via a dependency graph (a malformed composite fails the sync, Β§2.7).
  • A composite's parameters/export are schema-published exactly like a primitive, so the AI-generation contract (Β§2.7) and the closed catalogue are unaffected β€” the LLM sees composites as just more callable units with declared I/O.

Why deferred, not built now. The user-facing question β€” "is it truly useful for authors to edit their own scenarios?" β€” is an evidence question, and B is the safe default to gather that evidence against. We adopt B, ship real content on it, and promote C only when duplication in authored packages crosses a pain threshold (a heuristic: the same step/check pattern repeated across β‰₯3 devices or β‰₯2 jobs). Building C speculatively would expand the validator, the schema set, and the LLM contract before we know authors need it. The cut is reversible upward (B is a strict subset of C: every B job is a valid C job), so deferring costs nothing structurally.

Restated Q1 posture. A phase body is still a list of primitive calls (B). C does not change that β€” it only lets a named, parameterised group of primitive calls be one reusable call with declared I/O. Authors still never write code and never add a primitive.

Pressure-test β€” for_each + CompositeScenario against the LAB-1.1.1 collect stageΒΆ

To check the hybrid is real (not hand-waving), we rewrote the LAB-1.1.1 collect stage with it. The proposed JSON Schema is drafted at content-format/schemas/composite.schema.json (marked proposed/deferred β€” not wired into sync validation).

The composite (PAv1/composites/check_interface_up_up.yaml) β€” bundles a collect + an up/up evaluate.regex into one reusable, parameterised call. The device is the call-site target:, inherited by the inner steps (so a composite acts on one connector, exactly like a primitive):

apiVersion: pav1
kind: CompositeScenario
metadata: { name: check_interface_up_up, version: "1.0.0" }
spec:
  description: "Collect an interface's detail and assert it is up/up."
  parameters:
    interface: { type: string, required: true }     # device = the call-site target:
  export:
    detail: "${ vars.collect_if.output }"            # raw output, for further checks
    is_up:  "${ vars.assert_up.passed }"             # the pass/fail flag
  steps:
    - id: collect_if
      uses: collect@v1
      stage: collect
      with: { command: "show interface ${ parameters.interface }" }
      capture: { output: output }
    - id: assert_up
      uses: evaluate.regex@v1
      stage: evaluate
      with:
        source: "${ vars.collect_if.output }"
        regex: "${ parameters.interface } is up, line protocol is up"
        mode: positive
        flags: [multiline]
      capture: { passed: passed }

The rewritten collect stage β€” the 12 hand-written steps collapse to ~6. Per-router-pair commands fold into one for_each; the two Loopback0 collects and their two rubric up/up rows fold into a single composite call:

# was c_rtr01_acl + c_rtr02_acl  β†’ one for_each over the router group
- id: c_rtr_acl
  uses: collect@v1
  stage: collect
  for_each: { var: dev, in: "${ runtime_env.device_groups.routers }" }
  target: "@dev"
  with: { command: "show access-list" }
  capture: { "@dev.show_access_list": output }   # interpolated KEY β†’ same flat vars.* namespace

# was c_rtr01_gi + c_rtr02_gi, c_rtr01_ntp + c_rtr02_ntp  β†’ two more for_each (elided)
# was c_sw01_vlan + c_sw02_vlan  β†’ one for_each over the switch group (elided)

# was c_rtr01_lo + c_rtr02_lo  AND  the two Loopback0 up/up rubric rows β†’ one composite call
- id: check_loopbacks
  uses: composite:check_interface_up_up@v1
  for_each: { var: dev, in: "${ runtime_env.device_groups.routers }" }
  target: "@dev"
  with: { interface: Loopback0 }
  capture: { "@dev.lo0_up": is_up, "@dev.show_int_loop0": detail }

# rtr01-only command stays flat
- id: c_rtr01_ospf
  uses: collect@v1
  stage: collect
  target: rtr01
  with: { command: "show ip ospf neighbor" }
  capture: { rtr01.show_ip_ospf_nei: output }

What the pressure-test confirmed (the win). Collect-stage step count drops 12 β†’ 6 on a two-router lab; the ratio improves on larger topologies. Crucially, interpolating the loop var into the capture key ("@dev.show_access_list") reproduces the exact flat vars.<device>.<name> namespace the original used β€” so the existing grading/rubric.yaml source: references (rtr01.show_access_list) keep working unchanged. This resolves the obvious "dynamic capture" objection.

What the pressure-test surfaced (the open questions β€” why C stays deferred).

# Finding Impact
OQ-1 Capture-key interpolation puts ${ }/@var in key position, not just values Small validator/schema special case (the live schemas don't allow it today)
GAP-1 for_each … in needs a device role/group list (runtime_env.device_groups.routers) topology/ports.json + connectors.yaml don't model device groups yet; hard-coding the list in content would re-introduce the non-portability ADR-058 forbids. Needs a topology addition first.
OQ-3 A composite bundling collect+evaluate overlaps the rubric-driven evaluate expansion the model already uses (LAB-1.1.1 Β§evaluate) Two reuse axes (rubric rows vs composites) now compete for where points/issues attach. Must decide: composites collect-only (points stay in rubric) or composites carry points. Unresolved.
CAVEAT Artifacts use ADR-057 uses/with/capture; the live scenario.schema.json (do/call) and lifecycle.schema.json (name/handler) diverge Building C presupposes first reconciling ADR-057's step shape with the implemented PAv1 format β€” a prerequisite, tracked separately.

Verdict. The hybrid is mechanically sound and delivers a real ~50% reduction on the golden port β€” but it also introduces a topology concept (device groups), a capture-key special case, and a second reuse axis that overlaps the rubric. None is blocking; together they confirm the Β§2.8 decision: specify C, ship B, and promote C only once authored content proves the duplication pain and OQ-3 (rubric vs composite) is settled.

3. ConsequencesΒΆ

Positive

  • One mental model. scenarioFunction = trusted primitive; JobDefinition = declarative DAG over primitives; process_type selects the report. Authors and LLMs compose, never code.
  • Full legacy fidelity. Every LAB-1.1.1 task (tScp, tVerify gating, control-node ops, candidate-solution exec, pauses, conditionals, the connector model) maps to a primitive β€” proven by the examples/LAB-1.1.1/ golden port.
  • One validator, sync-time. The published schemas + generated primitive catalogue reject malformed content before runtime and give the LLM a machine-checkable target.
  • Terminology fixed (Q3): see the glossary β€” scenarioFunction, JobDefinition, Job, step, native step each defined once.

Negative / trade-offs

  • The closed primitive set must be deliberately curated; a genuinely new capability needs a code PR + version bump (intended β€” it is the sandbox boundary).
  • A generated scenario-functions.catalog.json couples sync-time validation to the SE registry version; the catalogue must be published as part of the SE release.

Neutral

  • The SE executor (ADR-044) and native PipelineExecutor (ADR-034) are mechanically unchanged β€” only the ingested job body shape is fixed to the step DAG. Sequential execution today; a parallel DAG executor can be added without changing the step shape.
  • Reuse/iteration is deferred, not foreclosed (Β§2.8). Model B (closed primitives + flat DAG) is a strict subset of the hybrid Model C (CompositeScenario + for_each): every v1 job remains valid if/when C is built. The duplication cost of B is accepted now and measured against real authored content before C is promoted from specified to built.
  • ADR-058 β€” the session.* / content.* / runtime_env.* / vars.* scopes that with/capture/when reference, and the composite scope-frame (Β§2.6) that isolates a CompositeScenario's vars.*.
  • generic-pattern.md β€” the primitive vocabulary, step shape, and canonical layout in narrative form.
  • scenario-definition-format_draft.md β€” SPEC-001, the author-defined-Scenario draft (Model A) mined for the Β§2.8 composition/iteration analysis.
  • examples/LAB-1.1.1/README.md β€” the 1:1 golden port of the legacy XML proving fidelity (and the duplication evidence motivating Β§2.8).