SFEP-0046
Native Toolchain Version Pinning + Dispatch
- Status
- Accepted
- Type
- tooling
- Created
- Updated
- Author
- agent:compiler-architect; human review: Michael Curtis
- Tracking
- SFN-167, SFN-168, SFN-169, SFN-170, SFN-171, SFN-172
SFEP-0046 — Native Toolchain Version Pinning + Dispatch
1. Summary
A Sailfin project has no native way to declare which sfn toolchain builds it.
This SFEP adds a [toolchain] section to capsule.toml and workspace.toml
where a project pins the required sfn version, and teaches sfn build/run/check/test to (Phase 1) read and verify the running toolchain
against that pin, and (Phase 2) fetch and dispatch to the pinned toolchain
when it is not the one on PATH — so a fresh clone plus sfn build “just
works.” This is the downstream-consumer analogue of Go’s go 1.22 directive +
Go 1.21 toolchain auto-download, rust-toolchain.toml, and
package.json/corepack. It deliberately keeps the compiler repo’s own
.seed-version bootstrap plumbing out of user projects: [toolchain] is the
user-facing surface; .seed-version stays an internal self-hosting seed pin.
2. Motivation
The gap
Today the only version-pinning mechanism in the repository is
.seed-version (currently 0.8.0-alpha.2, a bare semver line), consumed by
make fetch-seed / install.sh / the Makefile. That file exists to pin the
seed binary that self-hosts the compiler — it is bootstrap plumbing for
this repo. A downstream product capsule (the first real one being
SailfinIO/sfn-gateway) has nothing: it can sfn init and sfn build, but the
manifest cannot say “this project must be built with sfn 0.8.0-alpha.2.” A
teammate or CI runner with a different sfn on PATH silently builds against
the wrong compiler, and the failure — if any — surfaces as a confusing type or
codegen error rather than “your toolchain is too old.”
Concretely, I verified that a [toolchain] stanza is silently ignored
today. _parse_toml_internal (compiler/src/toml_parser.sfn:163) recognizes
only the sections capsule, build, workspace, exports, dependencies,
dev-dependencies, and capabilities (the if strings_equal(section, ...)
ladder at lines 200–246). An unknown section header sets the section local to
"toolchain" and every subsequent key falls through the ladder unmatched. So
sfn check/sfn build succeed with [toolchain] sfn = "0.8.0-alpha.2" present
— it parses as TOML but is read by nothing. There is no field for it on the
SailToml struct (toml_parser.sfn:7).
Prior art (why every serious toolchain solves this)
- Go —
go.modcarries ago 1.22directive. Since Go 1.21 atoolchain go1.21.4line (and the baregoline as a floor) makes thegocommand auto-download and re-exec the named toolchain when the local one is older, fromhttps://proxy.golang.orgwith checksum verification viago.sum/the checksum DB. A fresh machine with any Go ≥1.21 builds a Go 1.22 project correctly with zero manual steps. This is the model Phase 2 mirrors most closely. - Rust —
rust-toolchain.toml([toolchain] channel = "1.78.0") is read by therustupproxy shims;cargo/rustconPATHare shims that dispatch to the channel the project pins, installing it on demand. - Node —
package.jsonengines.nodeis a soft check (npm warns/errors);packageManager: "[email protected]"+ corepack is the hard version: corepack shimspnpm/yarnand fetches the exact pinned version, verifying an integrity hash.
The common shape: a declaration in the manifest, a verification step in the everyday build command, and an auto-fetch + dispatch so the pin is self-fulfilling. Sailfin should match this boring, well-understood convention rather than invent a novel mechanism.
Why not just document make
make/install.sh are the compiler-repo bootstrap. A downstream capsule has no
Makefile and must never inherit the seed ritual — that plumbing knows about
.seed-version, the seed cache under build/toolchains/seed, and <seed> build -p compiler, none of which are meaningful to a product. The user story is sfn init → edit code → sfn build, with the toolchain pinned in the same manifest
the user already edits.
3. Design
Two phases. Phase 1 (declare + verify) is shippable on its own and is the
gate sfn-gateway waits on. Phase 2 (fetch + dispatch) layers on top without
changing the Phase 1 schema.
3.1 Manifest schema — the [toolchain] section
# capsule.toml (or workspace.toml)[toolchain]sfn = "0.8.0-alpha.2" # required: the pinned toolchain versionchannel = "alpha" # optional: prerelease channel gate (see §3.3)Decisions and rationale:
- Key name is
sfn, mirroring Go’sgo 1.22(the tool’s own name is the key). One key, one string. This reads naturally: “[toolchain] sfn = "0.8.0-alpha.2"”. - Value is a floor semver (DECIDED: Go-style floor, not exact-pin, not an
NPM-style range). A bare
"0.8.0-alpha.2"is a minimum floor in the spirit of Go’sgodirective: the running toolchain must be>=the pin (see §3.3 for how prereleases order). This matches user intent (“built with at least this”) and lets a project keep building under newer patch/alpha toolchains without a manifest edit, and it avoids shipping a full range-grammar parser pre-1.0. Exact-pin (rust-toolchain.toml-style “must equal”) was rejected as the default because it forces a manifest bump on every toolchain upgrade; the schema is identical either way, so a future exact/range mode is a one-predicate addition in §3.3 without a schema change. channelis optional and advisory in Phase 1. When present it names the minimum acceptable prerelease channel (stable>rc>beta>alpha). Its primary job is to make prerelease comparison legible and to let a project say “I require a stable toolchain” (channel = "stable") without naming a version it can’t yet know.- No
[toolchain] dependencies/component list (rustup’scomponents) — out of scope; Sailfin has one binary, not a multi-component distribution.
Parsing lands as an additive section == "toolchain" arm in
_parse_toml_internal plus two fields on SailToml
(toolchain_sfn: string, toolchain_channel: string) and a string-based
accessor toml_get_toolchain_sfn(text) -> string / toml_get_toolchain_channel
alongside the existing toml_get_* API (toml_parser.sfn:252+). Because the
old parser already ignores unknown sections, an old sfn reading a manifest
that pins a newer sfn simply ignores the pin — the additive-parse property
that keeps self-hosting safe (see §5).
3.2 Version comparison — the missing primitive
The repository has no semver comparison today. version.sfn only extracts
a version string (resolve_compiler_version, toml_get_version); nothing
compares two versions or orders prereleases. Both phases need this, so Phase 1
introduces a small, well-tested semver module.
Proposed: compiler/src/semver.sfn exporting:
struct SemVer { major: int; minor: int; patch: int; prerelease: string; // "" for a release; e.g. "alpha.2" // build metadata after '+' is parsed and ignored for ordering (semver §10)}
fn semver_parse(text: string) -> SemVer // tolerant of a leading 'v'fn semver_compare(a: SemVer, b: SemVer) -> int // -1 / 0 / +1, semver §11 orderfn semver_satisfies_floor(running: SemVer, pin: SemVer) -> booleanOrdering follows semver §11 precedence: numeric fields compared numerically; a
release outranks any prerelease of the same core (1.0.0 > 1.0.0-alpha);
prerelease identifiers compared left-to-right, numeric identifiers numerically,
alphanumeric lexically. This is the one piece of genuinely new logic; everything
else is manifest plumbing and CLI orchestration.
3.3 Verification behavior (Phase 1)
Where it hooks in: sfn build, sfn run, sfn check, and sfn test all
resolve a project root via discover_project_root / discover_workspace_root
(capsule_resolver.sfn:90+). Immediately after the manifest is read for a
project build, a new toolchain_gate step runs:
- Read
[toolchain] sfnfrom the project manifest (and, if a workspace root is found, theworkspace.toml[toolchain]— the workspace pin is the default; a membercapsule.toml[toolchain]overrides it for that member). If absent → no-op, current behavior preserved (unpinned projects keep working; this must be true so the feature is additive for the whole existing ecosystem). - Resolve the running toolchain version via the existing
resolve_compiler_version(binary_dir)(version.sfn:130). - Compare with
semver_satisfies_floor(running, pin). - On satisfaction → proceed silently. On mismatch → Phase 1 behavior below.
Mismatch behavior (Phase 1, no auto-fetch yet):
-
Default: hard error with a clear, actionable diagnostic and a non-zero exit. Rationale — a silent warn (Node
enginesdefault) trains users to ignore it, violating “don’t ship unfinished safety claims.” Precedent: Go errors when the local toolchain is older than thegodirective and auto-download is off (GOTOOLCHAIN=local). -
Message shape (an ordinary CLI diagnostic, not an
E0xxxtypecheck code — this is a driver gate, not source analysis):error: toolchain mismatchthis project pins sfn >= 0.8.0-alpha.2 (capsule.toml [toolchain])but the running toolchain is 0.7.4-alpha.1install the pinned toolchain, or re-run with --skip-toolchain-check to override -
Override flag
--skip-toolchain-check(and envSAILFIN_SKIP_TOOLCHAIN_CHECK=1for CI) downgrades the hard error to a one-line warning and proceeds. This is the escape hatch for “I know what I’m doing” and for the compiler’s own repo during a transition. -
Prerelease direction: because alpha < the corresponding release, a project pinning
0.8.0-alpha.2is satisfied by0.8.0-alpha.2,0.8.0-alpha.3,0.8.0,0.9.0, etc., and rejected by0.8.0-alpha.1or0.7.x. Ifchannel = "stable"is set, any-alpha/-beta/-rcrunning toolchain is rejected regardless of core version.
3.4 sfn init scaffolding
toml_generate (toml_parser.sfn:537) and sfn init
(cli/commands/init.sfn:66) gain a [toolchain] stanza in the generated
manifest, pinned to the version of the sfn doing the scaffolding
(resolve_compiler_version is already available in the driver). So a project
created with sfn 0.8.0-alpha.2 is born pinning it — matching cargo new
stamping the edition and Go’s go mod init writing the go line. init is
![io], which is enough to read the running version; no new effect.
3.5 Fetch + dispatch (Phase 2)
When verification (§3.3) fails and auto-fetch is enabled, instead of
erroring, sfn fetches the pinned toolchain and re-execs it, transparently.
This is the Go 1.21 / corepack behavior.
Store layout — reuse, don’t reinvent. install.sh already installs versioned
toolchains to ~/.local/share/sailfin/versions/<version>/ (INSTALL_BASE,
install.sh:240) with the alias symlink sfn in the bin dir. Phase 2 fetches
into the same store so a sfn-fetched toolchain and an install.sh-installed
one are interchangeable:
~/.local/share/sailfin/versions/ 0.8.0-alpha.2/ sailfin # the binary runtime/ # bundled runtime (install.sh already ships this) .sha256 # recorded digest of the downloaded tarball(Configuration and the capsule cache stay under ~/.sfn/ — config.toml,
cache/capsules/ per cmd_shared.sfn:101. The toolchain store stays under the
existing ~/.local/share/sailfin/versions to converge with install.sh.
SAILFIN_HOME/INSTALL_BASE overrides honored.)
Fetch is native .sfn — no shelling out to bash. (DECIDED: native
Sailfin, not a shell-out to install.sh.) The fetch/verify/extract path is a
new sfn toolchain install command implemented in Sailfin with ![io, net],
matching the pure-Sailfin-toolchain 1.0 goal — the toolchain must not depend on a
bash installer to bootstrap itself. It reuses only the release-asset layout
install.sh established (asset name sailfin_<version>_<os>_<arch>.tar.gz at
https://github.com/SailfinIO/sailfin/releases/download/<tag>/<asset>, OS in
{linux, macos, windows}, arch in {x86_64, arm64}), not the script itself. The
native command owns asset resolution, prerelease selection, download, signature +
digest verification (below), extraction, and the version-store symlink. install.sh
remains only as a one-shot initial installer for a machine with no sfn at all
(the chicken-and-egg bootstrap); once any sfn is present, all further toolchain
acquisition is native. Accepting native fetch means the compiler’s own
[capabilities] required gains net — see §4; this is the deliberate,
production-grade choice, not a side effect.
Dispatch. After ensuring ~/.local/share/sailfin/versions/<pin>/sailfin
exists, the current process re-execs it with the original argv (a
process.exec-style replacement, not a subprocess, so exit codes and signals
pass through — Go re-execs; corepack spawns). A re-entrancy guard
environment variable (SAILFIN_TOOLCHAIN_DISPATCHED=<pin>) is set before re-exec
so the dispatched toolchain, which will also read [toolchain] and satisfy the
floor, does not attempt to dispatch again (and to hard-fail loudly if a fetched
toolchain still doesn’t satisfy the pin — a corrupt store or a bad release).
Trust model (production-grade, mandatory verification). A downloaded toolchain
is executable code that sfn is about to re-exec, so verification is not optional
and not best-effort — it is a fail-closed gate modelled on what a production
toolchain does (Go’s checksum database + signed releases; corepack’s integrity
hashes; rustup’s signed manifests):
- Signed release-digest manifest. (DECIDED: build the signing root; it is a
hard prerequisite of Phase 2 shipping.) Each release publishes a
SHA256SUMSmanifest of its assets plus a detached signature over that manifest (a minisign/cosign-class Ed25519 signature). The signing public key is embedded in thesfnbinary (pinned at build time), so verification needs no network trust-on-first-use. This is the supply-chain root; TLS + GitHub integrity alone is explicitly not sufficient to execute downloaded code. - Verification order (fail closed at each step). Download the asset →
download
SHA256SUMS+ its signature → verify the signature against the embedded public key → confirm the asset’s SHA-256 is the one the manifest lists → only then extract, record.sha256in the store, and mark the version usable. Any failure aborts before the toolchain is extracted or executed, with a clear error; a corrupt or unsigned asset is never re-exec’d. - Reuse re-verification. On every dispatch to an already-stored toolchain,
the recorded
.sha256is re-checked against the on-disk binary before re-exec, so a tampered store is caught too. - Auto-dispatch defaults ON (
SAILFIN_TOOLCHAIN=auto), matching Go’s default — a fresh clone +sfn buildtransparently fetches, verifies, and dispatches the pinned toolchain. This is safe to default-on because verification is mandatory and fail-closed: we never silently execute unverified code, so the production-grade behavior is the transparent one.local/offremain available for air-gapped or CI-preinstalled environments (see the knob below).
Offline behavior. If fetch is needed but the network is unavailable (or the
knob is local), fall back to the Phase 1 hard error naming the exact
sfn toolchain install <version> command the user can run when back online. An
already-fetched toolchain in the store is used offline with digest
re-verification — no network needed once the pin is present locally.
GOTOOLCHAIN-style control knob. A single env/config lever governs the whole
behavior, matching Go’s GOTOOLCHAIN:
SAILFIN_TOOLCHAIN |
Behavior |
|---|---|
auto (default in Phase 2) |
verify the pin; on mismatch fetch + verify + dispatch |
local (default in Phase 1) |
verify only; hard error on mismatch, never fetch |
<version> |
force dispatch to that exact version regardless of the pin |
off |
skip the gate entirely (equivalent to --skip-toolchain-check) |
Signature/digest verification is mandatory whenever a toolchain is fetched or
dispatched and is not disabled by any knob value except off (which skips the
gate wholesale for explicit air-gapped/CI use).
4. Effect & capability impact
No language-semantics or effect-system change — no new effect keyword, no new effect-checker rule. The only capability-surface change is on the compiler’s own manifest, and it is a deliberate, accepted decision (native fetch):
- Phase 1 (parse
[toolchain], compare versions, gate the build) is pure driver orchestration. Reading the manifest and the running version is already;semver.sfnparsing/comparison is![pure]. - Phase 2’s native
sfn toolchain installfetch path is![io, net]— the only new effect surface, living in a toolchain-management command, not in the language. Because the fetch is native (not a shell-out), the compiler’s own[capabilities] requiredgainsnet(from["io", "clock"],compiler/capsule.toml). This is DECIDED and accepted: a self-fetching toolchain legitimately needs the network capability, and declaring it honestly is exactly the capability model working as intended (the compiler’s own manifest states the authority it actually uses). Verification (§3.5) uses only in-process crypto over already-downloaded bytes, so it adds no further effect beyond thenetfetch itself.
5. Self-hosting impact
No compiler pass changes — this touches only the manifest parser
(toml_parser.sfn), the driver/CLI (capsule_resolver.sfn project-root path,
cli/commands/*, a new semver.sfn), and scaffolding (init.sfn). Lexer,
parser, AST, typecheck, effect-checker, native emitter, and LLVM lowering are
untouched.
The additive-parse property is the self-hosting safety net:
- The
[toolchain]section is additively parsed. An old seed compiling the new compiler never sees new syntax —[toolchain]is data in a.tomlfile, not.sfnsource. The new.sfncode that reads the section (the newSailTomlfields, thetoolchain_gatecall,semver.sfn) is ordinary Sailfin the old seed already compiles. Somake compilebuilds the new compiler from the pinned0.8.0-alpha.2seed with no seed cut — this is a bundled capability+consumer change in one PR (.claude/rules/seed-dependency.mddefault), not a seed-blocker. - The compiler repo dogfoods
[toolchain]. (DECIDED: yes — the compiler adopts its own mechanism.).seed-versionand[toolchain]coexist for distinct jobs:.seed-versionstays as the seed pin formake compileself-hosting — it names the seed binary that bootstraps the compiler, a repo-internal concept nothing downstream should learn.- The compiler’s
capsule.tomlalso gains a[toolchain] sfnstanza and the gate runs on the compiler’s own build, so the toolchain eats its own dog food: the same verification a product gets. Floor semantics make this well-behaved — the seed’s version is the pin, somake compilefrom the pinned seed satisfies the floor; a newer seed also satisfies it. The only hazard is the reverse (an older seed against a source that raised its[toolchain]pin), which is exactly the case the gate should catch. The seed bump does both edits together:/pin-seedadvances.seed-versionand the[toolchain]pin in the same change, and--skip-toolchain-check/SAILFIN_TOOLCHAIN=offis wired intomake compileas the escape hatch for a mid-transition self-build. This keeps the mechanisms honestly aligned rather than exempting the compiler from the rule it ships.
6. Alternatives considered
- Keep
.seed-versionand document amake-style ritual for users. Rejected:.seed-version+makeis repo-internal bootstrap plumbing; a product capsule has noMakefileand must not inherit the seed cache /<seed> build -p compilermachinery. Leaking it into user projects couples every consumer to the compiler’s self-hosting internals. - An environment variable only (
SAILFIN_TOOLCHAIN=0.8.0-alpha.2). Rejected as the primary mechanism: env vars aren’t checked into the repo, so they don’t travel with the project or pin CI reproducibly. Retained as an override knob (SAILFIN_TOOLCHAIN, §3.5) exactly as Go keepsGOTOOLCHAINalongside thego.moddirective. - A separate
sfn-toolchain.toml(à larust-toolchain.toml). Rejected: Sailfin already has a single project manifest the user edits (capsule.toml); splitting the toolchain pin into a second file adds a file to discover and keep in sync for no gain. Go puts the directive ingo.mod, not a side file; we follow that. (Rust’s separate file is a historical artifact ofrustuppredating a manifest field.)workspace.toml[toolchain]covers the multi-capsule case. - A
sfnupexternal version manager (rustup/nvm-style shim). Rejected as the baseline because it requires users to install a second tool beforesfnworks, and shims add a layer of indirection and PATH fragility. The Go 1.21 model — the tool dispatches itself — is strictly better UX for the common case and is what Phase 2 implements. Asfnup-style manager could still be built later on top of the same store layout for users who want explicit multi-version management; nothing here precludes it. - NPM-style range expressions (
^0.8,>=0.8, <0.9). Rejected for the initial cut: a range grammar is more parser surface and more comparison logic than a single floor pin needs pre-1.0, and Go’s single-directive model has proven sufficient.semver.sfnis structured so a range predicate can be added later without changing the schema. - Shell out to
install.shfor the Phase 2 fetch. Rejected: it would keep bash on the toolchain’s own bootstrap path, against the pure-Sailfin-toolchain 1.0 goal, and it splits the trust/verification code across a script the compiler can’t statically reason about. Phase 2 fetches natively (sfn toolchain install,![io, net]) and reuses only the release-asset layoutinstall.shestablished (§3.5).install.shsurvives solely as the one-shot installer for a machine with nosfnyet.
7. Stage1 readiness mapping
This is a tooling/driver SFEP: it adds no language syntax, AST node, or IR. The codegen rows are therefore N/A by construction and are marked so honestly.
- Parses — N/A for
.sfngrammar; the change is TOML-section parsing intoml_parser.sfn(a newsection == "toolchain"arm +SailTomlfields). - Type-checks / effect-checks — N/A; no new effect rule. The new
.sfncode (semver.sfn, the gate,initscaffolding) type/effect-checks as ordinary driver code (sfn check). - Emits valid
.sfn-asm— N/A; no new AST/IR node. - Lowers to LLVM IR — N/A; no new lowering.
- Regression coverage — see §8 (unit tests for
semver.sfn, integration for the gate, e2e for init scaffolding and dispatch). - Self-hosts — required:
make compilemust build the new compiler from the 0.8.0-alpha.2 seed (additive, no seed cut — §5). -
sfn fmt --checkclean — required on every touched.sfnfile. - Documented in
docs/status.md+ a tooling reference chapter (the[toolchain]manifest section and theSAILFIN_TOOLCHAINknob).
Phase 1 clears this list for the verify feature; Phase 2 re-clears it for
fetch/dispatch (with the added ![io]/![net] note from §4).
8. Test plan
Unit (compiler/tests/unit/):
semver_parse_test.sfn— parses1.2.3,v1.2.3,0.8.0-alpha.2,1.0.0-alpha.10, build metadata (1.0.0+abc), and malformed input.semver_compare_test.sfn— the §11 ordering matrix: numeric fields, release > prerelease of same core,alpha.2 < alpha.10(numeric identifier), prerelease vs. prerelease left-to-right.toml_toolchain_parse_test.sfn—toml_get_toolchain_sfn/_channelextract the pin; a manifest with no[toolchain]returns empty (unpinned no-op); an old-parser regression asserting an unknown section is still ignored.
Integration (compiler/tests/integration/):
toolchain_gate_test.sfn— with a fixture manifest pinning a version equal to / below / above the running toolchain, assert satisfy / satisfy / hard-error respectively; assert--skip-toolchain-checkdowngrades the error to a warning and proceeds; assertchannel = "stable"rejects an-alpharunning toolchain.
E2E (compiler/tests/e2e/, *_test.sfn per .claude/rules/no-bash-e2e.md):
init_toolchain_stanza_test.sfn—sfn initin a temp dir writes a[toolchain] sfn = "<running version>"stanza (drive the subprocess withprocess.run_capture, read the generatedcapsule.toml, assertfindlocates the pin).toolchain_gate_e2e_test.sfn— build a fixture capsule pinning a version above the running one; assertsfn buildexits non-zero with the “toolchain mismatch” message; assertSAILFIN_SKIP_TOOLCHAIN_CHECK=1(threaded via the child env) makes it succeed.- Phase 2
toolchain_dispatch_test.sfn— with a stub toolchain pre-seeded into a temp store (no real network), assert re-exec dispatch selects it and the re-entrancy guard prevents a loop; assert digest mismatch is refused; assert offline-with-store-present succeeds and offline-without-store gives the actionable install hint. (Real GitHub fetch is exercised behind a network guard that skips when offline, mirroring the tool-probe skip pattern in the e2e rules.)
9. References
docs/proposals/0002-package-management.md— capsule/model management,sfn add+capsule.lock, thehttps://pkg.sfn.devregistry,~/.sfn/cache layout.[toolchain]is the toolchain analogue of that capsule-pinning story; the store reuses the~/.sfn/config +~/.local/share/sailfin/versionsinstall layout.docs/proposals/0003-tooling.md— the built-in tooling surface (sfn init/build/run/check/test) this SFEP extends with the gate.install.sh— the one-shot bootstrap installer whose release-asset layout (asset namingsailfin_<version>_<os>_<arch>.tar.gz, GitHub-release fetch, INSTALL_BASE~/.local/share/sailfin/versions, prerelease selection) the nativesfn toolchain installreuses. Phase 2 does not shell out to it (§3.5, §6).compiler/src/toml_parser.sfn(_parse_toml_internal:163,SailToml:7,toml_generate:537) — the section-parse ladder that silently ignores unknown sections today and where[toolchain]parsing lands.compiler/src/version.sfn(resolve_compiler_version:130,__version_fallback__:15) — running-toolchain version resolution the gate compares against.compiler/src/capsule_resolver.sfn(discover_project_root,discover_workspace_root:90+) — where the gate hooks into the project-build path.compiler/src/cli/commands/init.sfn— scaffolding that will stamp the[toolchain]stanza..seed-version(0.8.0-alpha.2) — the internal seed pin that stays bootstrap-only and is not superseded for the compiler repo.- Prior art: Go
go/toolchaindirectives +GOTOOLCHAIN(auto-download since Go 1.21);rust-toolchain.toml+rustupproxy shims;package.jsonengines/packageManager+ corepack. - First consumer:
SailfinIO/sfn-gateway— the first downstream product capsule. It will carry a forward-looking[toolchain]stanza now (harmlessly ignored by the current parser, per §2/§5) and becomes the acceptance driver for Phase 1: once the gate ships, a freshsfn-gatewayclone verifies (Phase 1) and eventually auto-dispatches (Phase 2) its pinned toolchain with no manual step.