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): collectshowoutput (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-deviceunit-template/connector(Telnet, UnixSSH via PAT5052, serial, control node) with prompts, timeouts, and credentials.
Three structural inconsistencies block authoring (and block AI generation):
LifecyclePhaseJobvsscenarioFunctionis 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.- No closed primitive set.
tScp,tVerifygating, control-node ops, candidate-solution exec, pauses, and conditionals have no primitive. Examples LAB-0.1/0.2 can only expresscollect+ regex grading. - 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
scenarioFunctionis the code-defined, trusted primitive. ALifecyclePhaseJob(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.regexis the single check primitive and absorbs the legacytVerifygate. In a grading stage itspassed/issuefeedreport.score; in a setup stage its capturedpassedflag feeds a later step'swhen:(this is exactlytVerify β¦ 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 magiccmlctl-0device. The author names the operation; SE owns the mechanics, thecml_password(resolved fromruntime_env.*, never hard-coded β see ADR-058), and the serial-port wiring. - Candidate-solution execution (
py_deploy.py,run-playbook.sh) is justexec@v1with ascripton theworkstation_serialconnector β no special primitive needed. - The
report.*primitive actually emitted is selected by the job'sprocess_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
scenarioFunctioncalls, not a fixed triad and not an open task language. The CollectβEvaluateβReport triad survives as thestageconvention and asprocess_type-driven report selection β it is the recommended ordering, enforced softly by the schema (aGradingjob SHOULD containcollectβ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 thecml_on_aws(ornone) entry ofnative_steps_by_pod_type; the multi-part case applies the samejobs[]per part underpart_workflow. This subsumes LAB-0.1'snative_steps(nownative_steps_by_pod_type.cml_on_aws) and LAB-0.2'spart_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 inlifecycle.yaml. This kills the "inline tasks vs separate files" inconsistency: orchestration is inlifecycle.yaml, bodies are injobs/.grading/rubric.yamlandreports/score_report.yamlare referenced by theevaluate/reportsteps (the rubric supplies theitems[]; the report spec supplies thereport_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:
CompositeScenarioβ a new content kind (PAv1/composites/<name>.yaml), distinct from the codescenarioFunction. It declares typedparameters(input schema) andexport(output schema) and a body that is the same step DAG of Β§2.4, but whose steps mayuses: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.
for_eachβ a step/composite modifier (adopted from SPEC-001 D18) that runs a step once per element of a list, binding a loopvarintovars.*(with dot-notation for object lists). It collapses thec_rtr01_*/c_rtr02_*duplication into onefor_eachover 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/exportare 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,tVerifygating, control-node ops, candidate-solution exec, pauses, conditionals, the connector model) maps to a primitive β proven by theexamples/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.jsoncouples 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.
4. RelatedΒΆ
- ADR-058 β the
session.*/content.*/runtime_env.*/vars.*scopes thatwith/capture/whenreference, and the composite scope-frame (Β§2.6) that isolates aCompositeScenario'svars.*. - 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).