Skip to content

feat(config): resolve settings from layers, with provenance - #849

Open
jdx wants to merge 1 commit into
agent/config-completefrom
agent/config-runtime
Open

feat(config): resolve settings from layers, with provenance#849
jdx wants to merge 1 commit into
agent/config-completefrom
agent/config-runtime

Conversation

@jdx

@jdx jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner

The runtime half of the config feature: a new usage-config crate that resolves a CLI's settings from its layers. Sixth PR of stack #836; depends on the vocabulary in #835 only conceptually — this crate has no dependencies at all, not even on usage-lib.

That is the central design decision. The spec declares the settings; usage-config-build (a later PR) reads the config block at build time and emits a registry of consts; this crate resolves against it. A CLI therefore ships a resolver, not a spec parser, and a registry costs nothing to load.

Why this exists

Every CLI in the fleet has written this by hand, and every copy has rotted differently:

  • hk declares 18 sources.cli bindings and reads 5 of them; its validate.enum is declared and never read; its docs claim 7 layers and its code has 5.
  • pitchfork documents a CLI layer in --help that does not exist, and has 5 settings that are declared, generated, live in code, and unreachable from settings get/set.
  • fnox's module doc describes a config-file layer that does not exist, and generates provenance types that are dead code.
  • mise hand-copies 13 flags into its settings in a 49-line function with 13 ifs, scans --offline out of raw argv, and types Duration as "number" in one generator and "string" in another.

None of that is carelessness. It is what happens when the declaration of a setting and the code that resolves it are two separate things kept in step by hand.

What is in it

module what
value Value (owned, runtime) and Const (const-constructible default) — two types because a generated registry must have no initializer to run
ty the declared type as a match rather than a parse, plus the named splitters (list_by_comma and friends)
registry PropId-interned keys, so a merge over 100 settings never hashes one; rename chains resolve to their end
layer the interface a CLI writes its own sources against
resolve one merge — replace/union/deep — with provenance as its output

Provenance is not a second pass. hk grew a parallel merge function purely to answer "where did this come from", and the two could disagree — so hk config explain could describe a resolution that never happened. Here the origin is recorded as the value is chosen, so that class of bug is unreachable. Origin carries the identifier, not just the kind: "from the environment" is not something a user can act on and HK_JOBS is.

Custom sources are the normal case, not an escape hatch. Registry::bindings("git") hands a layer the settings bound to its own kind and the key each has there. usage knows nothing about git, pkl or .npmrc; hk's git layer becomes about twenty lines rather than a second resolution system.

Two things live in the merge rather than in layers. Scope (global, env) is enforced there because mise calls it a security property, and a check every layer has to remember is one a new layer will forget. And a post-merge rewrite goes through coerced(), which re-labels the origin — so a value mise derived from raw never claims to have come from a file that never said it. That is how a user ends up editing a file with nothing to do with what they are seeing.

Nothing prints. Unknown keys, bad values and deprecated settings all come back as warnings. mise queues these until its logging is up, and a library with an opinion about stderr cannot be used by anything that has its own.

Verification

24 tests, including the crate-level doc example so the documented usage is compiled and run. Eight mutations of the merge semantics, each verified to fail without its fix: inverted precedence, scope unenforced, global over-refusing the user's own file, union and deep each degraded to replace, a set that stops deduplicating, and a rewritten value that keeps its old origin. Two of those mutations initially did not apply — the search text had been reflowed by cargo fmt — which is only visible if you assert the match before mutating; the run that reported "ok" was measuring nothing.

cargo test --workspace --all-features green, clippy --all-targets -D warnings clean with no allow anywhere (the one clippy complaint, Origin::default shadowing the trait method, is now Origin::declared_default — an Origin has no sensible zero, since every one names a real place).

Not in this PR

File layers (TOML/JSON, find-up, trust), the explain renderer, usage-config-build and the typed Settings struct, and the config conformance corpus. Each is its own change on top of this one.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.


Note

Medium Risk
New foundational config resolution with scope/trust rules and extensive merge edge cases; security-sensitive behavior is centralized but will underpin future CLI integration.

Overview
Adds a new usage-config workspace crate (zero runtime dependencies) that resolves CLI settings from ordered layers against a build-time Registry of PropMeta consts—no KDL or spec parsing in the hot path.

resolve performs a single merge with fixed precedence (higher layers win), recording provenance (Origin with concrete identifiers like HK_JOBS) alongside values. Merge modes replace, union, and deep are supported; declared defaults seed the lowest contributor so union/deep semantics stay correct. Scope (global, env) is enforced centrally via Trust on origins (including custom sources like git/pkl), not per layer.

Layer + LayerCtx give CLIs a shared path for parsing raw strings with spec Ty / Parser, unknown keys and bad values as warnings, and rename/deprecation handling. Registry::bindings lets custom layers map external keys without usage knowing the format.

Post-merge rewrites go through Resolved::coerced so explain-style tooling stays honest. Workspace Cargo.toml / lockfile register the new member.

Reviewed by Cursor Bugbot for commit 82096dc. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 83992c61-744e-436e-b715-a37a62c5c2ef

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds the dependency-free usage-config crate for resolving generated setting registries across ordered layers while recording provenance.

  • Introduces typed runtime and const values, parsers, registry metadata, rename resolution, and custom-source bindings.
  • Implements replace, union, and deep merging with defaults, scope enforcement, warnings, contributors, and post-merge coercion provenance.
  • Adds the crate to the workspace and extensive unit and documentation tests.

Confidence Score: 4/5

The PR does not appear safe to merge until renamed declarations carrying the only default preserve that default on the canonical setting.

The current default-seeding loop skips every renamed declaration, so a registry where the old declaration owns the default and its replacement has none resolves both names without the declared value.

Files Needing Attention: config/src/resolve.rs

Important Files Changed

Filename Overview
config/src/resolve.rs Implements layered merge, defaults, scope checks, provenance, and rename folding; renamed declarations carrying the only default are still skipped.
config/src/layer.rs Adds layer interfaces and helpers that preserve original rename keys while parsing against canonical metadata.
config/src/registry.rs Defines const registry metadata, canonical lookup through bounded rename chains, and custom-source bindings.
config/src/source.rs Defines source identities, file scope, trust levels, and actionable origin descriptions.
config/src/ty.rs Adds declared-type coercion and named text splitters for layer inputs.
config/src/value.rs Adds owned runtime values and const-constructible defaults with recursive conversion.
config/src/lib.rs Exposes the new resolver API and documents its layering, provenance, and scope guarantees.
config/Cargo.toml Defines the dependency-free usage-config crate and workspace release metadata.
Cargo.toml Registers config as a workspace member and workspace dependency.

Fix All in Greploop

Reviews (14): Last reviewed commit: "feat(config): resolve settings from laye..." | Re-trigger Greptile

Comment thread config/src/resolve.rs Outdated
Comment thread config/src/layer.rs
Comment thread config/src/resolve.rs
Comment thread config/src/resolve.rs
Comment thread config/src/resolve.rs
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▁▁▁▁▁▁▁▂▂▂█ 152,897,001 → 175,042,461 +14.48% ⚠️ 14.41 → 16.10ms +11.73%
startup ▁▁▁▁▁▁▁▁▁▁▁▁█ 1,201,844 → 1,222,055 +1.68% ⚠️ 0.97 → 0.98ms +1.27%

2 benchmark(s) above the 1% gate: markdown +14.48%, startup +1.68%

Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run.

Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.

Shadow comparison

Parsing mise use -g node@20 against a shadow of mise's committed spec.
Reported, not gated: the shadow grows as the derive learns to express more, so
what to watch is the ratio rather than either column.

usage clap ratio
instructions, cold parse 29823 5960254 199x
usage: argv -> struct                             834 ns      0.83 µs
clap: build tree + parse -> struct             503686 ns    503.69 µs
clap: parse -> struct, tree reused              23407 ns     23.41 µs
clap: build tree only                          313685 ns    313.69 µs

82096dcc3615 vs ebea8955d430 · measured on the runner, not pushed to the history.

@jdx
jdx force-pushed the agent/config-runtime branch from 34b7af5 to 7743a25 Compare August 12, 2026 23:49

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Four findings on the first review round, all real. The first is the one worth reading — it was a genuine hole in something I called a security property.

The scope check skipped every custom source. refuse asked whether an origin was a file, and every custom layer builds its origins with Origin::new — so a pkl file, a git config or an .npmrc inside a checkout could set a scope="global" setting, or a scope="env" one. Bugbot also noted that Layer::source was documented as the check's input and never consulted.

The fix reframes the question rather than adding a case. What matters is not "was it a file" — a pkl file in a repository is exactly as much a thing a checkout carries as hk.toml is — but how much the place is trusted:

pub enum Trust { Project, Operator, Invocation }

Every origin carries one, and Origin::new derives it from the kind: the invocation for cli/env/defaults/coerced, and Project for anything else. That default is the point: a kind usage does not recognize is one it cannot vouch for, so a layer that says nothing cannot accidentally be believed. A layer that knows better says so — Origin::new(git, "hk.jobs").trusted_as(Trust::Operator) for a git config in $HOME.

Renames were not folded at the merge. LayerCtx::prop follows a rename, but a layer taking ids from Registry::bindings or ids — the documented way to write a git or pkl layer — hands over the old prop's id, and the value was stored there, where get_key (which does follow the rename) would never look. Honoured nowhere, reported nowhere. Folding now happens in the merge, which also covers Greptile's related point: the warning names both keys, the one written and the one it was read as, so entry() keeping its PropId signature costs nothing.

A set kept repeats from a single source. Deduplication lived on the merge-two path, so one TAGS=a,b,a kept its repeat. A set that is only a set once two layers disagree is not a set.

Collection defaults could not take part in a merge. As a floor applied after the layers, a default only filled in what nothing had set — so a union list with a declared default lost every one of its items the moment any layer supplied anything. Defaults are now seeded as the bottom layer, which is what a default is; they show up in contributors as the lowest contribution, so explain says so too.

All four mutation-verified. Two of the mutations initially did not apply — cargo fmt had reflowed the text I was matching — and one test initially could not reproduce its own bug at all, because the helper it used to build the entry went through lookup, which folds renames. It now uses the raw id, which is what bindings() actually hands a layer.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

Comment thread config/src/resolve.rs
Comment thread config/src/resolve.rs
@jdx
jdx force-pushed the agent/config-runtime branch 2 times, most recently from f2b4563 to 306b14c Compare August 13, 2026 00:05

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Both bots caught the same thing and both were right: my fix for the rename fold moved the value and the winning origin to the folded PropId and left contributors keyed by the id the layer supplied. So origin() named the real source while contributors() on the canonical prop omitted it — the provenance split this crate exists to make unreachable, reintroduced by two lines, in the same change that was meant to close a gap.

Fixed, but the more useful outcome is why it survived my own testing: every assertion I had checked one field at a time — the value landed, the warnings said the right thing — and none checked that the fields agree. So there is now an invariant test: resolve a mix of layers including a renamed key, then for every prop assert that the winning origin is the last contributor and that the two are present or absent together. That fails on the mutation; the old per-field assertions did not.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

Comment thread config/src/resolve.rs
@jdx
jdx force-pushed the agent/config-runtime branch from 306b14c to b930fa9 Compare August 13, 2026 00:20
Comment thread config/src/resolve.rs
Comment thread config/src/resolve.rs
Comment thread config/src/resolve.rs
Comment thread config/src/resolve.rs
@jdx
jdx force-pushed the agent/config-runtime branch from b930fa9 to 1aaa9bd Compare August 13, 2026 00:29

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Four genuinely new findings in this round — all of them consequences of the previous round's fixes, which is fair. (Several other comments on this commit are re-anchored copies of findings already fixed: the file_scope scope check, the rename fold, single-source set dedup, and the contributors key.)

A rename's default was seeded where nothing reads it. My defaults-as-bottom-layer change seeded by each prop's own id, so a default declared on an old name landed under an id every lookup folds away from. An old name is an alias, not a setting — it is skipped now, and the setting that replaced it declares its own default.

A deprecated key in a file was folded in silence. The rename warning fired only when the entry still carried the old id, and LayerCtx::prop already folds — so a file layer looking up dotted keys reported nothing at all. This is Greptile's entry finding from the last round, arriving from the other direction, and it is now fixed properly rather than argued away: Entry carries the key that was written, and there is a ctx.entry_for_key(key, raw, origin) that fills it in — looking up, folding, remembering the written name, parsing, and turning an unknown key into a warning. That is the path a file layer should take, so the warning is not something a layer has to remember to produce.

A post-merge rewrite split the provenance. coerced() replaced the value and the winning origin without touching contributors, so origin() named the rewrite and contributors().last() named what it replaced — the invariant I added a test for last round, broken by the function next to it. It appends now.

An empty list could not clear a default. With defaults merging rather than filling in, HK_EXCLUDE= concatenated with the declared default and left every item in place. Parser::split returns an empty list for an empty string precisely so a user can say "none", so an explicit empty list now clears what is below it.

All four mutation-verified. One took two attempts: the alias-default mutation survived at first because the fixture's alias had no default to seed, so the test could not tell the two behaviours apart. The fixture now has one.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

Comment thread config/src/layer.rs
Comment thread config/src/ty.rs
@jdx
jdx force-pushed the agent/config-runtime branch from 1aaa9bd to 57eb621 Compare August 13, 2026 01:00

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Two more, both real, and both consequences of the previous round again.

An alias was read with its own metadata. Registry::bindings yields the pre-rename id, and LayerCtx::parse/entry read parse and ty from it — so a git or pkl layer bound to an old name split and coerced with the alias's metadata, which is usually bare. A comma-separated value arrived as one unsplit string on the replacement's list. What governs a value is the setting it lands on, so LayerCtx folds the id before reading metadata, and entry folds it while recording the name that came in — which means an entry built from a binding now carries exactly what one built from a key does, and the deprecation warning fires either way.

An empty string became a one-item list. A list or set given a bare "" was wrapped as a list holding "". The named parsers already treat "" as no items, and an empty list is what a higher layer uses to clear a declared default — so on a list without a parser, HK_EXCLUDE= both failed to clear the default and added an item nobody asked for. Same rule everywhere now.

Three mutations, all verified.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

@jdx
jdx force-pushed the agent/config-runtime branch from 57eb621 to a3967d0 Compare August 13, 2026 01:07
Comment thread config/src/resolve.rs
@jdx
jdx force-pushed the agent/config-runtime branch from a3967d0 to 49f82ea Compare August 13, 2026 02:38
Comment thread config/src/resolve.rs
@jdx
jdx force-pushed the agent/config-runtime branch from 49f82ea to 06b6aab Compare August 13, 2026 03:27

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Both real, and both about the same thing: a warning that names something the user cannot find.

The refusal named the folded key. I had fixed the deprecation and rename warnings to say the name that was written and left the refusal saying written.key — which, after LayerCtx folds a rename, is the replacement's name. A refused value was reported under a key that appears nowhere in the file the user would go and edit. The type-error message in LayerCtx::entry had the same fold, and is fixed too.

The refusal blamed a config file. Since the check became one about trust rather than about files, it also refuses a pkl file, a git config or an .npmrc in the checkout — and telling that user their config file is at fault points them at a file that never held the value. It now says "cannot be set by anything a project can carry", and the warning carries the origin so whoever renders it can name the place exactly.

Three mutations. The type-error one initially survived, because nothing covered that message under an old name; there is a case for it now.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

Comment thread config/src/registry.rs Outdated
@jdx
jdx force-pushed the agent/config-runtime branch from 06b6aab to 5b5209e Compare August 13, 2026 03:39
Comment thread config/src/ty.rs
Comment thread config/src/ty.rs
Comment thread config/src/ty.rs
@jdx
jdx force-pushed the agent/config-runtime branch from 5b5209e to 83ec93b Compare August 13, 2026 03:53

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Real, and worth fixing for the reason given: a stack overflow is an abort with no message, so one mistyped renamed_to in a generated or hand-written registry would take the process down instead of failing a lookup.

The chain is now walked rather than recursed, bounded by the number of settings — a chain longer than the registry is a cycle by definition. lookup returns None for a cycle, which the caller already handles as an unknown key.

Verified by mutation in the strongest form available: with the bound removed the cycle test does not fail, it hangs — the run reaches "running 1 test" and never returns. Covered are a two-step cycle, a setting renamed to itself, and a three-link chain that does resolve, so the bound is not simply refusing chains.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

Comment thread config/src/resolve.rs

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 83ec93b. Configure here.

Comment thread config/src/layer.rs
@jdx
jdx force-pushed the agent/config-runtime branch from 83ec93b to 72af80d Compare August 13, 2026 04:02

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Accurate description, but I am keeping the behaviour, so let me say why rather than quietly not fix it.

An old name is an alias. Which value a setting takes is the setting's business, and the replacement declares its own default — so an alias whose default the replacement lacks is a contradictory spec, not a case with an obvious answer. Seeding it would mean a declaration nobody reads any more silently governing the setting that replaced it, which is the class of spooky behaviour the rest of this crate is spent eliminating.

The right place to catch it is where the registry is built, not where values are resolved: usage-config-build reads the config block at build time and can refuse an alias that declares a default its target does not, with the file and line. I have noted it for that PR. Warning at resolve time instead would fire on every run of a shipped binary for a mistake only the CLI's author can fix.

Worth adding that the case is narrow: a rename in a real registry carries defaults on both declarations — mise's do — and the target's own default wins, which is already what happens.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

A new crate, `usage-config`: the runtime half of the config feature. The spec
declares the settings; `usage-config-build` will turn that declaration into a
registry of consts at build time; this crate resolves values against it. No KDL
parser and no dependencies at all, because it is what runs in the CLI.

Every CLI in the fleet has written this by hand, and every copy has rotted
differently — hk declares eighteen `sources.cli` bindings and reads five,
pitchfork documents a CLI layer it does not have, fnox's module doc describes a
config-file layer that does not exist, mise hand-copies thirteen flags in a
forty-nine-line function. That is not carelessness; it is what happens when the
declaration of a setting and the code resolving it are two things kept in step
by hand.

What this PR contains:

- `Value` and `Const` — the runtime value and the `const`-constructible default,
  separate because a generated registry must cost nothing to load.
- `Ty` and `Parser` — the declared type as a match rather than a parse, and the
  named splitters (`list_by_comma` and friends) that are spec vocabulary so
  another language's runtime can honour them identically.
- `Registry`/`PropId` — interned keys, so a merge over a hundred settings never
  hashes one. Rename chains resolve to their end while reporting the name the
  user actually wrote.
- `Layer`/`LayerCtx` — the interface a CLI writes its own sources against, with
  `Registry::bindings(kind)` as the mechanism behind hk's git and pkl layers and
  aube's `.npmrc`. usage knows nothing about git; it just hands back the keys.
- `resolve` — one merge, `replace`/`union`/`deep`, and provenance as its output
  rather than a second pass. hk grew a parallel merge just to answer "where did
  this come from", and the two could disagree; here `config explain` cannot
  describe a resolution that did not happen.

Two things are deliberately in the merge rather than left to layers. Scope
(`global`, `env`) is enforced there because mise calls it a security property,
and a check every layer has to remember is one a new layer will forget. And a
post-merge rewrite goes through `coerced()`, which re-labels the origin, so a
value mise derived from `raw` never claims to have come from a file that never
said it.

Warnings are returned, never printed: an unknown key, a bad value, a deprecated
setting. mise queues these until its logging is up, and a library with an
opinion about stderr cannot be used by anything that has its own.

24 tests including the crate-level doc example; eight mutations of the merge
semantics — inverted precedence, scope unenforced, global scope over-refusing,
union and deep degraded to replace, sets not deduplicating, a rewrite keeping
its old origin — each verified to fail without its fix.

Not in this PR: file layers (TOML/JSON, find-up), the `explain` renderer, the
build-time codegen and the typed `Settings` struct, and the config conformance
corpus.
@jdx
jdx force-pushed the agent/config-runtime branch from 72af80d to 82096dc Compare August 13, 2026 04:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant