feat(engine): deferred values — a driver option read from another target - #456
Merged
Merged
Conversation
A driver option's value has to be a literal in the BUILD file. But the
value's owner is usually somewhere else — a role ARN in the Terraform
that created the role, a registry per environment in a platform team's
repo, a version in a release process. Copying it in makes a second source
of truth that someone has to keep agreeing with the first, purely to
satisfy heph.
`${read://pkg:name}` in a driver option is the contents of that target's
single output. The ARN appears exactly once in the repository, in the
Terraform that defines it; heph reads it and holds no copy.
What no other build system can express
--------------------------------------
Gradle, Bazel, Buck2, Pants and Nix each shipped an answer, and they all
made the same structural choice: the value crosses the boundary as a bare
string, and the thing that produced it never becomes a node. So the
producer must re-run eagerly on every build (which is why every one of
them documents some version of "only use fast commands here"), the cache
key can only hash the string and over-invalidate or hide it and sanction
staleness, and "why did this rebuild?" bottoms out at the string.
Here the producer is a node. Two moments and no new hashing rule: at
**parse** the reference becomes an `Input{hashed, not staged}`; at **run**
the bytes are substituted. The def hash covers the *unresolved* reference,
so `heph query` and `heph inspect def` never trigger a build; `hashin` is
the def hash plus the hashouts of hashed inputs, so the producer's content
reaches the consumer's key through the ordinary dep path. The consumer's
key therefore derives from the Terraform state rather than from the ARN
string — `example/deferred/` demonstrates exactly that, with an input the
producer declares and does not use.
Both halves already existed: synthesizing an edge that is not in the BUILD
`deps` list is what `runner` does, and a producer's content reaching a
consumer's key is what `inputs_result_meta` does for every hashed input.
The host does all of it
-----------------------
An earlier shape left three obligations on the plugin author — type the
field, collect its edges at parse, substitute at run — and both "remembers"
fail *silently*, which is the bug class this exists to remove. So:
- the **host** walks `TargetSpec.config` for references at any nesting
depth, appends the `Input`s after `parse`, reads the producers and fills
`RunRequest.deferred`;
- the **shared decoder** refuses `${read:…}` in `String`, so a reference
in `out`, `deps`, `name` or a glob is a parse error in every driver at
once, with no driver code and no schema flag;
- the **driver**'s whole diff is `role: String` → `role: Deferred<String>`.
Reading it needs a `&RunRequest`, so forgetting to resolve is a compile
error, and `accepts_deferred` is derived from the field types rather
than being a second thing to remember.
The walk is over the raw `Value` tree, which is what makes it work where a
flat per-field schema could not: a credential's `sources = [heph.auth.oidc(…)]`
is a list of maps of maps and the reference sits three levels inside it.
The refusal: a deferred value changes what a target **does**, never which
targets **exist**. Rejected in `deps`, `tools`, `runner`, `scratch`, `out`,
`name` and `labels`. That is what keeps cycle detection working at parse,
keeps `query` from triggering a build, and keeps evaluation from ever
blocking on one — which is the whole of Nix's import-from-derivation
problem, cut off at one level.
Two consumers: the credential driver's presentation templates, and the
`exec` driver's `run` in exec mode. `bash` mode's `run` is deliberately
not deferrable — `${src:0:3}` is valid bash, and removing that collision
class beats documenting an escape for it.
Review board
------------
hermeticity returned NOT HERMETIC, now fixed. A deferred value inside a
credential declaration reached **no** consumer's key: a credential target's
`get_def` is never called on a build path, so an edge appended to the
credential's own def was never hashed by anyone, and a `terraform apply`
that moved the ARN produced a silent cache hit. The synthesized edges for
a resolved credential now land on the **consumer's** def, labelled with the
credential's address.
code-quality returned BLOCKED on two, both fixed. Resolution matched a
producer by `ends_with(":name")`, so `${read::v}` written in package `zzz`
resolved to whichever key happened to end `:v` — `//aaa:v`, in sort order —
and handed the author a different producer's bytes with no diagnostic; it
now normalizes against the declaring package, the same way the walk keyed
the map. And a credential's non-presentation fields (`audience`, `runner`,
a `rename` selector) accepted a reference and used it unsubstituted.
feature-quality returned BLOCKED on the escape. `$` meant two things: a
credential presentation is a document heph writes and owns `$$` for, while
a driver option is somebody else's shell text where `echo tmp.$$` is the
PID idiom. Folding `$$` into literal text made a field whose meaning
changed depending on whether a `${read:…}` appeared elsewhere in it. The
tokenizer now keeps `Piece::Escape` distinct and the two consumers use
different substituters. It also caught that reserving `src` and `env`
outright broke `${src:0:3}` and `${env:FOO}` in every `run`; the
reservation is now `read` only, and `src`/`env` say "not yet" only inside
a driver that accepts references.
compatibility: COMPATIBLE AFTER BUMP. `ABI_SEMVER` 0.9.0 → 0.10.0.
`RunRequest`/`ManagedRunRequest` gained `map<string, string> deferred`;
`Schema` gained `bool accepts_deferred`. Both additive and cold-path. The
bump exists for the schema field: an old plugin's `String` decoder rejects
nothing, so it would run the target with the reference text as the value —
`accepts_deferred` decodes as `false`, so the host refuses at parse
instead, naming the driver. `Deferred<T>`'s `Hash` delegates to the raw
text, so retyping an existing field leaves its def hash byte-identical;
without that, every target of that driver invalidates on upgrade and a
mixed fleet double-populates the shared remote.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
…h take one
Four corrections to the mechanism the commit below introduces, found by the
review board while scoping `${src://…}` on top of it. The first two are one
change: the claiming rule exists *because* of bash, and shipping the rule
without the capability would leave a restriction with no visible reason next to
a document explaining why bash is excluded.
**A reference is heph's only when its argument starts with `//`.** The kind name
alone was never enough — the shells whose syntax shares this shape are not going
away. Verified against a real shell rather than reasoned about:
src=abcdef; echo "${src::3}" -> abc bash keeps it
src=abcdef; echo "${src:0:3}" -> abc bash keeps it
echo "${FOO:-default}" -> default bash keeps it
src=abcdef; echo "${src://x:y}" -> arithmetic syntax error
echo "${src://x:y}" -> "" (unset)
echo "tmp.$$" -> tmp.12345
So the forms heph claims are, in bash, either an error or the empty string —
nobody writes them on purpose — while every form bash actually uses is
reproduced byte for byte. The `:name` and `./` relative forms an address
elsewhere accepts are deliberately *not* claimed: `${src::3}` is the exact text
of a real bash idiom, and no diagnostic is worth taking it over. The predicate
lives in `hcore::template::claims`, so the spec decoder, the engine walk and the
substitution give one answer rather than three.
**`bash` mode's `run` therefore becomes deferrable**, which was the design's one
open exclusion. `$SRC_<GROUP>` is unaffected and remains the right tool for a
declared dep group — it names a *group* and its value is that group's
space-joined paths, where `${read://x:y}` declares the edge inline and is a
single value. The consequence worth stating: heph substitutes, it does not
quote. In exec mode a value fills one argv element; in bash it is spliced into a
shell program, so a producer emitting `1.2.3; rm -rf /` runs it — and those bytes
may have been pulled from the *shared remote cache*, written by another machine.
That is a code trust boundary, not just a data one.
**The warm path was paying for a feature nothing used.** `deferred_refs`
computed `driver.schema().accepts_deferred` before checking whether the config
contained a `${` at all — and `schema()` is generated by `#[derive(Spec)]`, so it
builds a fresh `Vec<DriverField>` with a `name`, a `doc` and a `ParamType` per
field on every call. Measured for the exec spec: 82 allocations, 7212 bytes,
~1.6 µs, for every target on every build, cache hits included, in every
workspace including those using none of this. The `${`-pre-scan now comes first,
and `reserved_later`'s independent second traversal is behind it too. A driver
whose `schema()` counts calls pins it: nothing else can observe the difference,
because the answers are identical either way.
**A nested `${` is refused.** `${FOO:-${read://a:b}}` closes on the inner `}`, so
the outer form tokenized as an unknown kind, was reproduced verbatim, and the
inner reference silently never resolved and never complained. `${VAR:-default}`
is the commonest bash brace form there is.
**`${read:}` trims surrounding whitespace** rather than exactly one trailing
newline. `echo`, `printf '%s\n'` and `terraform output` end with a newline; a
file committed on Windows ends `\r\n`; a `yq`/`jq` pipeline can leave a trailing
space; an indented heredoc leaves leading ones. None of that is a role ARN, an
image tag or a registry host. An all-whitespace output is still empty, and still
an error.
Two smaller changes fall out of the rebase onto the credential-driver refactor
below. A presentation's `env`/`files` are now `BTreeMap<String, Deferred<String>>`
— the entire diff to make a field deferrable, exactly as advertised — so the
private `reject_reference`/`raw_string`/`template_map`/`str_map_inner` machinery
this branch had added to the credential driver is gone: the shared decoder
refuses a reference in every field not typed to take one. And the blanket
`FromSpecValue for Option<T>` carries `T`'s gate, so an optional field is
protected by the same rule as a required one.
`${env:NAME}` is dropped from the reserved set and will not ship. It is legal
bash (`${var:offset}`); getting its value into the def hash requires
substituting before `parse`, which is before the decoder that refuses a
reference in `out`, `deps`, `runner` and `name`; and it reaches a credential
declaration, whose inputs are `hashed: false` by design. The value it would have
carried composes from a `pass_env` producer plus `${read:}` — which keeps the
variable's *name* in the key, states the freshness choice with `cache = False`,
and shows up in `inspect deps`. Recorded in the doc so it is not proposed again.
Review board: hermeticity NOT HERMETIC, code-quality/feature-quality BLOCKED and
compatibility BREAKING, all on the `${env:}` and escape proposals this commit
declines; the findings against the shipped mechanism are the ones above. The
per-host cache-key divergence and the escape were put to the user, who chose to
drop `${env:}` and ship no escape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
raphaelvigee
force-pushed
the
feat/deferred-values
branch
from
September 9, 2026 10:49
54dabdf to
79c1dc7
Compare
raphaelvigee
pushed a commit
to hephbuild/hephbuild.github.io
that referenced
this pull request
Sep 19, 2026
hephbuild/heph#455 introduced the credential driver, hephbuild/heph#456 the underlying deferred-values mechanism it and #458's ${src://…} both build on. Only the deferred-values half was covered so far; add a Credentials concept page covering the driver, source chain, when vocabulary, presentation shapes and presets, the CLI, and redaction — sourced from heph's docs/CREDENTIALS.md and the plugincredential builtins on master. Cross-link it from the deferred-values page, which already noted a credential's present block as a consumer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc
3 tasks
raphaelvigee
added a commit
to hephbuild/hephbuild.github.io
that referenced
this pull request
Sep 19, 2026
* docs: document deferred values (${read://…} and ${src://…})
hephbuild/heph#458 added ${src://pkg:name} — the sandbox path of
another target's output — alongside the existing ${read://pkg:name}
contents form. Neither was documented on the site; add a Deferred
values concept page and cross-link it from the exec/bash driver docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc
* docs(deferred-values): fix copy-version example output path
cp's destination argument didn't match the declared out path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc
* docs: document credentials as targets
hephbuild/heph#455 introduced the credential driver, hephbuild/heph#456
the underlying deferred-values mechanism it and #458's ${src://…} both
build on. Only the deferred-values half was covered so far; add a
Credentials concept page covering the driver, source chain, when
vocabulary, presentation shapes and presets, the CLI, and redaction —
sourced from heph's docs/CREDENTIALS.md and the plugincredential
builtins on master. Cross-link it from the deferred-values page, which
already noted a credential's present block as a consumer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc
---------
Co-authored-by: Claude <noreply@anthropic.com>
raphaelvigee
added a commit
that referenced
this pull request
Sep 21, 2026
…master Post-rebase compile fixes only, no behavior change: - plugin-js-cdylib's PluginComponents literal needed the new `runners` field (from feat(execrunner): run targets in a described environment, #425) — js exports none, mirroring plugin-gha-cdylib/plugin-go-cdylib's own empty case. - Every test-only RunRequest literal in plugin-js needed the new `scratch`/`credentials`/`deferred` fields (from feat(scratch) #403/#434, feat(credential) #455, feat(deferred) #456) — empty/default, mirroring the same pattern already used in plugin-oci and builtins' pluginfs/ plugingroup tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the Deferred Values design, P1. Stacked on #455 — the credential driver's presentation templates are one of the two consumers.
A driver option's value has to be a literal in the BUILD file. But the value's owner is usually somewhere else — a role ARN in the Terraform that created the role, a registry per environment in a platform team's repo, a version in a release process. Copying it in makes a second source of truth that someone has to keep agreeing with the first, purely to satisfy heph.
The ARN appears exactly once in the repository, in the Terraform that defines it. heph reads it; it does not hold a copy.
What no other build system can express
Gradle, Bazel, Buck2, Pants and Nix each shipped an answer, and they all made the same structural choice: the value crosses the boundary as a bare string, and the thing that produced it never becomes a node. Three consequences follow mechanically — the producer must re-run eagerly on every build (which is why every one of them documents some version of "only use fast commands here"), the cache key can only hash the string and over-invalidate or hide it and sanction staleness, and "why did this rebuild?" bottoms out at the string. Each ecosystem then converged on the same workaround — commit a snapshot of the computed value — and every snapshot is a second source of truth that drifts, undetectably, for exactly the reason the snapshot was needed.
Here the producer is a node.
The def hash covers the unresolved reference, which is what keeps
heph queryandheph inspect deffrom triggering a build. The producer's content still reacheshashin, becausehashinis the def hash plus the hashouts of hashed inputs. No new hashing rule is introduced at any point — the correct behaviour is a structural consequence of where the work happens. Both halves already existed: synthesizing an edge that is not in the BUILDdepslist is whatrunnerdoes, and a producer's content reaching a consumer's key is whatinputs_result_metadoes for every hashed input.example/deferred/demonstrates it directly — the producer declares an input it does not use, so editing that input re-runs the producer and moves nothing, while editing the one that defines the value moves the consumer'shashin.Three layers, and a driver touches only the third
An earlier shape left three obligations on the plugin author: type the field, collect its edges at parse, substitute at run. Both "remembers" fail silently — a missed edge means the producer never builds and the value never enters the key; a missed substitution means
"${read://infra:role-arn}"is used as the ARN. That is the exact bug class this exists to remove, reintroduced one layer down.TargetSpec.configfor references at any nesting depth, appendsInputs todef.inputsafterparse, reads the producers, fillsRunRequest.deferred. Cannot be forgotten; the driver never participates.String::from_spec_valuerefuses${read:…}— so a reference inout,deps,nameor a glob is a parse error in every driver at once, with no driver code and no schema flag.role: String→role: Deferred<String>. Reading it requires a&RunRequest, so forgetting to resolve is a compile error.The entire diff to make a field deferrable:
#[derive(Spec)] struct MySpec { - role: String, + role: Deferred<String>, }accepts_deferredis derived from the field types, so there is nothing to remember there either. The walk is over the rawValuetree, which is what makes it work where a flat per-field schema could not: a credential'ssources = [heph.auth.oidc(…)]is a list of maps of maps, and the reference sits three levels inside it.The refusal
Rejected in
deps,tools,runner,scratch,out,nameandlabels. That buys three things: cycle detection still works at parse, because every edge is known before anything runs;heph queryandheph inspect defnever trigger a build; and evaluation never blocks on one. That last is the whole of Nix's import-from-derivation problem, which nixpkgs forbids outright — what is here is IFD with the recursion cut off at one level.A runner spec is never deferrable, as a standing rule: a runner target's fingerprint is what moves every consumer's cache key when the environment moves.
What ships, and what does not
Ships:
${read://…}, the host-side walk and edge append, resolution from the store,RunRequest.deferred,Deferred<String>, the//-address claiming rule, the whole-driver schema gate. Consumers: the credential driver's presentation templates, and theexecdriver'srunin both exec and bash mode.The claiming rule, and why bash is in after all
An earlier draft excluded bash on one argument:
${src:0:3}is valid bash, so accepting references there would create a collision an escape rule could only document. The rule below removes the collision instead — heph takes a${…}only when its argument is an absolute address. Verified against a real shell rather than reasoned about:${src::3}abc— first three chars of$src${src:0:3}abc— same, explicit offset${FOO:-default}default${src://x:y}""unsettmp.$$tmp.12345$$is not${So the forms heph claims are, in bash, either an error or the empty string; nobody writes them on purpose. The
:nameand./relative forms are deliberately not claimed —${src::3}is the exact text of a real bash idiom, and no diagnostic is worth taking it over. One predicate (template::claims) answers "is this heph's?" for the spec decoder, the engine walk and the substitution alike.The consequence is stated rather than buried: heph substitutes, it does not quote. In exec mode a value fills one argv element; in bash it is spliced into a shell program, so a producer emitting
1.2.3; rm -rf /runs it — and those bytes may have been pulled from the shared remote cache. A code trust boundary, not just a data one.Does not ship, ever:
${env:NAME}. It is legal bash (${var:offset}); getting its value into the def hash requires substituting beforeparse, which is before the decoder that refuses a reference inout,deps,runnerandname; and it reaches a credential declaration, whose inputs arehashed: falseby design. All three were board blockers. The value it would have carried composes from apass_envproducer plus${read:}, which keeps the variable's name in the key, states the freshness choice withcache = False, and shows up ininspect deps. Recorded in the doc so it is not proposed again.Does not ship yet:
heph.core.read()as a Starlark function;inspect deps"via" lines andinspect def --resolved; and the OCI family'sbuild_args,dest,labels,cache_from. (${src://…}lands in #458, stacked on this.)Also in this PR, from a second review pass
deferred_refsasked every driver for its schema before checking whether the config held a${at all — and#[derive(Spec)]rebuildsVec<DriverField>on each call: 82 allocations, 7212 bytes, ~1.6 µs per target, on every build including full cache hits, in workspaces using none of this. The pre-scan now comes first. A driver whoseschema()counts calls pins it, because nothing else can observe the difference.${is refused.${FOO:-${read://a:b}}closed on the inner}, so the outer form tokenized as an unknown kind, was reproduced verbatim, and the inner reference silently never resolved.${read:}trims surrounding whitespace rather than exactly one trailing newline —\r\nfrom a Windows-committed file, a trailing space from ayqpipeline, leading spaces from an indented heredoc. An all-whitespace output is still empty, and still an error.BTreeMap<String, Deferred<String>>— the entire diff to make a field deferrable, as advertised — which deletes the privatereject_reference/raw_string/template_map/str_map_innermachinery this branch had added to the credential driver.Review board
hermeticity — NOT HERMETIC, fixed. A deferred value inside a credential declaration reached no consumer's key: a credential target's
get_defis never called on a build path, so an edge appended to the credential's own def was never hashed by anyone, and aterraform applythat moved the ARN produced a silent cache hit. The synthesized edges for a resolved credential now land on the consumer's def, labelled with the credential's address.code-quality — BLOCKED on two, both fixed. Resolution matched a producer by
ends_with(":name"), so${read::v}written in packagezzzresolved to whichever key happened to end:v—//aaa:v, in sort order — and handed the author a different producer's bytes with no diagnostic; it now normalizes against the declaring package, the same way the walk keyed the map. And a credential's non-presentation fields (audience,runner, arenameselector) accepted a reference and used it unsubstituted.feature-quality — BLOCKED on the escape, fixed.
$meant two things: a credential presentation is a document heph writes and owns$$for, while a driver option is somebody else's shell text whereecho tmp.$$is the PID idiom. Folding$$into literal text made a field whose meaning changed depending on whether a${read:…}appeared elsewhere in it. The tokenizer now keepsPiece::Escapedistinct and the two consumers use different substituters. It also caught that reservingsrcandenvoutright broke${src:0:3}and${env:FOO}in everyrun; the reservation is nowreadonly, andsrc/envsay "not yet" only inside a driver that accepts references.compatibility — COMPATIBLE AFTER BUMP.
ABI_SEMVER0.9.0 → 0.10.0.RunRequest/ManagedRunRequestgainedmap<string, string> deferred;Schemagainedbool accepts_deferred. Both additive and cold-path. The bump exists for the schema field: an old plugin'sStringdecoder rejects nothing, so it would run the target with the reference text as the value —accepts_deferreddecodes asfalse, so the host refuses at parse instead, naming the driver.Deferred<T>'sHashdelegates to the raw text, so retyping an existing field leaves its def hash byte-identical; without that, every target of that driver invalidates on upgrade and a mixed fleet double-populates the shared remote.Tests
22 engine e2e tests (
crates/e2e/tests/deferred.rs) covering the two hashes, the refusals, the cycle, the failure modes and the size cap, plus unit tests for the tokenizer, the walk, the derive and the ABI conversion.docs/DEFERRED_VALUES.mdhas the full design;example/deferred/is a worked package. Fulltstpasses; the only failure is the pre-existingtest_real_docker_default_builder_without_exporters_is_diagnosable, which needs a running Docker daemon.🤖 Generated with Claude Code
https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
Stack created with GitHub Stacks CLI • Give Feedback 💬