ADR-0005 — Two-tier variables (${…} / {{…}}), the World, and secrets
Status: Accepted · Date: 2026-07-28
Context
The author-time variable system (${var}, ${env:NAME:-default}, ${run:id}, ${global:key},
${fake:*} seeded from the run id, $${} escape, recursive expansion) is proven with
test authors. Hurl has its own runtime templating ({{name}}) fed by captures. The spike
validated running both tiers side by side — including the bug it surfaced: step-captured
args can themselves contain ${…} and need recursive (depth-capped) resolution.
Decision
Two explicit tiers. ${…} is author time: params, env (with defaults), run id,
World reads (${global:key}), fake data, secrets references — resolved during lowering,
recursively with a depth cap of 8, and baked into artifacts (except secrets). {{…}}
is run time: hurl-native templates for captures, left verbatim in artifacts so the
embedded engine and the stock CLI resolve them identically. World: one typed variable
scope per scenario plus a persistent global store (.proef-state.json, atomic
temp+rename). Engine
bridging: seed hurl’s VariableSet from the World before each batch; merge
HurlResult.variables back after; saveAs: global promotes a capture into the
persistent store. Secrets: ${secret:NAME} resolves from the PROEF_SECRET_<NAME> environment
override, else the encrypted store .proef-secrets.json (chacha20poly1305 + rpassword); values are injected via
VariableSet::insert_secret (hurl redacts them in logs/reports); artifacts carry
{{secret_name}} placeholders, never values; our reporters additionally redact by value
(property-tested invariant).
Amendment (2026-08-16): “redact by value” includes each secret’s common
encoded forms — base64 (both alphabets, with/without padding), hex (both
cases), RFC 3986 percent-encoding, and the JSON-string escape — derived inside
Redactions::new so every sink is covered by construction. Demonstrated live
before the amendment: a server reflecting a bearer token base64-encoded put a
trivially-decodable string into an assert-failure detail, the raw needle never
fired, and the encoded credential reached the console and events.jsonl. The
needle set covers the reversible transforms that occur at HTTP boundaries; a
secret reflected hashed or re-encrypted matches no needle list, and the ADR
does not claim otherwise. Over-redaction is the accepted failure direction.
Consequences
Artifacts are runnable by both toolchains with identical meaning; authors keep a familiar
mental model unchanged; secrets are structurally absent from every persisted output.
Cost: two syntaxes coexist in packs — mitigated by the strict rule of thumb (“$ =
before the run, {{ = during the run”) documented in the pack authoring guide.
Alternatives considered
Single-tier (resolve everything at author time) — breaks capture chaining and makes
artifacts non-parametric. Single-tier (everything hurl {{}}) — loses env defaults,
fakes, and World reads. String-only World — kept
typed here (hurl Value model) because captures cross engines; stringly-typed
round-trips would lose numbers/bools at engine boundaries.
Errata
2026-09-07 (0.18): the invariant’s reach was found unenforced on one of
its two paths. The event stream masks through Redactions::apply_event, an
exhaustive destructure; the CI sinks that render from RunSummary (JUnit,
CTRF, TAP, timings.json, the GitHub summary and annotations) masked failure
detail, but five of them bypassed the masker for the identity fields
(scenario, file, tags, the skip reason) — no live leak, since secrets
lower to {{name}} and the engine pre-redacts details, but a boundary held by
convention. Closed per sink (#171), then made structural (#178):
Redactions::apply_outcome destructures ScenarioOutcome/StepOutcome
without .., so a new text field fails to compile until it is masked, and each
sink redacts one outcome after matching @quarantine on the raw identity.
Per-sink rather than a wholesale apply_summary, because RunSummary also
feeds exit_code_excluding, whose quarantine matching needs the unredacted
identity.
2026-07-29 (v0.3.1): the “secrets reach no sink” invariant now explicitly
covers the persistent World: a saveAs: global capture whose value equals a
known secret is refused (the owning step warns) — .proef-state.json is
plaintext at rest and must never receive secret-derived material. PROEF_KEY
(base64) may supply the project key via the environment for CI use of a
committed ciphertext store.
2026-07-28 (post-M5 hardening; API removed 2026-07-29 per YAGNI):
“snapshot/restore across scenario retries” originally described a mechanism whose
trigger was never specified anywhere in the corpus — no
CLI flag, tag, or pack directive schedules a scenario-level retry (US-5’s step-level
retry: is implemented and is the flake tool in practice). Decision: scenario-level
retries are deferred indefinitely, and the unused GlobalStore::snapshot/restore
API has been removed (YAGNI: no dead promised-behavior code). Whoever implements
scenario retries specifies the trigger surface and its mechanism in a superseding ADR. Also note: the scenario merge-back is write-set-only (World
tracks its saveAs promotions) — merging a whole snapshot back would lose concurrent
scenarios’ updates.