Skip to content

feat(deferred): ${src://…} — the sandbox path of an artifact - #458

Merged
raphaelvigee merged 3 commits into
feat/deferred-valuesfrom
feat/deferred-src-env
Sep 19, 2026
Merged

raphaelvigee merged 3 commits into
feat/deferred-valuesfrom
feat/deferred-src-env

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

Implements ${src://pkg:name} — the sandbox path of a producer's artifact, where ${read://pkg:name} (#456) is its contents. Stacked on #456.

target(name = "parser", driver = "bash", deps = [file("grammar.y")],
       out = "parser.c", run = "bison -o $OUT $SRC")

target(name = "app", driver = "exec",
       run = ["cc", "-o", "$OUT", "${src://gen:parser.c}"])

This is $SRC_<GROUP> finally available in an exec argv — where there is no shell to expand it in — and without a deps entry to keep in sync with the reference that uses it. 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

(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.

ref hashed staged transitives
${read://x:y} yes no no
${src://x:y} yes yes no ← new
deps yes yes yes

Writing a path expression asks for a path, so the edge carries an annotation collect_transitive_deps skips. Take the artifact as an ordinary deps entry 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 FUSE sandbox_dir is redirected after the host has set it. So invoke_inner completes it — still host-side, before anything crosses the plugin ABI.

That placement is load-bearing. crates/driver-support is statically linked into every plugin cdylib, so putting the substitution inside a ManagedDriver implementation would version it with each plugin: an older cdylib would advertise accepts_deferred: true (it has a Deferred field for read) 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 read values it also names, rather than half-substituting and re-scanning later. A second pass would read a producer's own bytes as references — what hcore::template is single-pass to prevent.

No ABI change. deferred_pending is a Rust-only field on RunRequest, consumed before serialisation; ABI_SEMVER stays where #456 left it, and abi-check.sh reports no surface change.

A dedup bug this exposed

inputs_for_labelled keyed on the producer's address alone. 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.

Refusals

  • A producer emitting several files has no single path: the error lists them, checked against the staged file list so the message is what is actually there, and points at |<group>.
  • A producer emitting nothing: there is no path to substitute.
  • ${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_LATER and its second per-get_def config 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 the pass_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; |group narrows 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-bytes is a worked exec consumer.


🤖 Generated with Claude Code

https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm

Stack created with GitHub Stacks CLI • Give Feedback 💬

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
raphaelvigee added this pull request to stack #457 September 9, 2026 10:50
@raphaelvigee raphaelvigee added the ci/force-ci Run full CI on this stacked PR, which the gate would otherwise skip label Sep 9, 2026
raphaelvigee and others added 2 commits September 11, 2026 13:23
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
@raphaelvigee
raphaelvigee merged commit d21817d into master Sep 19, 2026
23 checks passed
@raphaelvigee
raphaelvigee deleted the feat/deferred-src-env branch September 19, 2026 15:41
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/force-ci Run full CI on this stacked PR, which the gate would otherwise skip

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant