feat(deferred): ${src://…} — the sandbox path of an artifact - #458
Merged
Merged
Conversation
The second reference kind the design named. `${read://x:y}` is a producer's
*contents*; `${src://x:y}` is the sandbox *path* of its artifact — `$SRC_<GROUP>`
finally available in an `exec` argv, and without a `deps` entry to keep in sync
with the reference that uses it.
target(name = "a", driver = "exec",
run = ["cc", "-o", "$OUT", "${src://gen:parser.c}"])
Both kinds may name an output group — `${src://tools:cli|bin}` — through
`TargetAddr::parse`, the same function `deps` goes through, so a reference and a
dep agree about what an address is.
**A third cell, and it is the user's call recorded.** `(hashed, staged)` is the
`deps` cell, and an edge there is also merged by `collect_transitive_deps`: the
producer's `transitive` tools, deps and env follow into the consumer, and
`apply_transitive` folds those env *values* into the consumer's def hash by
reading `std::env::var`. So `${src://tools:go}` on a producer declaring
`transitive = {"env": {"GOFLAGS": pass}}` would move the consumer's cache key
with the host's `GOFLAGS` — for a target that only wanted a filename. Writing a
path expression asks for a path, so the edge carries an annotation that
`collect_transitive_deps` skips. Take the artifact as an ordinary `deps` entry
when you do want the environment.
**Substituted host-side, in the managed-driver layer.** The path is the one
value the host cannot compute in `execute`: it exists only once the artifact is
staged, and under FUSE `sandbox_dir` is redirected after the host has set it. So
`invoke_inner` completes it — which is still host-side, before anything crosses
the plugin ABI, so no driver participates and no cdylib ships its own copy of
the rule. Putting it behind the ABI would have let an older plugin advertise
`accepts_deferred: true` and then run the command with `${src://x:y}` as a
literal argv element, which is the silent-literal class this mechanism exists to
remove.
The completion is one pass over the *author's* text: the host hands over the
field whole, together with the `read` values it also names, rather than
half-substituting it and re-scanning later — a second pass would read a
producer's own bytes as references, which is what `hcore::template` is
single-pass to prevent.
**The dedup was wrong the moment two kinds existed.** `inputs_for_labelled` keyed
on the producer's address alone, and `collect` sorts refs by their whole field
text — so `"${read://a:b}"` sorted before `"${src://a:b}"`, the `read` edge won,
the artifact was never staged, and `${src://a:b}` resolved to a path that did not
exist. Which of the two survived depended on the surrounding literal characters.
The key is now `(address, staged)` and the test asserts both sort orders.
**Arity is checked against what was actually staged.** A producer emitting a
binary and a man page has no single path; the error lists the files and points
at `|<group>` rather than substituting whichever sorted first.
`${src:}` is refused in a credential declaration: a credential is a document the
host reads, not a target that runs, so there is no sandbox for a path to point
into.
Nothing is reserved any more — `RESERVED_LATER` and its second config walk are
gone, and `${env:NAME}` is not a heph kind at all (see the commit below).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
raphaelvigee
added this pull request to stack #457
September 9, 2026 10:50
Three things in `example/deferred/` said one thing and did another.
The `exec` consumers both ran `sh -c`, directly under a comment explaining that
there is no shell — and `version-bytes` piled a positional-argument trick on top
(`sh -c '… "$1" …' sh "${src://…}"`) to smuggle the path in. That demonstrated
the opposite of the point.
Now each target shows the case it is actually for:
- `//deferred:image` is `bash`. Writing a computed string into a file is a
shell's job, and bash takes references, so the splicing note belongs here —
where it is true.
- `//deferred:version-copy` is `exec`, and hands the path to `cp` as one argv
element. Nothing parses it, so nothing can be spliced into it. That is the
case `$SRC_<GROUP>` could never serve, since expanding it needs a shell.
The README repeated two claims that stopped being true a commit ago — that
`${src://…}` is "named by the design and not implemented", and that the example's
value is handed to `sh -c`. It also documented the hashes as "note the hash";
they are now the measured values (`bda27f9cec08eb6f`, moving to
`793e79ba462c87de` when `version` changes and not when `notes` does), so the
property can be checked rather than taken on trust.
And `docs/DEFERRED_VALUES.md`'s "does not ship yet" paragraph was left at 90
columns by an earlier scripted edit that removed text from the head of the line
without rewrapping. Rewrapped; nothing else in the file exceeds its width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
… one at the ABI
`RunRequest` carried the deferred values in two fields — a finished `deferred`
map and a `deferred_pending` list — which meant twenty-odd construction sites
each writing `deferred_pending: Vec::new()`, and one of them (the plugin side of
the ABI, where there is nothing on the wire to decode it from) needing a comment
to explain why empty was correct. A comment is the wrong place for an invariant.
They are now one map of a two-state value:
pub deferred: BTreeMap<String, DeferredValue>
enum DeferredValue {
Ready(String),
NeedsSandbox { reads: …, srcs: … },
}
The point is not tidiness. "Not finished" has to be **representable** to be
reportable: as a separate list, an unfinished field was simply *absent* from the
map a driver reads, which is indistinguishable from a field that was never
deferred — so the failure mode was a reference text used as a value, silently.
Now `RunRequest::resolve` can tell the three cases apart and says which is which.
And the state that must never cross the plugin ABI now fails there instead of
being dropped. `convert::deferred_to_pb` refuses a `NeedsSandbox`, naming the
field and blaming the host, because dropping it would surface later as a missing
key reported against the author's BUILD file. That matters specifically because
`driver-support` is statically linked into every plugin cdylib: completion has to
stay host-side, and a plugin built against an older copy would otherwise run the
command with `${src://x:y}` as a literal argv element.
Two tests pin it: `deferred_to_pb` refuses an incomplete entry and passes a
finished one, and `resolve` distinguishes ready / unfinished / never-deferred —
the last of which also covers a field carrying `${FOO:-d}`, which heph does not
own and must hand back verbatim.
Net −20 lines of boilerplate, one fewer field, and the invariant checked where it
is established rather than asserted where it is convenient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
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>
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
${src://pkg:name}— the sandbox path of a producer's artifact, where${read://pkg:name}(#456) is its contents. Stacked on #456.This is
$SRC_<GROUP>finally available in anexecargv — where there is no shell to expand it in — and without adepsentry to keep in sync with the reference that uses it. Both kinds may name an output group (${src://tools:cli|bin}) throughTargetAddr::parse, the same functiondepsgoes through, so a reference and a dep agree about what an address is.A third cell
(hashed, staged)is thedepscell, and an edge there is also merged bycollect_transitive_deps: the producer'stransitivetools, deps and env follow into the consumer, andapply_transitivefolds those env values into the consumer's def hash by readingstd::env::var. So${src://tools:go}on a producer declaringtransitive = {"env": {"GOFLAGS": pass}}would move the consumer's cache key with the host'sGOFLAGS— for a target that only wanted a filename.${read://x:y}${src://x:y}depsWriting a path expression asks for a path, so the edge carries an annotation
collect_transitive_depsskips. Take the artifact as an ordinarydepsentry when you do want the environment. This was put to the user as a design decision rather than settled in the implementation.Substituted host-side, and why that mattered
The path is the one value the host cannot compute in
execute: it exists only once the artifact is staged, and under FUSEsandbox_diris redirected after the host has set it. Soinvoke_innercompletes it — still host-side, before anything crosses the plugin ABI.That placement is load-bearing.
crates/driver-supportis statically linked into every plugin cdylib, so putting the substitution inside aManagedDriverimplementation would version it with each plugin: an older cdylib would advertiseaccepts_deferred: true(it has aDeferredfield forread) and then run the command with${src://x:y}as a literal argv element. That is the silent-literal class this whole mechanism exists to remove, reintroduced one crate down.The completion is one pass over the author's text: the host hands the field over whole, together with the
readvalues it also names, rather than half-substituting and re-scanning later. A second pass would read a producer's own bytes as references — whathcore::templateis single-pass to prevent.No ABI change.
deferred_pendingis a Rust-only field onRunRequest, consumed before serialisation;ABI_SEMVERstays where #456 left it, andabi-check.shreports no surface change.A dedup bug this exposed
inputs_for_labelledkeyed on the producer's address alone.collectsorts refs by their whole field text, so"${read://a:b}"sorted before"${src://a:b}"— thereadedge won, the artifact was never staged, and${src://a:b}resolved to a path that did not exist. Which of the two survived depended on the surrounding literal characters. The key is now(address, staged), and the test asserts both sort orders.Refusals
|<group>.${src:}in a credential declaration is refused at parse — a credential is a document the host reads, not a target that runs, so there is no sandbox for a path to point into.Nothing is reserved any more:
RESERVED_LATERand its second per-get_defconfig walk are gone.Review board
The design for this went to product-vision, hermeticity, compatibility and feature-quality before any code.
${src:}came back HERMETIC WITH EXEMPTIONS, and both exemptions were put to the user rather than settled here — the absolute-vs-relative path form (absolute, matching$SRC_<GROUP>; an e2e test asserts the two produce byte-identical strings) and the transitive-merge question above. The host-side substitution point and the(address, staged)dedup key are direct board findings.The same review blocked
${env:NAME}on three counts, and it is not in this PR — see #456, which documents thepass_env+${read:}composition that replaces it with better provenance.Tests
Six new e2e tests: the path resolves to a file that is really there;
${src:}and$SRC_<GROUP>agree byte for byte; the producer's transitive env does not follow; a multi-file producer names its files;|groupnarrows one; a credential refuses it. Plus unit tests for the two cells, the dedup in both sort orders, group parsing, and that no bash brace form is collected.example/deferred/version-bytesis a workedexecconsumer.🤖 Generated with Claude Code
https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
Stack created with GitHub Stacks CLI • Give Feedback 💬