Profile / Bundle / Patch: How Far Users Can Reshape the Product
Swap deep capabilities without touching source: config is four layers of patches stacked onto an empty array — later wins, same id means whole-entry replace.
--patch (one-shot experiments), and how those layers prioritize; why a patch that hits the same entry does whole-entry replace, and which counterintuitive result that choice causes; and how dsh --dump-config lays out the final effective config for you.
The sandbox below turns the four config layers into toggleable cards — bottom to top is application order. Hit Play and watch each layer brush its changes onto the final entry on the right. Watch layer 3 closely: it writes only one field, and the fields the two layers below carefully set get wiped. When you’re done, switch to deep-merge contrast mode and see another ending for the same change under merge semantics.
apps/cli/src/profile-boot.ts lines 121–129 and vendor/include/src/index.ts lines 58–128. deep-merge mode is a teaching hypothesis — DSH does not implement that semantics.Up front: DSH has no one big config file. The product shape is stacks of patch lists, and each stack has a clear owner.
Bundle is the release default. It’s an npm package; the substance is the patch list shipped inside. The three built-ins are base (shared core), web-app (browser surface), and headless (one-shot task mode).
Profile is a user’s assembly. It’s a directory under $DSH_HOME/profiles/<name>: the manifest lists which bundles to use and in what order, with the user’s own patch file beside it. First use of the names web or headless auto-initializes a template.
Patch is the smallest unit of change. One patch finds a target entry by id, then changes config, disables it, or inserts a new entry. Out-of-tree plugins enter here too: dsh plugin add installs into a profile, and afterward it’s just another patch layer.
Source: the three built-in bundles and how package.json declares bundles — see packages/bundle/README.zh.md; profile template auto-init at apps/cli/src/profile.ts lines 114–117.
What problem it solves
Imagine the opposite: one big config file where release defaults, this assembly’s customizations, personal prefs, and one-off experiments all live together. Three months later you upgrade the release — new defaults and your old edits tangle in the same file. You can’t tell which line who wrote, which line is safe to touch; upgrades become a manual diff.
There’s a more everyday failure: you temporarily try an experimental model, tweak config and forget to revert — next day the whole environment runs on experiment settings. The root cause is no ownership of change: who wrote it, what it rides with, when it should disappear — one file can’t answer those three.
What the idea is
DSH splits config into four layers, each with an owner: Bundle rides with the release, Profile with this assembly, Home with this machine, --patch with this one command. At boot it starts from an empty array and brushes the four patch layers on in fixed order — later wins.
The starting point really is an empty array: the root config file is just [], and the template comment says don’t edit this file — edit patch files. All real content arrives via patch insert, so every line in the final plugin tree can answer which layer it came from.
The two user-patch layers also divide carefully: Profile rides with this assembly; Home is machine-local preference, applies to every profile, so it sits after Profile and overrides it. When both change the same id, Home wins. --patch comes last for one-shot experiments you don’t want in files — you can pass multiple, applied in CLI order. In long sessions both user-layer files are watched; save a change and the running tree recomposes in the same layer order.
Layer order in source is literally an array literal: the launcher flattens the composed profile into a patch array, and array order is application order. The function is short enough to quote as a slogan — it proves the four-layer sequence is hard-coded in one array, with no conditionals:
/** The full patch stack of one composed profile, in application order. */
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [
...composed.bundlePatches,
...composed.profile.patches,
...composed.homePatches,
...composed.overlays,
]
}
apps/cli/src/profile-boot.ts, verified on 2026-08-13. Code blocks keep the original source text.Source: empty-array root config and template comment at apps/cli/src/profile-boot.ts lines 60–64; why Home overrides Profile at packages/boot/app-boot/README.zh.md line 43; repeatable --patch at apps/cli/src/args.ts line 132; hot reload via same-package watchUserPatches.
Why it lasts
Layered override is a config-system universal: CSS cascade, systemd drop-in dirs, editor user settings over defaults — same structure. Whenever one product is edited by release, team, individual, and single-command roles at once, layers must separate, or upgrades and rollbacks have nowhere to start. Rewrite the whole harness in another language and these four layers are still these four layers.
What problem it solves
Stacking still needs one key decision: what when two patch layers hit the same entry? The intuitive answer is deep-merge — field-level merge, upper layer writes only what it wants to change, other fields keep automatically. Easy to write.
Easy has two costs. First, merge can’t express delete: if a lower layer set a field and the upper wants it gone, merge has no such action — you can only overwrite with another value. Second, the final result matches no file’s literal content; debugging config means running every layer’s merge algorithm in your head before you know what’s actually in effect.
What the idea is
DSH chose whole-entry replace. After a patch hits a target by id, every top-level key in the patch except id is assigned over. config is one top-level key, so the old config object is wholly replaced — none of its fields survive. Want to change one field? You still restate the fields to keep. Official docs call this a known limitation, in so many words: “profile overrides must restate bundle fields that should be preserved.”
That yields the easiest counterintuitive trap — the scene layer 3 demos: Home writes only model, and provider and temperature set by the two layers below get wiped, with no warning, just a wrong result. Three behavior edges to remember: a patch pointing at a missing id warns but doesn’t error — one stderr line, then skip; an empty or comment-only patch file throws, because the parse result isn’t a list; to make a layer do nothing, write [].
Replace semantics pays off in predictability. There’s one copy of this algorithm in the whole repo: mount uses it, offline compose for dsh --dump-config uses it — dump and real boot cannot drift. Inputs are never mutated; results are always deep copies, so when hot-reload drops a patch it truly restores — earlier values aren’t baked into a cache. There’s also a what-you-see-is-what-you-edit map: docs/config-catalog.zh.md, a 3152-line generated doc listing every loadable package’s config types as-is. Want to know which keys a patch can write? Check that catalog.
Source: replace semantics (top-level keys assigned one by one) at vendor/include/src/index.ts lines 110–124; the single-algorithm and immutable-input promise in the same file’s JSDoc lines 43–52; official whole-entry replace note at packages/boot/app-boot/README.zh.md line 60.
Why it lasts
It’s an old declarative-config choice: merge’s convenience vs replace’s readability. Replace gives each entry a unique last author — when something breaks, find the last patch and its literal content is what’s in effect; the cost is restating when you write. DSH scored the ledger toward predictability. Any config system that allows multi-party stacking and expects users to self-debug faces this question — independent of implementation language.
DeepSeek Harness
Plugin-entry level · whole-entry replaceThe unit of a layer is a plugin entry: one patch replaces a whole config. Four layers from Bundle to --patch in fixed order; root config is an empty array; every line traces to a layer.
Swapping a deep capability means swapping one entry: replace the compaction plugin’s config wholesale, or even insert a third-party implementation.
Claude Code
Settings-field level · five sourcesSETTING_SOURCES lists five layers: userSettings, projectSettings, localSettings, flagSettings, policySettings — later is larger (settings/constants.ts lines 7–22). Admin policy always sits on top, and some fields only take effect from managed sources.
What gets layered is settings fields — behavior parameters. The plugin tree itself isn’t on the config table.
Grok Build
TOML field level · deep mergexai-grok-config recursively merges with deep_merge_toml (loader.rs lines 415–426): tables merge, arrays replace, upper fields win. Layer order is system_managed, managed, user; requirements and MDM managed layers sit on top last (loader.rs lines 234–248).
It picked the deep-merge contrast from the DSH demo: change one field without restating, but the upper layer can’t delete a lower-layer field.
Two comparison foci. First, merge semantics: Grok and Claude Code both do field-level merge — easy to write; DSH does entry-level replace — verbose to write, in exchange for dump matching file literals. Second, what gets layered: those two layer settings fields; DSH layers the plugin tree itself, so how deep users can reach differs — one insert patch can wire a third-party compaction into the main loop, with no counterpart in the other two config systems. Claude Code also has what DSH lacks: an admin policy layer and locked fields that only apply from managed sources — DSH has no equivalent today, based on public materials.
Hand-trace a two-layer conflict
Profile layer writes id: conversation-model, config: { provider: deepseek, model: v3.2, temperature: 0.2 }; Home writes id: conversation-model, config: { temperature: 0 }.
Q1: Under whole-entry replace, write the final entry’s config — which fields vanish, and will any warning tip you off?
Q2: Swap which layer each patch sits in — what does the result become?
Q3: Using the dsh --dump-config idea, explain how to verify your answer without starting the product.