ADR-058: Lifecycle Data-Flow & Variable Scopes¶
| Attribute | Value |
|---|---|
| Status | Proposed |
| Date | 2026-06-13 |
| Deciders | Architecture Team |
| Extends | ADR-057 (Content-Driven Lifecycle DSL) |
| Related ADRs | ADR-044, ADR-049, ADR-050, ADR-053 |
1. Context¶
ADR-057 defines the step shape — with (inputs),
capture (outputs), when (gating) — but not where the values come from. The legacy RCUv1
content references runtime facts through a tangle of incompatible, un-namespaced mechanisms, with
secrets and ports hard-coded in content:
| Legacy reference | Meaning | Problem |
|---|---|---|
${config.core.paths.lab_root} |
content root path | opaque global config namespace |
{files} |
a captured variable | brace syntax, flat namespace |
$(rtr01.show_int_loop0) |
a captured variable | paren syntax, different from {} |
port="5052", --serial-port 5048 |
a device PAT/serial port | hard-coded per pod |
--cml-password trackNMC50 |
the control-node password | secret hard-coded in content |
prompt='rtr01#', enablepassword='cisco' |
connector facts | duplicated across files |
There is no data-flow or variable-scope model anywhere in the ADRs or solution docs. Without one, content is non-portable (ports/passwords baked in), insecure (secrets in the package), and impossible to validate or generate (no declared namespace of available facts). This ADR defines an explicit, namespaced, four-scope model and the interpolation rules over it.
2. Decision¶
2.1 Four scopes¶
Every value a step reads or writes lives in exactly one of four namespaced, read/write-disciplined
scopes. References use jq expressions in ${ } (ADR-044 §2.8), evaluated against a single
merged context object whose top-level keys are the scope names.
flowchart LR
subgraph Inputs["Read-only inputs (resolved at job submit)"]
SE_S["session.*\ncandidate / exam / timeslot\n(from mosaic_meta.json + Session)"]
SE_C["content.*\nlab_root, packaged files, form FQN\n(from the PAv1 package)"]
SE_R["runtime_env.*\nPOD instance facts: device ports,\ncml_password, prompts, creds, worker IP\n(from PodInstance + Host + secret store)"]
end
subgraph RW["Read/write during the job"]
SE_V["vars.*\ntask-captured intermediates\n(written by step.capture)"]
end
SE_S --> STEP["step\nwith / when read all scopes\ncapture writes vars.*"]
SE_C --> STEP
SE_R --> STEP
STEP -->|capture| SE_V
SE_V --> STEP
classDef ro fill:#1e3a5f,color:#fff;
classDef rw fill:#0d9488,color:#fff;
class SE_S,SE_C,SE_R ro;
class SE_V rw;
| Scope | Lifetime | Writable by content? | Source | Holds |
|---|---|---|---|---|
session.* |
the job run | No (read-only) | mosaic_meta.json + the Session/SessionPart |
candidate/exam/timeslot metadata |
content.* |
the job run | No (read-only) | the synced PAv1 package + blob store | lab-root path, packaged file handles, form FQN |
runtime_env.* |
the job run | No (read-only) | the PodInstance + bound Host + secret store |
live POD facts: device ports, prompts, credentials, cml_password, worker IP, connector handles |
vars.* |
the job run | Yes | step.capture |
task-captured intermediate values |
Only vars.* is writable. session.*, content.*, and runtime_env.* are resolved at job
submission and frozen for the run — they are the trusted, validated inputs the content reads but
cannot forge.
2.2 Scope contents (the declared namespace)¶
This is the namespace an author (or LLM) may reference. It is the schema the validator checks
${ } expressions against.
session.* — from mosaic_meta.json + the runtime Session:
session.track # "350-901 AUTOCOR" (TrackLongName)
session.track_short # "AUTOCOR" (TrackShortName)
session.exam # "350-901" (ExamId/exam)
session.form # "LAB-1.1.1" (FormName / FQN leaf)
session.form_id # "68b054a4…" (FormId)
session.module # module id
session.language # "ENU" (Language)
session.candidate_id # the delivering candidate (Session)
session.timeslot.start # ISO-8601 (Timeslot)
session.timeslot.end # ISO-8601
content.* — from the synced PAv1 package:
content.form_fqn # six-token FQN
content.lab_root # resolved package root (replaces ${config.core.paths.lab_root})
content.files.<name> # a handle to a packaged file under PAv1/files/
# e.g. content.files.desktop_package -> files/desktop_package.tgz
content.version # content_version
runtime_env.* — from the PodInstance + bound Host + secret store:
runtime_env.worker_ip # the CML worker IP
runtime_env.cml_password # SECRET — from the secret store, never the package
runtime_env.region # AWS region
runtime_env.lab_id # resolved CML lab id (also captured by cml.lab_resolve)
runtime_env.devices.<name>.serial_port # e.g. 5045 (rtr01), 5048 (sw01)
runtime_env.devices.<name>.vnc_port # e.g. 5051
runtime_env.devices.<name>.pat_port # e.g. 5052 (-> 22)
runtime_env.devices.<name>.prompt # e.g. "rtr01#"
runtime_env.devices.<name>.enable_password # SECRET
runtime_env.devices.<name>.username # connector cred
runtime_env.devices.<name>.password # SECRET
runtime_env.control_node.serial_port # the cmlctl-0 control-node port
runtime_env.devices.* is populated from topology/ports.json (ports) + connectors.yaml
(prompts/transports) + the secret store (credentials) at submit time. No port or password is
ever literal in content.
2.3 Interpolation & capture rules¶
- Interpolation: any
with,when, or connector-model field may contain${ <jq> }. The expression is evaluated against the merged context{ session, content, runtime_env, vars }. Plain strings pass through unchanged. - Capture:
capture: { <name>: <output-key> }writes the named scenarioFunction output intovars.<step_id>.<name>(namespaced by step id to prevent collisions), and also as a flatvars.<name>alias when unambiguous. Example: anexecstepid: list_tmpwithcapture: { stdout: files }makes bothvars.list_tmp.filesandvars.filesavailable. - Gating:
when: "${ vars.file_ok }"skips the step when false — the direct replacement for legacyif='file.OK'after atVerify … set='file.OK'.
Worked extract — the scoped copy@v1 from
LAB-0.1/PAv1/jobs/post_init.yaml
touches three scopes at once: content.* (the packaged payload), runtime_env.* (the PAT port),
and vars.* (the captured result):
- id: push_package # was RCUv1 tScp
uses: copy@v1
target: workstation_22
with:
source: "${ content.files.desktop_package }" # content.* (was ${config.core.paths.lab_root}/…)
dest: "/home/cisco/Desktop/tmp/desktop_package.tgz"
via_port: "${ runtime_env.devices.workstation.pat_port }" # runtime_env.* (was port=5052)
capture: { ok: scp_ok } # vars.push_package.ok / vars.scp_ok
2.4 Legacy → scoped reference mapping¶
The exact substitutions the examples/LAB-1.1.1/ golden port (and the minimal
examples/LAB-0.1/) apply:
| Legacy reference | Scoped reference |
|---|---|
${config.core.paths.lab_root}/desktop_package.tgz |
${ content.files.desktop_package } |
port="5052" (tScp PAT) |
${ runtime_env.devices.workstation.pat_port } |
--serial-port 5048 |
${ runtime_env.devices.sw01.serial_port } |
--cml-password trackNMC50 |
${ runtime_env.cml_password } |
prompt='rtr01#' |
${ runtime_env.devices.rtr01.prompt } |
enablepassword='cisco' |
${ runtime_env.devices.rtr01.enable_password } |
string="{files}" |
${ vars.files } |
string='$(rtr01.show_int_loop0)' |
${ vars.rtr01.show_int_loop0 } |
set="file.OK" / if="file.OK" |
capture: { passed: file_ok } / when: "${ vars.file_ok }" |
2.5 Composite scope-frame (the isolation mechanism for CompositeScenario)¶
ADR-057 §2.8 specifies a deferred, opt-in
CompositeScenario — a content-defined, parameterised group of closed primitives invoked from a
step via uses: composite:<name>@<ver>. The data-flow model already supplies everything needed to
run one safely; this section names the rule rather than inventing a new mechanism.
A composite executes in an isolated vars.* frame:
- Trusted scopes pass through unchanged.
session.*,content.*, andruntime_env.*remain the same frozen, read-only objects inside the composite — a composite cannot see less or forge more than its caller. (target:likewise resolves against the sameconnectors.yaml.) vars.*is a fresh frame. The caller'svars.*is not visible inside the composite. At invocation the composite gets a newvars.*seeded only from its declaredparameters(bound from the step'swith:). This is the §2.2 namespace discipline applied recursively.exportis the only way out. When the composite returns, only the keys named in itsexportschema are written back — into the caller'svars.<step_id>.*(and the flatvars.<name>alias when unambiguous), exactly as a primitive'scapture:does. Everything else in the composite's frame is discarded.
# caller step # composite: PAv1/composites/check_interface_up_up.yaml
- id: check_lo0 # parameters: { interface, ip }
uses: composite:check_interface_up_up@v1 # export: { interface_name }
target: rtr01 # (body = a §2.4 step DAG over closed primitives,
with: { interface: Loopback0, # running in its OWN vars.* frame)
ip: "${ runtime_env.devices.rtr01.lo0_ip }" }
capture: { interface_name: rtr01_lo_name } # promoted from the composite's `export`
This makes a composite indistinguishable from a primitive at the call site for both the runtime
and the validator: same with/capture discipline, same scope visibility, same I/O-schema check
(ADR-057 §2.7). The for_each modifier (ADR-057 §2.8) is orthogonal — each iteration binds its
loop var into the current vars.* frame before the (composite or primitive) call runs.
2.6 Security posture¶
- Secrets never live in content.
runtime_env.cml_password,*.enable_password,*.passwordresolve from the platform secret store at submit time, injected into the job'sruntime_envscope. The PAv1 package contains only references, never literals. This removes the legacy--cml-password trackNMC50/enablepassword='cisco'class of leak. - Content is read-only over trusted scopes. A malicious or buggy package can read but not write
session.*/content.*/runtime_env.*; it can only mutatevars.*, which is discarded at job end. This bounds the blast radius of generated content. - jq evaluation is sandboxed to the merged context object (no filesystem/network builtins), consistent with the AuthorizationPolicy JQ usage in ADR-053.
3. Consequences¶
Positive
- Portable content. The same PAv1 package runs on any pod — ports, IPs, prompts, and passwords
are resolved per-instance from
runtime_env.*, never baked in. - Validatable & generatable. The four scopes are a declared namespace; the JSON Schema
(ADR-057 §2.7) checks every
${ }reference, and an LLM is handed the exact set of facts it may read. - Secure by construction. Secrets out of content; content read-only over trusted inputs.
- Terminology fixed. session / content / runtime_env / vars replace the legacy
${config…}/{}/$()soup with one consistent syntax.
Negative / trade-offs
- The platform must resolve
runtime_env.*at submit time — the SE/CPA seam must assemble device ports (fromports.json), connector facts (fromconnectors.yaml), and secrets (from the store) into the scope before the first step runs. - jq-over-four-scopes is slightly more verbose than a flat variable bag — the cost of namespacing and validatability.
Neutral
vars.*capture semantics mirror the existingScenarioContextoutput flow (ADR-044 §2.9); only the naming/namespacing is formalised.- The composite scope-frame (§2.5) reuses the same
vars.*namespacing recursively — it adds no new scope, only an isolation rule for the deferredCompositeScenario(ADR-057 §2.8).
4. Related¶
- ADR-057 — the step shape whose
with/capture/whenreference these scopes, and the deferredCompositeScenario+for_eachextension (§2.8) whose isolation rule is defined here (§2.5). - generic-pattern.md — narrative description of the scopes.
- examples/LAB-0.1/README.md — the minimal
PAv1/package using these scopes (real files). - examples/LAB-1.1.1/README.md — every scoped reference applied to the real legacy content.