Skip to content

Environment Composition

This page is the reference-level specification for how OCX assembles environment variables and selects which toolchain tier is active. For the motivation behind these design decisions, see Environment composition in the user guide.

Strict Isolation

OCX enforces a hard boundary between the global toolchain and project-tier resolution. The rule is unconditional:

Global tools never compose into, supplement, or fall back into a project's resolved environment.

This applies without exception to:

  • ocx exec — project-tier env-composition command. Reads ocx.toml + ocx.lock. The global toolchain ($OCX_HOME/ocx.toml) is not consulted, not merged, and not used as a fallback for tools the project does not declare.
  • ocx package exec — OCI-tier env-composition command. Never reads any ocx.toml, whether project or global. Takes OCI identifiers directly.

Both commands are hermetic: the environment they produce is determined entirely by their declared inputs. An undeclared tool is absent, never filled from the global set.

Why hard isolation instead of gap-fill?

Volta pioneered this model for Node.js: global tools are hidden when a project toolchain is active. The alternative — filling in tools the project does not declare from the global set — produces the reproducibility hole OCX is designed to close: collaborators without the same $OCX_HOME/ocx.toml get different resolved environments.

PATH precedence model

OCX enforces isolation by PATH precedence, not PATH stripping. The global toolchain's current/entrypoints/ directory sits on PATH at login time (via $OCX_HOME/env.sh sourced from the login profile). When a project toolchain is activated — via ocx exec or ocx direnv — the project tools are prepended to PATH, shadowing any global tools of the same name.

There is no PATH strip, no # ocx: global toolchain suppressed comment, and no _OCX_APPLIED fingerprint. The per-prompt shell hook (ocx shell hook) has been removed entirely. Isolation is a static consequence of PATH ordering: project tools appear earlier in PATH than global tools.

For ocx direnv, the .envrc evaluates ocx direnv export on every directory entry. This emits only the project tools' PATH entries, which direnv prepends before the ambient PATH — global tools remain reachable for tools not declared by the project, but project-declared tools take priority.

Idempotent re-application

Every PATH prepend OCX emits is idempotent with move-to-front semantics. Re-applying the same output — direnv re-evaluating .envrc on each directory change, a captured snippet re-read from a profile, or a tool re-running ocx env --shell — never grows PATH. A directory already present is removed from its old position and placed at the front, so the most recent activation wins lookup and the variable stays a fixed length.

The emitted shell statements are self-contained: they depend on no ocx process, no guard variable, and no helper function. That makes them safe to capture into a profile —

sh
ocx package env kitware/cmake --shell bash >> ~/.bashrc

— where every later shell re-sources the block with ocx possibly absent and the directory still lands exactly once, at the front. The same move-to-front dedup applies to the in-process environment (ocx exec, ocx package exec) and to CI exports (--ci=github, --ci=gitlab).

Shell-specific requirements

All ten supported shells — bash, zsh, ash, ksh, dash, fish, PowerShell, elvish, nushell, and Windows cmd — emit idempotent move-to-front output. The cmd form rebuilds PATH with %VAR:search=% substring deletion (no FOR /F, no delayed expansion, so !-bearing paths stay intact) and matches segments case-insensitively, the way Windows PATH lookup does. A couple of shells have version floors: elvish needs the str: module (0.16+); nushell needs the auto-list PATH conversion (0.101+).

What "hermetic" means for ocx exec

ocx exec reads exactly two files: ocx.toml and its sibling ocx.lock. The resolved environment consists of the tools those files declare — no more. If a tool is not in ocx.toml, it is not in the child environment, regardless of what is installed globally or what is on the parent shell's PATH.

Naming a binding subset (ocx exec cmake -- …) narrows composition further: only the named tools are resolved to a host leaf and installed. A -g group selects the namespace for name resolution, not a mandate that every tool in it be available — an unrelated tool in scope with no leaf for the current host does not block a narrowly-named run. Omit the names and the whole scope must resolve.

By default ocx exec inherits the spawning shell's environment and merely prepends the composed tool bin/ directories to PATH — ambient parent-shell PATH entries remain reachable after the project tools. The default is not hermetic. Pass --clean for a hermetic environment that drops the inherited environment and exposes only the composed tool set, exactly like exec --clean.

One part of the spawning shell's environment is not inherited: whatever shell integration applied to it. An explicit ocx exec names the environment it wants, so the toolchain the per-prompt reconciler folded into your shell is taken back out first, using the __OCX_ENV_STATE ledger as the revert set — the same set the reconciler itself would revert on leaving the directory. Your own variables and your own PATH entries are untouched; only what OCX put there is removed, and only where the value is still the one OCX wrote. Without shell integration there is no ledger and nothing is removed. The same applies to ocx package exec, ocx package test and ocx patch test.

What "hermetic" means for ocx package exec

ocx package exec takes one or more OCI identifiers on the command line. It resolves each identifier, composes the declared environment variables from the resolved packages, and spawns the command with that environment. No ocx.toml is read — not the project file, not the global file. The entire operation is stateless with respect to project configuration.

Patch Opt-Out Scope

A project can opt a base package out of the [patches] companion overlay with no-patches. That opt-out lives in ocx.toml, so it is subject to the same explicit-scope rule as every other project setting: it applies only where the project file is directly read, and nowhere else.

Without an explicit forwarding step, a launcher re-entry would silently break that promise. A package's generated entrypoint re-enters ocx through the hidden ocx launcher exec subcommand, resolving its own base from a synthetic content-addressed identifier rather than ocx.toml — so on its own it has no way to know the parent project opted this base out, and would re-apply the companion the parent just suppressed.

ocx exec closes that gap by forwarding the opt-out to the child process over OCX_PATCHES: alongside the resolved [patches] tier, it includes the opted-out bases' canonical registry/repository keys and the content digest of each one actually resolved that run. The digest leg is what a launcher's re-entry matches against, since it has no repository path to compare. ocx env and ocx direnv export read the same project config and honor the opt-out directly in the environment they compose — they have no child launcher to forward it to.

A launcher invoked outside this chain — standalone, or re-entered through the OCI-tier ocx package exec — decodes no forwarded opt-out from its environment and composes the companion overlay as if no-patches were never set. A system-required tier is unaffected either way: enforcement is not subject to the opt-out at all. See Per-package opt-out in the Patching packages guide for the full walkthrough.

Tier Selection

OCX has two toolchain tiers. Selection is always explicit — there is no implicit fallback from project to global.

TierHow to activateFile
ProjectCWD walk finds ocx.toml; or --project <path>; or OCX_PROJECTnearest ocx.toml ancestor
Global--global flag; or OCX_GLOBAL$OCX_HOME/ocx.toml

The two flags are mutually exclusive — combining --global with --project exits with code 64 (UsageError).

No implicit home-tier discovery. Earlier versions of OCX fell back to $OCX_HOME/ocx.toml when the CWD walk found nothing. That behavior has been removed. The global toolchain is only active when explicitly requested. A CWD walk that finds nothing means no project tier is active — the command operates without a project context.

Root --global affects these toolchain-tier commands

--global is a root flag — it must appear before the subcommand (e.g. ocx --global add ripgrep:14). The following toolchain-tier commands are affected when --global is set:

CommandWith --global
ocx addAdds binding to the global file
ocx removeRemoves binding from the global file
ocx lockRe-locks the global file
ocx updateAdvances a binding in the global file
ocx pullPre-warms packages declared by the global file
ocx execComposes env from the global file + its lock
ocx envEmits composed toolchain env for the global file

Toolchain Activation

A tier being selected and a tier reaching your shell are two different things. Selection answers which ocx.toml is in effect; activation answers what that toolchain puts in the environment, and when.

Recomposing a whole toolchain on every prompt costs a little work per prompt and rewrites PATH under you. Composing nothing and letting a launcher resolve each tool at invocation time costs nothing per prompt but gives a shell no declared variables. Neither is right for every toolchain, so two ocx.toml keys decide per toolchain: activate picks the shape, and pinned picks whether composed paths follow the rendered links/<group>/<entry> links or name digest paths straight out of ocx.lock.

The activate × pinned matrix

activatepinnedWhat the project contributes to PATHWhat a composing emitter composesWhat ocx pull renders
envfalseits composed entries — each tool's own PATH directoriesthrough the links/<group>/<entry> linksthe trampolines and the links
envtrueits composed entries — each tool's own PATH directoriesdigest paths from ocx.lock, consulting no linkthe trampolines only — the link pass writes and prunes nothing
binfalse<home>/toolchain/active/bin, and nothing elsethrough the links/<group>/<entry> linksthe trampolines and the links
bintrue<home>/toolchain/active/bin, and nothing elsedigest paths from ocx.lock, consulting no linkthe trampolines only
nonefalsenothingthrough the links/<group>/<entry> linksthe trampolines and the links
nonetruenothingdigest paths from ocx.lock, consulting no linkthe trampolines only

The session's own directories — $OCX_HOME/toolchain/active/bin and OCX's install directory behind it — are on PATH in every row, including none. They are minted at shell start and again per prompt, by neither toolchain's mode.

Two things the matrix's shape is saying:

The prompt's own PATH takes the lane too. In env mode the per-prompt reconciler is itself a composing emitter: it composes the project's default group and, with pinned false, follows that group's links/<group>/<entry> links — so the project entries on your PATH are link paths. It carries no flag of its own, so ocx.toml and OCX_TOOLCHAIN_PINNED are the only tiers that answer for it.

Five emitters compose, two carry the flag. ocx env, ocx exec, ocx direnv export, the env-mode reconciler and the env-mode login exporter all take a lane; only ocx env and ocx exec declare --pinned / --no-pinned. The last two read the global tier's activate, so under bin or none neither composes at all and the lane question does not arise for them. pinned selects the lane for all five, and separately decides whether a render writes links at all.

A row promises a lane, not every path in it. Two qualifications on the link cells, both by design and neither reported: a lock entry whose link is absent, stale, or not a link composes on its digest path while its siblings still compose through theirs — and the digest path is the correct path, the same directory under its other spelling. And only roots have links at all: a dependency's PATH contributions and every ${deps.<name>.installPath} are digest paths in both lanes, so an ocx update moves a composed environment's roots and leaves its dependency paths where the previous lock put them. See --pinned for both.

Turning pinned on never deletes a tree. The link pass is suppressed, not reconciled to empty: switching the key on and back off again takes effect with no re-render.

The bin rows carry one gate. The <home>/toolchain/active/bin entry is added only when the tree's render stamp matches what is on disk for this project and this lock. When it does not — an unrendered tree, or a lock that has moved since — nothing is added and the prompt prints one line instead:

ocx: /home/dana/work/api: its toolchain has not been rendered for this lock; run `ocx pull` here

Within one prompt the entries land front-to-back as: the project's composed entries, the project's <home>/toolchain/active/bin, $OCX_HOME/toolchain/active/bin, OCX's own install directory, then the global tier's composed entries. A globally installed tool therefore never shadows the project's own — the tier inversion this page opens with, enforced by ordering. ocx itself is no exception: the install directory is last, so a toolchain that pins ocx is the one that answers for the name.

The same two positions mise and rustup take

mise ships both shapes and names them the same way round: mise activate recomposes the environment on every prompt, mise activate --shims puts one shim directory on PATH instead. rustup only ever ships the second — ~/.cargo/bin/cargo is a proxy that forwards to whichever toolchain is active. env and bin are those two positions, chosen per toolchain rather than once per install.

A trampoline composes at call time

The surprising part of bin mode is that a trampoline in <home>/toolchain/active/bin/ carries no frozen environment. It re-enters ocx exec against its own home, so the environment is composed at the moment the tool runs — from ocx.toml and ocx.lock, project [env] and sibling packages included, not just the one package the tool came from.

The whole body is five lines, and what it bakes is the entire story:

sh
#!/bin/sh
# ocx-toolchain-trampoline
unset OCX_GLOBAL OCX_PROJECT
__ocx_binary='/home/dana/.ocx/symlinks/ocx.sh/ocx/cli/current/content/bin/ocx'
exec "${OCX_BINARY_PIN:-$__ocx_binary}" --project '/home/dana/work/api' exec -- "${0##*/}" "$@"

Two baked values, and nothing else: the home selector (--project <root>, or a bare --global for the global tier) and the absolute path to ocx. No tool path, no digest, no environment. On Windows the same selector rides a one-line <name>.exec sidecar beside a native <name>.exe, carrying either the absolute project root or the literal global.

Two consequences follow directly:

  • A config edit takes effect with no re-render. Change [env], add a tool, flip pinned — the next invocation composes against the new file. The trampoline body is byte-identical either way.
  • There is no baked value that can drift. The tool a name resolves to is decided by the lock at call time, not by what the renderer saw.

The absolute ocx path is baked rather than left as a bare ocx for a specific reason: in bin mode the interactive shell has toolchain/active/bin prepended, and a project may pin its own ocx. A bare-name fallback would make /bin/sh re-resolve the trampoline as itself — an infinite loop before any ocx process starts, which no in-process guard can see. asdf hit exactly this — a shim of asdf itself on PATH, re-resolving to the shim, hundreds of frames deep and hanging every command. OCX_BINARY_PIN still overrides the baked path.

Name collisions

Two packages in one closure can claim the same tool name. OCX resolves it and moves on: the last one walked wins, matching composed-PATH order, so the trampoline name and the name that wins a PATH lookup are decided by one rule rather than two.

There is no warning and no refusal. The collision is logged at debug level, and the losing claim is recorded against the winning name so it can be reported rather than lost. A refusal here would be wrong on its face: a project may legitimately pin its own ocx, or its own python, and shadowing that name for itself is the declared intent, not an accident.

A package that claims one name on both its binaries and its entrypoints axes is making one claim, not two — it can never shadow itself.

Visibility Surfaces

Each OCX package declares two environment surfaces: the interface surface (what consumers see) and the private surface (what the package's own launchers see).

The --self flag on package env, package exec, package test, and package deps switches which surface is emitted. It is OCI-tier only — the project-tier commands do not accept it, because a toolchain is a consumer of every tool it declares and the self view leaves those tools' entrypoints/ off PATH:

--selfSurface emittedUse case
off (default)Interface surface — vars where has_interface() is trueHuman or CI script using the package
onPrivate surface — vars where has_private() is trueGenerated launchers invoking ocx launcher exec internally

Generated launchers force self_view = true internally; they do not expose --self to callers.

Self-Referencing Values

A package's env values can reference each other, not just ${installPath}. ${self.env.KEY} resolves to the resolved value of this package's own earlier-declared KEY var, so a computed path or value is named once and reused instead of repeated in every value template that needs it — the same reuse an earlier GitHub Actions step's output, or a Bazel --define, gives a workflow, applied to one package's own metadata.

json
{
  "env": [
    { "key": "TOOL_HOME", "type": "constant", "value": "${self.installPath}/sdk" },
    { "key": "TOOL_CFG",  "type": "constant", "value": "${self.env.TOOL_HOME}/etc" },
    { "key": "TOOL_BIN",  "type": "path",     "value": "${self.env.TOOL_HOME}/bin" }
  ]
}

TOOL_CFG and TOOL_BIN both build on TOOL_HOME without repeating ${self.installPath}/sdk. If the SDK subdirectory ever moves, one edit to TOOL_HOME propagates to every var built from it.

${self.env.KEY} may only reference a KEY declared strictly earlier in the same package's env array — a forward or self reference, or a reference to a key declared twice, is refused at publish time (see self.env on the metadata reference for the full resolution rules and the generator-order hazard). It resolves to KEY's resolved value, not its unexpanded template, and resolution is surface-independent: it reads the same bytes regardless of KEY's own visibility or which surface (--self on or off) is being composed. It is legal only in env values, never in entry-point args. See Interpolation Tokens for the full token grammar shared by both.

A template fault anywhere in a package's env can fail the whole composition

Surface-independent resolution (above) means OCX cannot resolve only the vars a surface is about to emit — a public var's ${self.env.KEY} might name a private KEY, so every declared var, regardless of its own visibility, is resolved before the composer decides what crosses. A malformed template, a required path that does not exist on disk, or an unresolvable ${self.env.KEY} reference on any var — including one that will not itself be emitted on the surface in play — fails the whole composition with exit 65. This holds for ocx env / ocx package env and ocx exec / ocx package exec alike, on either surface: a package's own metadata either resolves in full or the composition refuses, independent of who is asking or which surface they asked for.

Integrations

Package metadata can declare vendor-namespaced configuration blocks for tools OCX has no model for at all — an editor extension list, a devcontainer fragment, a JetBrains plugin set. See Integrations in the metadata reference for the field's grammar, namespace-key rules, size caps, and interpolation. This section covers what composition does with those blocks once ocx env and ocx package env assemble them.

Composition never merges. Two packages that declare the same namespace produce two independent rows in the output, never one combined row. devcontainer.json takes the opposite approach — merging every Feature's customizations contribution into a single object per tool — and OCX deliberately does not follow it; see No Merge, Ever for the full comparison. A consumer that needs to reconcile two blocks does so itself; OCX never picks a winner or combines fields on its behalf.

Why not merge, like devcontainer.json does?

Merging means someone has to resolve a conflict when two contributions disagree — devcontainer.json leaves that decision to whichever tool reads the merged object. OCX has no way to know which of two colliding settings objects should win, so instead of guessing it keeps both, each attributed to the package that declared it.

Row Shape

The composed JSON envelope carries integrations as a fourth top-level array, alongside entries, binaries, and entrypoints — never nested inside entries:

json
{
  "entries": [  ],
  "binaries": [  ],
  "entrypoints": [  ],
  "integrations": [
    {
      "namespace": "com.microsoft.vscode",
      "package": "kitware/cmake:3.28@sha256:aaaa…",
      "payload": { "extensions": ["rust-lang.rust-analyzer"] }
    }
  ]
}

namespace is the declared key; package is the canonical resolved identifier of the package that declared it (a tag may be absent — a digest-pinned identifier is legal); payload is that package's block, fully interpolated against that package's own ${installPath}, never the composing root's. The array is present, with attribution, even when composing a single package — it is never collapsed to a bare object, so the one filter idiom that works against a multi-package composition (.integrations[] | select(.namespace=="…")) is the only idiom anyone needs to learn.

Two packages declaring com.microsoft.vscode therefore produce two rows with identical namespace but different package — an array whose length exceeds its distinct-namespace count is the visible proof nothing merged.

Ordering

Rows follow the same admitted-set walk order Composition Order below uses for entries: for each root, its admitted dependencies first in topological order, then the root itself; roots in the order the tool set produced them. Within one package's own contribution, namespaces are ordered lexicographically. A dependency reached by two different roots (a diamond) contributes once, at its first-seen position — the same cross-root dedup binaries and entrypoints already apply.

Interface Surface Only

Integrations reach only the interface surface at every depth — --self always composes "integrations": [], whether from ocx package env --self or a generated launcher's internal self view (forced, never exposed as a flag). This differs from binaries and entrypoints, whose surface membership follows the two-axis visibility each declared entry carries; an integrations block has no visibility field of its own, because the field belongs to the interface surface structurally rather than by a declared axis. A dependency reached only through a private edge still contributes nothing on either surface — the same edge rule that already governs when its env vars cross.

Patch Companions Contribute Too

A patch companion declaring integrations contributes them exactly the way an ordinary package does — a companion is a package loaded into the environment, and no carrier gives it exceptional rules. The row's package field names the companion's own identifier, never the base it was admitted for, so a consumer can always distinguish a site-policy contribution from one belonging to the package it asked for. A companion matched against several bases contributes once, matching the dedup its env entries already get.

This is what makes a site-wide com.microsoft.vscode proxy or CA-bundle setting expressible: site policy publishes it once as a companion instead of every package author restating it.

The interface-surface rule applies unchanged and is not evaluated per contributor — under --self the array is empty regardless of who declared what. Declining a companion is the existing no-patches opt-out, which drops an optional companion whole (its env along with its integrations); a required companion is required on the same terms its env already is.

Absent from --shell and --ci

Neither --shell nor --ci output carries an integrations representation. A shell export line and a CI sink's key/value pair have no shape for an arbitrary JSON payload, and inventing one — a serialized blob packed into an env var — would be a second wire format for data the JSON envelope above already has one for. Only --format json carries the array.

Composition Order

When multiple packages contribute to an environment (via ocx exec -g GROUP1,GROUP2 or ocx package exec PKG1 PKG2), env entries are prepended — the last tool walked has its PATH entries placed first in the resolved PATH. In -g argument order, groups listed later win PATH lookup.

For ocx exec, the full order rule is:

First by group-selection order (the order of -g flags, after all expansion, deduplicated); then alphabetical by binding name within each group.

See In Depth — Project Toolchain → Composition order rule for the worked example with -g ci,all,release.

List Variables

The prepend rule above is for path entries. A list entry does the opposite: each contribution is appended, matching how the surveyed option-list consumers themselves resolve duplicates — GODEBUG scans its setting list backward, and a repeated NODE_OPTIONS scalar flag takes the last occurrence. Ordering a list value the same way a path value is ordered would put the wrong contribution first for consumers like these.

Every fold — path or list — is a render, not accumulated state: the composer recomputes a variable's value from the ambient value plus every applicable contribution, in application order:

[prepend-zone: vector reversed] [ambient value] [append-zone: vector order]

path contributions land ahead of the ambient value, most-recently-applied first (move-to-front). list contributions land after it, in the order applied (move-to-back) — the last package or project stage to contribute a list value lands at the very end, which is what a last-wins consumer resolves to.

Idempotence, the same guarantee as path

Re-running the fold with a value already present removes it from its old position and re-appends it at the back, so a repeated ocx exec, a re-evaluated .envrc, or a launcher re-entry never grows a list variable — the same move-to-front idempotence path entries guarantee, mirrored for the opposite end.

Separator agreement across a composition

A list entry's separator is settled per key, not per entry: every contribution to one key over one composition must agree. The first entry that declares an explicit separator establishes it for that key; a later entry that omits separator inherits the established one, and a key nobody gives an explicit separator falls back to a single space. Two entries for the same key with different explicit separators fail the whole composition closed (exit 65), naming the key and both separators, Rust debug-quoted (e.g. "," and ";") — a package declaring GODEBUG with , plus a project [env] entry that omits the separator inherits the comma, keeping GODEBUG parseable; the same package plus a project entry that explicitly writes ; is a conflict, not a silent second delimiter.

This agreement runs once every contributing entry for the composition is known — package-composed, patch-companion, project, group, and --env alike — so it sees the whole picture before any fold happens. See separator is required on package metadata and the [env] value grammar for where each surface may or must spell the separator out.

Settling a separator also re-checks every entry's value against it, because a parse-time check can only compare a value to the separator that entry itself declared — an entry that inherits a separator from another contributor was never checked against it. A value edged by the separator it inherits therefore fails the composition closed (exit 65) even though its own parse-time check passed.

cmd.exe cannot export a list entry

ocx env --shell=cmd and ocx package env --shell=cmd skip every list-typed entry with a # ocx: note on stderr, naming the key. cmd.exe's only string-replacement primitive, %VAR:search=replace%, matches case-insensitively with no case-sensitive form — and list elements are opaque option strings where -DFOO=1 and -Dfoo=1 are different options, so a case-blind removal would delete the wrong one. Every other type (path, constant) still exports normally under cmd. This is a text-export limitation only: the in-process environment ocx exec and ocx package exec build for a child process on Windows is unaffected — only a captured --shell=cmd script loses the list lines.

Project Environment

ocx.toml can declare its own environment on top of what packages provide: [env] for project-wide constants, [group.<name>.env] for group-scoped ones, and the --env flag for a one-off override.

Before this stage existed, the only channel was the ambient shell (FOO=bar ocx exec -- …). That fails outright on Windows — neither PowerShell nor cmd.exe has a per-invocation variable prefix, both mutate session state that persists after the command — and it fails for any caller that builds an argv array rather than a shell command line, which is the shape a GitHub Action, a Bazel rule, or a Python subprocess call all use.

Project and group [env] entries materialize as ordinary env entries and are appended to the same vector Composition order already produces — the same uniform channel every consumer (ocx exec, ocx env, ocx direnv export, the --ci=github/--ci=gitlab writers) reads.

Precedence

StageSourceNotes
1 (lowest)Ambient inherited envMinus the shell reconciler's own contribution (above); skipped entirely under --clean
2Package-composed envComposition order above — group-selection order, then alphabetical by binding name
3Patch-companion overlay[patches] — unaffected by this feature
4Project [env]Constants replace; path entries prepend; list entries append
5Group [group.<name>.env]In -g selection order — a group listed later wins
6 (highest)--env KEY[:TYPE[:SEP]]=VALUERepeatable; constant (default) replaces, path prepends, list appends; a relative path value anchors to the current directory, not the project root stages 4-5 use

A stage-4, 5, or 6 path entry therefore lands ahead of a stage-2 package path entry for the same key — it is applied later, and every path application is idempotent with move-to-front semantics. A stage-4, 5, or 6 list entry lands the opposite way: behind a stage-2 package list entry for the same key, in the same append-zone — see List Variables above. Stage 6's path resolution differs from stages 4 and 5 in one respect: a relative value anchors to the directory ocx was invoked from, not the project root — see --env for why. A project constant that shadows a package-declared constant of the same key logs at debug, never warn: overriding a package default is the declared purpose of stages 4–6, not a collision to flag.

Where --env lives

--env is a per-invocation override, not project configuration, so it is available on every command that composes an environment — on both tiers:

TierCommandsAlso available
Project toolchainocx exec, ocx env, ocx direnv export-g/--group selects which groups' [env] composes
Package (OCI)ocx package exec, ocx package env, ocx package test, ocx patch test--self selects the visibility surface

The package tier still reads no ocx.toml — see the boundary note above. --env there composes only what the caller typed on that invocation; stages 4 and 5 do not exist, because there is no project file to declare them.

Export what you would execute

ocx exec never prints — it replaces itself with the child process — so the only way to see a composed environment is to ask the command that emits one. ocx env --env X composes stages 1–6 exactly as ocx exec --env X does, so the export and the execution agree by construction. The same pairing holds on the package tier between ocx package env and ocx package exec.

--clean is not the hermeticity boundary

--clean controls only stage 1 — what the child process inherits from the parent shell. It is not what makes the package-composed set (stage 2) reproducible. That comes from the resolver's scope: package env values are computed from ocx.lock and the resolved digests alone, identically with or without --clean. Project [env] (stage 4) is the opposite case by design — it is the user's own file, deliberately allowed to read ambient state, and is excluded from the lock's declaration_hash for exactly that reason.

--self is package vocabulary and does not exist on the project tier. It selects a package's own private surface — which by construction leaves that package's entrypoints/ off PATH, because launchers exist for a consumer to invoke the package while the package's own runtime calls bin/ directly. A project toolchain is a consumer of every tool it declares, so the self view would compose a strictly worse toolchain, not a fuller one. The flag lives on ocx package exec and ocx package env, where a package's own surface is the thing being asked about; see Visibility surfaces above.

Project and group [env] entries have no visibility axis at all — a project is never a dependency of anything, so there is no interface/private edge to gate.