SFEP-0076
User-Level Build Defaults
- Status
- Accepted
- Type
- tooling
- Created
- Updated
- Author
- agent:Sailbot; owner-directed investigation; project owner acceptance
- Tracking
- SFN-1257, SFN-1256, SFN-1258, SFN-1259, SFN-1260, SFN-1261, SFN-1262
SFEP-0076 — User-Level Build Defaults
1. Summary
~/.sfn/config.toml is Sailfin’s per-user configuration file, and it accepts
exactly two keys. Meanwhile the toolchain reads roughly forty environment
variables, none of which can be persisted anywhere. A user whose machine needs a
specific linker, C compiler, or job count has one option: a shell export, which
sfn config list cannot see, sfn cannot validate, and no non-login process
inherits.
This SFEP admits a bounded set of machine-scoped settings into the file that already exists, under one stated precedence order, with a single admission rule governing what may ever be added. It does not propose a new file, a new directory, or a general settings framework.
2. Motivation
2.1 The file exists and is nearly empty
sfn config recognises two keys (cli/commands/config.sfn:139-148):
[registry]url = "https://pkg.sfn.dev"
[toolchain]update-policy = "notify"Everything else the toolchain can be told is environment-only. The sharpest cases are facts about the workstation, which is precisely what a user-level config is for:
| Variable | Read at | Why it wants persisting |
|---|---|---|
SAILFIN_LINKER |
build/link.sfn:77, build/direct_link.sfn:156 |
one linker per machine, not per invocation |
SAILFIN_CC |
backend.sfn:145 |
same |
SAILFIN_BUILD_JOBS |
capsule_emit_parallel.sfn:48 |
core count and RAM are machine properties |
SAILFIN_BUILD_CACHE_DIR |
build_cache.sfn:812 |
which disk is fast is a machine property |
SAILFIN_TARGET_TRIPLE |
build/target.sfn:118, build.sfn:189 |
a cross-compiling user retypes it every time |
None of these describe the program being built. They describe the machine building it, and they do not change between projects or between invocations. A shell export is the wrong lifetime for a fact that stable.
2.2 The discoverability failure is real, not hypothetical
Observed 2026-09-03. A user with sixty installed toolchains under
~/.local/share/sailfin/versions/ and a populated ~/.sfn/cache/ concluded
that Sailfin had no user configuration at all. The reasoning was sound given
what is visible: ~/.sfn contained cache/ and credentials, and nothing
else, because config.toml is created lazily on first sfn config set and they
had never run one. There is no sfn config init, no commented default file, and
no documentation page that says “this is where your settings would live.”
A configuration surface that is invisible until written to is, for discoverability purposes, absent. SFN-1256 addresses the documentation half; this SFEP addresses the half where there is little worth documenting yet.
2.3 There is no precedence engine
compiler/src/build_flags.sfn is not a resolver. It is four env-read primitives
(lines 17-72) that every other module builds on independently, and the resulting
orders disagree:
| Concern | Order | Site |
|---|---|---|
| Registry URL | env → user config → default | cmd_shared.sfn:116-128 |
| Target triple | CLI flag → env → host default | build.sfn:154-196 |
| Build cache dir | env → XDG_CACHE_HOME → HOME |
build_cache.sfn:812-816 |
| Toolchain version | argv → env → project → user default → entry floor | toolchain/dispatch.sfn, SFEP-0073 §3.3 |
Four orderings, no shared machinery. Only one of the four consults a user config at all, and it is the only one that has a user-config key. This is the actual reason adding a key is expensive: there is no seam to add it to, so each key costs a bespoke resolution path and a bespoke test.
The cost is not hypothetical either. SAILFIN_TARGET_TRIPLE is validated in one
place (build.sfn:154-196) and read unvalidated in another
(build/target.sfn:118); HOME is resolved by two byte-identical helpers
(build_flags.sfn:24, capsule_resolver/paths.sfn:126) that exist only to
break an import cycle. Duplication at the resolution layer is already producing
drift.
2.4 Prior art
Cargo — the tool SFEP-0073 §2 already cites as the model for rustup’s UX —
puts exactly these settings in a user-level config.toml:
[target.aarch64-apple-darwin]linker = "/opt/homebrew/opt/llvm/bin/ld64.lld"
[build]jobs = 8target-dir = "/fast/scratch/target"The shape below is deliberately close to it. Boring syntax wins, and a user
arriving from Rust should not have to learn a second spelling of a setting they
already know. This is the same reasoning CLAUDE.md applies to language syntax,
applied to configuration.
3. Design
3.1 The admission rule
This is the load-bearing part of the proposal. Without it, the file accretes keys until it is a second CLI.
A setting is admissible to
~/.sfn/config.tomlif and only if it is (a) a property of the machine or of the user’s standing preference, not of the program being built; and (b) safe to apply unchanged to every project on that machine; and (c) something a user would otherwise write into a shell profile.
Each clause excludes a real category:
- (a) excludes anything that changes what a program means. Effect
enforcement (
SAILFIN_EFFECT_ENFORCE,effect_gate.sfn:84) is the clearest case: a per-user setting that relaxes effect checking would make the same source compile on one machine and fail on another, which attacks the Reach pillar directly. Never admissible, at any priority. - (b) excludes per-project settings. A default
[build] targetfails this clause and is deliberately not proposed in §3.2 — see §6.3. - (c) excludes debug and test-harness knobs:
SAILFIN_INJECT_FAULT(emit_helpers.sfn:117),SAILFIN_DEBUG_FORCE_PANIC(ice.sfn:74),SAILFIN_TRACE_MEM_LIMIT, and theSAILFIN_TEST_*family. Nobody persists a fault injector. These stay environment-only permanently.
Of the ~40 variables surveyed, five clear all three clauses. That ratio is the point: the file stays small because the rule is strict, not because nobody has asked yet.
3.2 The admitted keys
[registry]url = "https://pkg.sfn.dev" # existing, unchanged
[toolchain]update-policy = "notify" # existing, unchanged
[build]jobs = 8 # new — see §3.4, clampedcache-dir = "/fast/scratch/sfn" # new
[target.aarch64-apple-darwin] # new — per host triplelinker = "/opt/homebrew/opt/llvm/bin/ld64.lld"cc = "/opt/homebrew/opt/llvm/bin/clang"[target.<triple>] is keyed by triple rather than flat so that one home
directory serves a machine that cross-compiles, and so a roaming profile does
not carry a Darwin linker path onto a Linux host. This mirrors the existing
host-qualification of the toolchain store (SFEP-0073 §3.5) and of
toolchain-default (user_default.sfn:26-33), which both learned the same
lesson.
SAILFIN_LINKER and SAILFIN_CC are read today as flat globals
(build/link.sfn:77, backend.sfn:145). The environment variables keep that
flat meaning — they are a one-shot override and the user knows which host they
are on. Only the config file is triple-keyed.
3.3 Precedence, stated once
For every admitted key, highest priority first:
- CLI flag, where one exists (
--target,--jobs) - Environment variable, where one exists
- Project manifest (
capsule.toml/workspace.toml), where the key is project-scoped — no admitted key is today, but the rung is reserved so the order does not change when one is - User config (
~/.sfn/config.toml) - Compiled-in default
The user config sits below the environment deliberately. SAILFIN_LINKER=... sfn build must keep working as a one-shot override for a user who has persisted
a different linker, and CI must be able to override a developer’s file without
editing it.
This order is chosen to be compatible with what already exists rather than to be
novel: it is the registry chain (cmd_shared.sfn:116-128) with rungs 1 and 3
inserted, and the toolchain-version chain (SFEP-0073 §3.3) with its argv
selector generalised to CLI flags. Neither existing chain changes behaviour
under it.
3.4 [build] jobs is clamped, not honoured
A user config that could raise the emit fan-out past the host RAM budget would
be a host-kill primitive. .claude/rules/compiler-safety.md is explicit that
the 8 GiB RLIMIT_AS self-cap bounds a process, not a fleet: N concurrent
children means N × 8 GiB with nothing enforcing the aggregate, and the failure
mode is the OS killing the host, not an sfn error (#1245).
Therefore [build] jobs is an upper bound the user may lower, never raise:
effective_jobs = min(configured_jobs, _cr_ram_budget_jobs())where _cr_ram_budget_jobs() (capsule_emit_parallel.sfn) keeps its current
2.5 GiB/job out of 66% of RAM sizing (SFN-626), unchanged. A configured value
above the budget is clamped with a warning to stderr naming both numbers; it is
not an error, because the same config file may be roamed onto a smaller host and
should not brick the build there.
SAILFIN_TEST_JOBS and the test pool’s separate _test_jobs_budget
(cli/commands/test/arg_and_jobs.sfn, 3 GiB/job, SFN-781) are out of scope.
The two fan-outs are deliberately sized against different workloads, and the
pooled-test children are pinned to SAILFIN_BUILD_JOBS=1 so the two cannot nest
(SFN-547). Admitting a user-level test job count would reopen that interaction
for no user-visible gain; test parallelism stays a CI and harness concern.
3.4.1 [build] cache-dir and the self-host pin
§3.3 states the precedence chain as env → manifest → user config → compiled-in
default, with no self-host rung, because at the time it was written no admitted
key interacted with SFEP-0040 §3.1’s compiler self-host cache pin. The
implementation (SFN-1260) shows that [build] cache-dir does, and resolves it
in a way that is a deliberate deviation from a literal reading of §3.3, not an
oversight — recorded here so a later reader does not “fix” it back into one.
(a) It sits below the SFEP-0040 §3.1 self-host pin, not directly below the
environment variable. The pin is a hermeticity invariant — make compile /
make check must never read a developer’s global store — not a preference
rung, so nothing below rung 1 may outrank it. [build] cache-dir is itself a
developer-global ambient store: exactly the hazard the pin exists to block, so
it cannot sit above it. An environment variable is different in kind, not just
in rank: it is typed next to the specific build it redirects, while a config
file value set months ago is passive and would otherwise silently pull the
self-host cache out of tree on every checkout its owner touches. The env var
therefore stays above the pin, so CI can still redirect the self-host cache
deliberately, while the config file cannot redirect it at all. The full ladder
implemented by cache_root_from (build_cache.sfn) is: $SAILFIN_BUILD_CACHE_DIR
→ self-host pin → [build] cache-dir → $XDG_CACHE_HOME/sailfin →
$HOME/.cache/sailfin → in-tree default.
(b) It resolves as <value>/<schema>, matching the environment variable
rather than the XDG rung’s extra sailfin/ segment. The XDG segment exists
because $XDG_CACHE_HOME is a directory shared across every application on the
host, so Sailfin must namespace itself under it. A directory the user names
specifically in [build] cache-dir is not shared — they typed it for Sailfin —
so adding the same segment would be redundant and, worse, would relocate the
store out from under a user who moves a value from SAILFIN_BUILD_CACHE_DIR
into the persisted file expecting no change in behavior.
(c) It deliberately does not extend to test_bin_cache_root /
runtime_obj_cache_root. Both stay in-tree (or follow
SAILFIN_BUILD_CACHE_DIR alone) regardless of [build] cache-dir — a
divergence already documented above runtime_obj_cache_root in
build_cache.sfn, predating this key and not created by it. The known UX
consequence: a user who sets cache-dir gets a partial move — the per-module
.ll/.o cache relocates, the runtime-object and per-test-binary caches do
not — and sfn cache info reports only the moved half. Closing that gap was
out of scope for SFN-1260.
3.5 Resolver seam
Each admitted key gets a resolver function in one module — proposed
compiler/src/user_config.sfn — with the shape:
// Rung 4 of the §3.3 precedence chain. Returns "" when the key is absent,// letting the caller fall through to its compiled-in default.fn user_config_linker(triple: string) -> string ![io] { ... }Callers keep their existing rung-1 and rung-2 handling and gain one fall-through call. This is deliberately not a general precedence framework: rewriting four divergent chains onto shared machinery is a larger change with its own blast radius, and it is not needed to admit five keys. §6.4 records why.
The one piece of shared machinery this does need is a config-directory
resolver: six call sites hand-rolled _get_home_cmd() + "/.sfn/..." (three of
them in config.sfn alone). SFN-1258 resolved that seam as user_config_dir()
(compiler/src/user_config.sfn) and extended SAILFIN_CONFIG_DIR to cover all
three per-user records rather than the toolchain-default record alone: the
override is total, not a search path — when set, the real ~/.sfn is not
consulted for config.toml, credentials, or toolchain-default, with no
read-through fallback. toolchain_user_default_dir()
(compiler/src/toolchain/user_default.sfn) now delegates to user_config_dir()
instead of re-deriving the path, so there is one resolver for the directory.
3.6 CLI surface
sfn config extends with no new subcommands:
sfn config set build.jobs 8sfn config set target.aarch64-apple-darwin.linker /opt/homebrew/opt/llvm/bin/ld64.lldsfn config get build.jobssfn config list # prints every resolved key, including defaultssfn config unset build.jobsDotted key paths map to TOML section + key. set validates before writing —
jobs must be a positive integer, cache-dir and the tool paths must be
absolute, linker/cc must exist and be executable at set time (a warning, not
an error: the path may be valid on another host sharing the file).
Each set/unset rewrites only its own section, matching the existing
guarantee that setting registry never clobbers toolchain.update-policy
(config.sfn:99-116).
sfn config list gains a provenance column, because a settings file whose
values you cannot trace is worse than no file:
build.jobs 8 ~/.sfn/config.tomlbuild.cache-dir /fast/scratch/sfn ~/.sfn/config.tomltarget.aarch64-apple-darwin.linker ld64.lld SAILFIN_LINKER (env)registry.url https://pkg.sfn.dev default4. Effect & capability impact
No new effects and no change to the effect system. Reading the config file is
![io], which every affected call site already declares —
_get_home_cmd() (build_flags.sfn:24) and the existing config readers
(config.sfn:66-84, update_policy.sfn:74-85) are already ![io].
One interaction is load-bearing and stated as a constraint rather than a
consequence: no admitted key may affect effect checking, capability
enforcement, or manifest derivation. §3.1 clause (a) forbids it by
construction, and SAILFIN_EFFECT_ENFORCE (effect_gate.sfn:84) is named there
as the motivating exclusion. A user-level setting that could relax the effect
gate would mean a capability manifest was derived under machine-local
configuration, which makes the manifest unattestable by the stranger the Reach
pillar promises. This is not a tradeoff to be revisited per key; it is the
boundary of the file.
The same reasoning excludes SAILFIN_CAPSULE_FILTER
(capsule_resolver/reachability.sfn:274) and SAILFIN_RUNTIME_SOURCE_GATES
(build/runtime_selection.sfn:74) permanently.
5. Self-hosting impact
No compiler pass changes. Nothing in the lexer, parser, AST, typechecker, effect
checker, .sfn-asm emitter, or LLVM lowering is touched — this is CLI and build
driver only (compiler/src/cli/commands/config.sfn, a new
compiler/src/user_config.sfn, and fall-through calls at
build/link.sfn:77, backend.sfn:145, build_cache.sfn:812,
capsule_emit_parallel.sfn:48).
The self-hosting invariant is preserved by omission: sfn dev bootstrap build
runs with no ~/.sfn/config.toml on CI runners, so every rung-4 lookup returns
absent and every caller falls through to the behaviour it has today. That is the
correctness argument and also the regression risk — CI cannot observe a
config-file bug, because CI never has the file. §8 requires tests that
materialise one in a scratch SAILFIN_CONFIG_DIR rather than relying on the
ambient home.
A second-order requirement, per .claude/rules/seed-dependency.md: the
implementation is compiler-source, and its consumers are compiler-source, so it
bundles into a single PR and needs no seed cut. It does not touch runtime
source, so the runtime/-consumer carve-out does not apply.
6. Alternatives considered
6.1 Do nothing; record it as a Non-Goal
The status quo is defensible. Cargo shipped 1.0 without user-level defaults, and shell exports are a working answer for the handful of users who cross-compile. The honest version of “do nothing” is to add a Non-Goals section to SFEP-0073 saying so, since it has none today and the absence currently reads as an oversight rather than a decision.
Rejected because clause (c) cases are real: SAILFIN_LINKER and SAILFIN_CC
are needed by every user on a machine whose default linker is wrong, which
includes any macOS user preferring Homebrew LLVM to the Xcode default. Telling
them to edit a shell profile means the setting is invisible to sfn config list, unvalidated, and absent from any non-login shell — including the ones
CI, editors, and agents spawn.
Kept as the fallback: if this SFEP is rejected, the Non-Goals section is the required consolation, and SFN-1257’s acceptance criteria say so.
6.2 A new file (~/.sfn/settings.toml, or XDG ~/.config/sailfin/)
Rejected. ~/.sfn/config.toml is already documented, already written by sfn config, already has a precedence chain, and already holds a
non-registry key (toolchain.update-policy). A second file would mean two
formats, two precedence stories, and a migration for no gain.
The XDG variant is more tempting — ~/.sfn is not XDG-conformant, and the
toolchain store already lives at ~/.local/share/sailfin/. But relocating a
documented path is a breaking change to a shipped contract, and it is
orthogonal to admitting keys. If Sailfin ever moves to XDG, it should move all
three files at once as its own proposal; SFN-1258 is where that conversation
starts.
6.3 Include [build] target (a default target triple)
Rejected on clause (b). A persisted default target applies to every project on
the machine, so a user who sets it once to cross-compile one project silently
cross-compiles all of them — including, on a compiler checkout, sfn dev bootstrap build. The failure is quiet and the diagnostic points at the wrong
layer.
SAILFIN_TARGET_TRIPLE remains available for the same job with a lifetime that
matches its blast radius. If a persisted target is wanted later, its home is the
project manifest (rung 3), not the user config.
6.4 Build the general precedence engine first
Tempting, and §2.3 is a real problem: four divergent chains is drift waiting to happen. Rejected as the first move on sequencing grounds — unifying four resolution paths touches the registry chain, the target chain, the cache chain, and toolchain dispatch, the last of which is SFEP-0073 machinery that is mid-delivery at milestone 4 of 5. Landing a refactor across it while it is still being built is how you get a self-host break that nobody can bisect.
§3.5’s per-key resolver is the smaller move that does not foreclose the larger one: five functions in one module are a seam a future engine can absorb. The engine should be its own SFEP once SFEP-0073 completes, and §2.3’s table is its motivation section, pre-written.
6.5 Let [build] jobs raise the fan-out as well as lower it
Rejected outright; see §3.4. The user asking for more parallelism cannot observe the aggregate memory ceiling, the self-cap does not enforce it, and the failure mode is a dead host (#1245). A setting whose worst case is losing the machine does not get a “trust the user” default.
7. Stage1 readiness mapping
This is a tooling SFEP: it adds no language construct, so the syntax and codegen rungs are not applicable and are marked as such rather than left to imply pending work.
- Parses — n/a, no new syntax
- Type-checks / effect-checks — n/a, no new construct; new code is ordinary
![io]and checked as such - Emits valid
.sfn-asm— n/a, no lowering change - Lowers to LLVM IR — n/a
- Regression coverage — §8
- Self-hosts —
sfn dev bootstrap build; structural (new module) so--clean-treeper.claude/rules/selfhost-invariant.md -
sfn fmt --checkclean - Documented —
site/src/content/docs/docs/reference/cli.mdsfn configsection, and the new on-disk-layout page (SFN-1256)
8. Test plan
compiler/tests/, Sailfin *_test.sfn only — no shell (.claude/rules/no-bash-e2e.md).
Every test materialises its own config file under a scratch SAILFIN_CONFIG_DIR
and drives sfn through process.run_capture, using
clean_runner_env(nested_runner_scratch("<label>")) so the child does not
inherit the parent pool’s orchestration keys (SFN-401). No test may read or
write the real ~/.sfn — §5 notes that CI has no config file, which makes an
ambient-home test both non-hermetic and silently vacuous.
Unit — pure resolution, no subprocess:
- each admitted key resolves from config text
- absent key returns absent, not a default (fall-through is the caller’s job)
- unknown key and malformed TOML are ignored with a stderr warning, never fatal
[target.<triple>]selects by host triple; a section for another triple is not consulted- jobs clamp: configured below budget passes through; above budget clamps and
warns; zero and negative are rejected at
set
Integration — precedence, one test per adjacent rung pair:
- env beats config (
SAILFIN_LINKERover[target.*] linker) - CLI flag beats env (
--jobsoverSAILFIN_BUILD_JOBS) - config beats compiled-in default
- setting one section leaves the others byte-identical, extending the existing
registry/update-policyisolation coverage
E2E — the observable contract:
sfn config set/get/unsetround-trip for each key, including the dottedtarget.<triple>.linkerpathsfn config listshows provenance, and the provenance changes when the same key is also set in the environment- a build with
[target.<triple>] linkerset to a recorded shim invokes that shim — the only test proving the value reaches the linker rather than merely parsing - a config file with a jobs value above budget builds successfully, clamped
Regression guard: one test asserting that no admitted key can alter effect
checking — a capsule that fails E0402 fails identically with every admitted
key set to every legal value. This is the §4 boundary made executable, and it is
the test that must never be deleted.
9. References
- SFN-1257 — this SFEP’s design gate
- Implementation slices, in delivery order:
- SFN-1259 (A) — resolver seam +
[target.<triple>]linker/cc; blocks B and D - SFN-1260 (B) —
[build] cache-dir+ clamped[build] jobs(§3.4) - SFN-1261 (C) —
sfn configdotted keys, validation, list provenance (§3.6); independent of A and B - SFN-1262 (D) — the §4 effect-boundary regression guard; never-delete
- SFN-1259 (A) — resolver seam +
- SFN-1256 — on-disk layout reference page (
graduates-totarget) - SFN-1258 — extended
SAILFIN_CONFIG_DIRfrom the toolchain-default record alone to all three per-user records (config.toml,credentials,toolchain-default), with total, non-read-through override semantics. The §3.5 resolver (user_config_dir()) SFN-1259 introduced is the single seam that reads the variable - SFEP-0073 — Installed Toolchain Lifecycle; §3.3 precedence and §3.5 host-qualification are the models followed here
- SFEP-0046 — Native Toolchain Version Pinning + Dispatch; establishes
~/.sfnas the config root - SFEP-0002 — Capsule Distribution; establishes
[registry] urland theSFN_REGISTRY→ config → default chain .claude/rules/compiler-safety.md— the RAM budget §3.4 clamps against.claude/rules/seed-dependency.md— bundling rationale in §5- Prior art: Cargo
config.toml[build]/[target.<triple>]; rustupsettings.toml