Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
37 KiB
Resolution must report provenance, not fall open silently
- Status: Accepted
- Date: 2026-06-17
Context
A verb resolves config (or a skill set, or a planning path) from the invoking cwd / MSD_WORKSTREAM / stored workstream pointer. When that ambient context is "off" — a descendant subdirectory with no .planning/, or a workstream with no scoped config — resolution silently falls open to bare defaults, the verb succeeds with empty output and no signal, and a downstream subagent plans or verifies without its configured context. The gap is invisible: output is still produced.
We have shipped the same fix-shape ≈11 times between April and June 2026 — anchor to the project root / fall back to the root config instead of defaults / bolt a diagnostic onto one verb. The recurrence is concentrated, not scattered:
loadConfigis a 9-patchtry/catchladder (#315, #443, #910, #1683, #2517, #2714, #3023, #3024, #3523). Each "config fell to defaults" bug adds a branch, and the returned object is the same shape whether it found real config or bare defaults — so every caller that cares re-detects degradation by sniffing the contents.- The agent-skills verb received this exact fix twice in two weeks: #1374/#1376 (the
warnings[]field) and then #1366 (PR #1408). - The diagnostic half is hand-rolled eight ways across seven files; the I/O Module's
output()has no notion of a "degraded" result. - The walk-up to the project root exists three-to-four times; PR #1408 adds a weaker fourth (
resolvePlanningCwd) because the canonicalfindProjectRoot(Project-Root Resolution Module) skips the plain single-repo-descendant case.
The #1366 trigger
msd-tools query agent-skills <agent> resolved a configured agent's <agent_skills> block to empty with no diagnostic under two invocation-context drifts: (1) invoked from a descendant subdirectory with no .planning/, config fell through to bare defaults → agent_skills was {}; (2) MSD_WORKSTREAM pointed at a workstream with no scoped config → the same fall-through. In both cases the verb exited 0 and emitted an empty block, so a planner/checker subagent planned or verified without its configured skill/rule context, invisibly.
The generalizing precedent
ADR-227 established that input validation at a trust boundary must check semantic shape, not just type, and surface coercion rather than propagate a contractually-invalid value. This ADR is the analog for the resolution side of the same trust boundary: looking a value up from ambient context (cwd, env, a stored pointer) is itself a trust boundary, and silently substituting defaults when the lookup misses is the resolution-side equivalent of propagating a garbage value — the caller cannot tell a real answer from a degraded one. CONTEXT.md's Planning Path Projection Module already states the rule for the SDK path-projection seam — "invalid workspace context is a validation error at this seam rather than a silent fallback" — but the CJS loadConfig never adopted it.
Decision
Context resolution at a trust boundary — reading config, anchoring to a project root, resolving a workstream — MUST report its provenance. A resolver may fall back, but the fallback must be a visible value, not a silent substitution. Three sub-rules:
- Deterministic anchoring. Resolve the project root through one walk-up module. Resolution MUST NOT depend on an arbitrary descendant cwd. The single owner is the Project-Root Resolution Module; ad-hoc walk-ups (e.g.
resolvePlanningCwd) are retired into it. - Provenance, not a bare value. A resolver returns what it resolved and where it came from. Callers branch on the provenance field, never on the resolved contents, to detect degradation.
- Visible degradation. A configured input that resolves empty MUST emit a diagnostic. "Not configured" and "configured-but-resolved-empty" MUST be distinguishable in the output contract.
Concretely, the principle binds three seams:
- Config Loader Module —
loadConfigexposes aConfigResolution { config, source: 'workstream' | 'root' | 'global-defaults' | 'builtin-defaults', degraded: boolean }. Introduced additively (loadConfigResolved) so the ~16 existingloadConfigcall sites, SDK parity, and the generated.cjsare unaffected until they opt in. - Project-Root Resolution Module — absorbs the nearest-
.planning/ancestor as a first-class heuristic;resolvePlanningCwdand any sibling walk-up are deleted. - I/O Module — a shared
Resolution<T> { value, configured, reason, warnings }envelope;output()carries degradation so the eight hand-rolledwarnings[]shapes converge on one.
A configured input that resolves empty without a reason is a CI-guarded regression (grandfather burn-down, mirroring the no-adhoc-markdown-parsing rule).
Consequences
Bug classes avoided
- Silent context drop — a planner/checker subagent planning or verifying without its configured skills (the #1366 / #1374 class).
- N callers re-sniffing — every consumer re-deriving "did this fall open?" from config contents instead of reading one field.
- Walk-up drift — a fourth or fifth project-root resolver diverging from the canonical one.
Cost
loadConfig's result type grows — mitigated by the additiveloadConfigResolved; callers migrate incrementally.- One envelope to learn; ~18 verbs migrate onto it across phases P3–P4.
Tradeoff
As in ADR-227, resolution may still fall back to preserve continuity — a missing workstream config should not abort the verb. The difference is that the fallback is now a visible value plus an opt-in warning, never a silent success. Fields where a miss is genuinely fatal may throw; that is a per-call decision, not the general rule.
Alternatives considered
Per-verb patching (status quo)
Rejected. The same fix-shape regenerated ≈11 times because each patch fixed one call site without changing the policy that the resolver fails open and hides which branch fired.
Throw on a resolution miss
Rejected, for ADR-227's reason: throwing breaks pipeline continuity. A missing workstream config must not abort query agent-skills. Visible provenance preserves continuity and visibility.
Deterministic anchoring only (no provenance)
Rejected. Fixing cwd/workstream drift removes the most common trigger but leaves callers re-sniffing contents and the diagnostic hand-rolled per verb — the bug class would keep regenerating at the next new consumer.
Related
- Epic: #1411 (Resolution Provenance) · This ADR (P0): #1412
- Supersedes the tactical fix in PR #1408 (closed) — its
resolvePlanningCwdand localAgentSkillsReason/AgentSkillsDiagnosticsare redelivered through the seams above in P1–P3. - Builds on: ADR-227 (input validation shape), ADR-0004 (Planning Workspace Module), ADR-0006 (Planning Path Projection Module).
- Prior recurrences of this class: #1374/#1376, #1683, #991, #2714, #2638, #3523, #2652, #2791, #2555, #2623, #3196.
Amendment — 2026-06-18: P3 narrowed (the shared envelope is not a real seam)
The original P3 plan was a single Resolution<T> { value, configured, reason, warnings } envelope adopted by agent-skills, capability-state, and capability-writer. An adversarial fit-analysis showed this fails the deletion test: configured/reason are meaningless for the capability read/mutation verbs, and capability-writer's errors[] (operation-not-applied) is load-bearing and cannot fold into warnings[] (advisory). The only genuinely shared seam across the three is warnings: string[].
P3 is therefore narrowed to an honest convention rather than a forced generic:
Resolution<T> { value, configured, reason, warnings }(src/resolution.cts) is the canonical shape for config-interpreting read verbs.agent-skillsis the first adopter — thevaluefield is added additively to its--jsonIR with the flat fields retained for back-compat;source/degradedremain config-provenance extras.- Capability verbs keep their existing shapes, named explicitly: read =
{ runtimeConfigDir, capabilities, warnings? }; mutation ={ capabilities, warnings, errors }. - The shared contract is documented, not forced: read verbs expose
warnings[]; mutation verbs exposewarnings[]+errors[];configured/reasonappear only on config-interpreting read verbs.
Recurrence prevention does not depend on a shared envelope — it is delivered by P4's CI guard (a configured input resolving empty must carry a reason). (#1416)
Amendment — 2026-07-26: corrupt is not absent
This ADR reasons exclusively about a resolution miss — ambient context is "off", the lookup finds nothing, resolution falls open to defaults. It is silent on the adjacent case: input that is present but not usable. That silence is why five engine read paths (#1879) could fold an unusable input into the very value that means "genuinely absent" without contradicting an Accepted ADR.
The failure detection differs per site and is not one mechanism — naming them precisely, because the fix differs with them:
| Site | How "not usable" is detected |
|---|---|
config-loader.cts (#1880) |
SyntaxError from JSON.parse, or an errno (EACCES) re-thrown by platformReadSync |
roadmap-parser.cts (#1881) |
errno only — the parse is regex over text and cannot throw |
frontmatter.cts (#1882) |
neither — no I/O and no throw site; an opening --- with no closing fence is a structural check the function must make for itself |
planning-workspace.cts / verify.cts (#1883) |
errno from readdirSync (EACCES/EIO) |
planning-workspace.cts (#1884) |
an errno that was swallowed, then misclassified as a different condition |
What the two governing ADRs actually permit
Read together rather than selectively, ADR-1411 and ADR-227 converge, and they do not license throwing as a cluster-wide answer:
- ADR-227's Decision requires malformed input to be "silently coerced to the contract's safe default … It MUST NOT be propagated. Throw only if the surrounding codebase treats throws as a normal-flow signal (it usually does not …)", and its rejection of throwing carves out only fields where a value is "genuinely fatal (not just malformed) … a per-field decision, not the general rule." Malformed is explicitly on the coerce side of that line.
- This ADR's own Decision says: "A resolver may fall back, but the fallback must be a visible value, not a silent substitution."
The gap in the five sites is therefore not that they fall back. It is that they fall back invisibly. Continuity is correct and stays; the silence is the defect.
The pattern — keep the fallback, make it visible
Both mechanisms below preserve every current return value. Neither changes a return type, so no caller that treats "absent" and "unusable" identically breaks.
-
In-band, where the result already carries provenance. A read whose result is a provenance envelope names the cause in that envelope.
loadConfigResolved'sConfigResolution { config, source, degraded }is the first adopter (#1880): genuine absence keepsdegraded:false; an unusable config setsdegraded:trueand adds areason.Resolution<T>(src/resolution.cts) has no value for this case today — its documented vocabulary isresolved/not_configured/configured_empty/configured_unresolved, all of which describe a miss. #1880 introduces the unusable-input values and is responsible for documenting them alongside the existing four. -
Out-of-band, where the return is a bare value that cannot carry provenance. A read that returns a bare sentinel or a plausible default keeps returning exactly that, and emits a deduplicated
stderrdiagnostic naming the file and the errno. This coversgetRoadmapPhaseInternal(#1881),findContextMdIn/listMilestoneArchiveDirs(#1883),getMilestoneInfo(#1881) — whose fallback is a populated{ version: 'v1.0', name: 'milestone' }rather than an empty sentinel, and a plausible-looking default is more in need of a diagnostic than an empty one, not less — andextractFrontmatter(#1882), which returns{}. The repo's existing seam isconfig-loader.cts's_warnedUnknownConfigKeysguard aroundprocess.stderr.write.This is unconditional, and that is a deliberate divergence from ADR-227. ADR-227's Tradeoff proposes mitigating silent coercion with "an opt-in debug log (
process.env.MSD_DEBUG)"; that env var has never been implemented, and an opt-in nobody sets is indistinguishable from the silence #1879 is about. This ADR's own Decision is the stronger rule and the one that governs here — degradation must be visible, not discoverable-on-request. The_warnedUnknownConfigKeysprecedent is likewise unconditional. Appliers follow this ADR, not ADR-227's tradeoff, on that point.Dedup key. Key the guard on the resolved absolute path plus the errno, not on the message text or the bare errno. Keying too coarsely suppresses a genuine second failure in a different file; keying on prose couples the guard to wording.
Throwing is not the cluster's answer. It remains available only under ADR-227's genuinely-fatal carve-out, decided per call and justified in that PR — never inferred from the return shape. withPlanningLock (#1884) is the one site that qualifies, and it already throws; its defect is that it throws the wrong error after swallowing the real one.
Detection, not propagation, where there is no exception. #1882 takes the out-of-band mechanism above like its siblings — the difference is only in how the condition is found. extractFrontmatter takes a string, does no I/O, and has no throw site, so there is nothing to catch: an opening --- with no closing fence is a structural check the function must make for itself, and having made it, it distinguishes malformed-truncated from well-formed-and-empty and emits the same deduplicated diagnostic. The check has to be written; the signal shape is not a new one.
Wiring clause. A reason that exists only inside an envelope no caller reads is not a delivered signal — it is an unreachable field. An in-band adopter MUST also expose the cause on the surface its callers actually use. loadConfig, the thin wrapper over loadConfigResolved, returns .config alone to roughly thirty call sites; adding reason to the envelope without a diagnostic on that path leaves every one of them exactly as blind as before.
Caller audit is mandatory per applier. Implemented as specified, neither mechanism can break a caller — no return type changes. The audit exists to prove the applier did implement it as specified, which is a different claim. The concrete hazard: src/state.cts carries a comment recording that a defensive try/catch around getMilestoneInfo was deliberately removed under the #2245 audit because that function "never throws". An applier who reaches for a throw here — the intuitive fix, and the one this amendment rules out — silently breaks that invariant. Each applying PR records its caller audit for that reason.
CI ratchet. scripts/lint-resolution-provenance.cjs's REGISTRY currently holds one verb (agent-skills). The config-loader seam is not registered, so nothing today would catch a regression of #1880's contract. #1880 registers it.
Test methodology. Assert the typed surface, not the diagnostic prose — CONTRIBUTING.md's Prohibited: Raw Text Matching on Test Outputs applies to stderr as much as to stdout, and tests/roadmap-parser.test.cjs already states the local convention for this call surface. Where the mechanism's only observable is a diagnostic, the applier exposes the typed surface (a frozen reason enum, or the dedup set) and asserts on that.
First appliers: #1880 (in-band), #1881 / #1882 / #1883 (out-of-band), #1884 (genuinely-fatal carve-out, already throwing) — epic #1879, Phase 0 = #2674.
Amendment — 2026-09-17: per-key provenance, and one encoder at the CLI boundary
This ADR's ConfigResolution { config, source, degraded, reason } is whole-config provenance: it
says which layer supplied the object, and says nothing about where any individual key inside that
object came from after merging. Epic #4633 is the bill for that silence. Four confirmed-bug issues (all four closed as duplicates of the epic on 2026-09-11)
divide into two halves of one pipeline — resolve a value across layers, then hand it to a caller —
and both halves are implemented more than once, so a consumer that needs the producing layer either
re-derives it from contents (which this ADR's Decision 2 forbids) or discards it.
This amendment is the design lock for that epic (Phase 0 = #4671). It changes no code. It fixes the owner, the result type, the family boundaries, the encoding contract and the child boundaries so that the implementation children can be reviewed against a written contract rather than against each other. It stays in this file rather than opening a second ADR because per-key provenance is the same decision as Decision 2 applied one level down — a separate ADR would split ownership of one rule.
What is actually live on next
Measured on next at c9a5cc3e1, in a hermetic fixture (HOME redirected, .planning/config.json
authored per case), not inferred from the issue text. The epic's own framing is corrected in two
places, and the corrections make the seam argument stronger, not weaker.
| Absorbed | Claim | Measured on next |
|---|---|---|
| #4071 | ~/.msd/defaults.json is dropped wholesale when a project config exists |
Live. With a project config, research:false / model_profile:"quality" from the global file do not survive (research:true, model_profile:"balanced"); the file is only read by Branch D, which requires no .planning/ at all. _warnShadowedGlobalDefaults (#3532) prints that this happened — the defect is announced, not fixed |
| #4090 | The evaluator discards which layer produced worktree.baseRef |
Live. resolveEffectiveBaseRef returns string | null. A user/global head reaches evaluateWorktreeBaseDegrade as the bare string "head", indistinguishable from a project-local one |
| #4262 | config-get --default emits a JSON string, not the JSON value |
Live. --default '[]' → "[]", --default 5 → "5", --default true → "true", --default null → "null" |
| #4382 | config-get without --raw double-encodes an already-JSON value |
Not reproducible as stated. A present array emits a real JSON array, not a string containing JSON text. #4382's own reproduction uses --default, so it reports the same defect as #4262 from the consumer side. There is one encoding defect, on the --default arm only |
Two further facts the epic does not state, both of which constrain the design:
--defaultis already correct under--raw.--raw --default '[]'prints[]and--raw --default 5prints5, because the raw arm emits the argv string verbatim. The disagreement is confined to the JSON arm, where the same argv string is JSON-encoded as a string. This is why the remedy is a parser at the boundary and not a change tooutput().--rawcannot round-trip today, for present values, by construction.output()'s raw arm isString(value). Measured:[]→ the empty string,[{path,depth}]→[object Object],["codex","gemini"]→codex,gemini,200000→200000(indistinguishable from"200000"),project_code:"007"→007(decodes back as the number7). See Decision 4.
Decision 1 — one resolution owner, and it returns the producing layer
A new leaf module, the Config Value Resolution Module (src/config-value-resolver.cts), owns
per-key precedence for every configuration family. It is a new module rather than a function grafted
onto config-loader.cts because the two answer different questions with different return types —
"assemble the effective config object" versus "resolve one key and say which layer produced it" —
and because the anti-divergence rule of Decision 5 needs a boundary it can name.
type ConfigLayer =
| 'workstream' | 'root' | 'global-defaults' | 'schema-default' | 'builtin-default' // family A
| 'runtime-local' | 'runtime-shared' | 'runtime-user'; // family B
interface ConfigValueResolution {
found: boolean;
value: unknown; // `undefined` if and only if `found === false`
layer: ConfigLayer | null; // `null` if and only if `found === false`
reason: ConfigReason; // config-loader's frozen CONFIG_REASON — not a second vocabulary
composite?: Readonly<Record<string, ConfigLayer>>; // per-leaf layer; present only for merged keys
}
function resolveConfigValue(key: string, opts: { cwd: string; family?: ConfigFamily }): ConfigValueResolution;
- Absence is
{ found:false, value:undefined, layer:null, reason:'not_configured' }. It is never represented by a falsy value, because a falsy value is a legitimate answer (below). - Unusable input reuses
CONFIG_REASON.CONFIG_UNPARSEABLE/CONFIG_UNREADABLEand the deduplicated diagnostic introduced by the 2026-07-26 amendment. A corrupt layer does not silently become an absent one, and the resolver introduces no new reason vocabulary —CONFIG_REASONis already frozen, andtests/config-loader.test.cjsalready pins its values as the wire contract. - Layer-file access belongs to this boundary. The resolver obtains each layer from
config-loader.cts's existing per-layer read (_readConfigFile, promoted to an internal export), so "who may open a config layer file" and "who may decide precedence" are the same two modules. Every other module becomes a reader of a resolution, never a participant in one. - Traversal is own-property only. Dotted paths walk with
Object.prototype.hasOwnProperty.call, ascmdConfigGetalready does, so__proto__/constructorresolve as absent rather than to inherited values. - Key eligibility is preserved, not widened. The resolver honours the existing
VALID_CONFIG_KEYS/DYNAMIC_KEY_PATTERNS/ federated-schema eligibility exactly as today. The epic's non-goals forbid expandingGLOBAL_DEFAULTS_RESOLUTION_KEYSor revisiting theloadConfigwhitelist, and this amendment does neither.
loadConfig / loadConfigResolved keep their callers (the epic counts 45 direct call sites) and their shape. The owner is additive at
introduction; adoption and deletion are Decision 5's job.
Decision 2 — two families, declared separately, with no invented precedence between them
The epic speaks of "layers" as though there were one ladder. There are two, they are read from different files by different code for different consumers, and merging their orderings would invent a precedence nobody decided.
| Family | Layers (highest first) | Read from |
|---|---|---|
| A — MSD project configuration | workstream → root → global-defaults → schema-default → builtin-default |
planningDir(cwd)/config.json, planningRoot(cwd)/config.json, ~/.msd/defaults.json, federated/capability configSchema default, CONFIG_DEFAULTS |
| B — runtime harness settings | runtime-local → runtime-shared → runtime-user |
<cwd>/.claude/settings.local.json, <cwd>/.claude/settings.json, <userClaudeDir>/settings.json |
A key belongs to exactly one family, declared in one table in the resolver. worktree.baseRef is
family B despite its dotted, MSD-looking name — the value #4090 is about never appears in
.planning/config.json. No ordering is declared between A and B; a caller that needs both asks
twice and composes the answer itself, visibly.
Normalization. normalizeLegacyKeys runs per layer, before precedence, exactly where
config-loader runs it today. Family B parses with parseJsonc, as resolveEffectiveBaseRef does.
Normalization never crosses layers and never rewrites a file — the resolver is a read path and
passes persist:false semantics all the way down.
Explicitly falsy values are configured values. false, 0, "", [], {} and a JSON null
present in a layer all resolve found:true with that value and that layer. Only a key absent from
every layer is found:false. This makes #4071's mechanism unrepresentable only when every key is
correctly classified into its resolution family: a bad declaration can reintroduce the failure as a
classification defect. Today's _globalBaseCfg uses ?? and || against 26 named keys, so a
global false or 0 survives one branch and not another, and "is this key set?" is answered by the
truthiness of its value in four different places.
Composite values. Some keys are objects assembled from more than one layer (effort,
model_overrides, agent_tools, agent_skills). Merge-eligibility is declared per key in the
resolver, not decided at a call site by whether a file happens to exist — which is precisely #4071's
mechanism, where the existence of any project config suppresses the whole global file. For a
declared-merge key:
layernames the highest layer that contributed at least one leaf;compositemaps each leaf key to the layer that produced it, at whatever depth the declaration says the merge is deep (effort.agent_overridesandeffort.routing_tier_defaultsare per-key deep today, per #3531 — that behaviour is preserved and becomes a declaration);- a consumer that branches on origin reads
composite[leaf]. It never re-derives provenance by re-reading a layer file, which is this ADR's Decision 2 restated for leaves.
Every other key replaces: the highest layer that has it wins outright.
Decision 3 — one parser and one encoder own the CLI boundary
cmdConfigSet already contains the only value parser at the config CLI boundary: true/false → boolean,
null → JSON null, a finite Number(val) → number, a leading [/{ → JSON.parse with
fall-through to string, everything else → string. It is extracted verbatim as
parseConfigArgValue(raw: string): unknown and shared, unchanged, with config-get --default.
Extracted, not copied — a second copy would be the defect this epic exists to close.
nullparses to the valuenull. Unsetting is a setter action, not a parse result. The two are conflated today only becausecmdConfigSethappens to branch onparsedValue === nullafter parsing. That branch stays in the setter, where it is a documented "clear" action (#2046). The getter's--default nullmust emit JSONnulland unset nothing. Any child that moves the unset decision into the parser has broken this contract.- Per-key setter policy stays in the setter.
project_codere-readingvalverbatim so"007"does not collapse to7, and everyassertEnumValue/ range validator, are setter key policies. The shared parser is type-directed only. - One encoder.
encodeConfigValue(value, { raw })is the single emission path for every arm ofcmdConfigGet: present key, root-inherited key (#2702),--default, schema default (#2256), and the masked-secret arm. Today those arms reachoutput()through two different helpers, and the--defaultone hands it a value of the wrong type. - Masking stays ahead of encoding.
isSecretKey(kp)→maskSecret(value)→ encoder. The mask yields a string and is encoded as one; the encoder never sees the plaintext.emitResolvedDefaultalready established this for the schema-default arm and it becomes the rule for all of them. - Both consumer kinds are audited by the children, not assumed. Structured consumers:
msd-core/workflows/code-review.md(workflow.code_review_depth_overrides --default '[]'),ship.md(ship.pr_body_sections --default '[]'),plan-review-convergence.md(review.default_reviewers),pr-branch.md(planning.sub_repos). Shell-string consumers:src/review-lane-invocation.cts'sconfigString(), which treats the literal four charactersnullas "unset" — a live contract on raw output — and theconfig-get … --rawcommand strings thatsrc/runtime-artifact-conversion.ctsbakes into generated runtime artifacts.
Decision 4 — the round-trip property is asserted in JSON mode; raw output is frozen (ruled 2026-09-20)
The epic asks for decode(encode(v)) === v "across --raw / --default / neither". Measured, that
property cannot hold in raw mode while raw means String(value): [] and "" both print
empty, [{…}] prints [object Object], and 5 and "5" are the same three bytes. The epic's
requirement is therefore split rather than silently weakened:
- JSON mode (no
--raw) — the property holds for every supported type and every layer, including the--defaultand schema-default arms once they route through the parser. This is the property test the epic asks for, and the cell it is currently red in is--default. - Raw mode — the contract is a display and shell-interpolation contract, and it is frozen.
String(value)rendering is preserved byte-for-byte and pinned by tests. Raw makes no round-trip claim, including for scalars:5and"5"collide, and"007"can be decoded as the number7.
Making raw lossless — emitting compact JSON for non-scalars, say — is a user-visible output-contract
change to a surface that live consumers parse by hand (configString() above; four workflow call
sites; baked artifact command strings). It is out of scope for #4633, and needs its own issue and its
own approval. No child may change raw's non-scalar rendering "while it is in there".
Ruled: freeze --raw as the display/shell-interpolation contract described above. A lossless
--raw would need its own issue before any child that touches raw output is planned; none is filed.
Decision 5 — migration census, child boundaries, and the anti-divergence guard
Census. Every site that reads a configuration layer file directly or re-implements precedence,
found by path-construction search over src/, bin/, hooks/, scripts/ at c9a5cc3e1.
Precedence implementations to delete or reduce to delegation:
| Site | What it re-implements |
|---|---|
src/capability-activation.cts resolveConfigKey |
A full four-level walk (loadConfig result → workstream file → root file → schema default) returning {found, value} with no layer. The closest thing to the epic's owner that already exists, and the reason the owner must absorb these rather than become another walk beside them |
src/config.cts resolveFromRootConfig + resolveSchemaDefault |
cmdConfigGet's own workstream→root→schema cascade (#2702, #2256) |
src/config-loader.cts _globalBaseCfg (Branch D) |
The 26-key ?? / || projection of ~/.msd/defaults.json — #4071's site |
src/config.cts buildNewProjectConfig |
A project-creation merge of ~/.msd/defaults.json |
src/install-model-override-resolver.cts |
Install-time global+project merges (model_overrides, agent_tools, runtime/profile) |
src/install-effort-resolver.cts |
Install-time effort merge, deep per sub-field |
src/worktree-base-ref.cts resolveEffectiveBaseRef |
Family B's three-layer cascade, returning a bare string | null — #4090's site |
src/model-resolver.cts projectExplicitlySetsOmit |
A workstream→root two-layer walk over config.json for resolve_model_ids, re-implementing loadConfig's precedence order by hand specifically to avoid its normalization side effects |
Single-layer readers that bypass precedence entirely (each reads one file and therefore silently
ignores root inheritance, global defaults and schema defaults): src/estimate-cli.cts
(workflow.smart_zone_tokens), src/gap-checker.cts (workflow.post_planning_gaps),
src/check-command-router.cts (readWorkflowConfig), src/runtime-slash.cts (runtime),
src/verify.cts (workflow.drift_threshold), src/phase.cts (workflow.auto_prune_state),
hooks/msd-agent-isolation-guard.js and hooks/msd-cursor-subagent-start.js (both read
~/.msd/defaults.json directly), src/init.cts's readConfigJsonBoolean/readConfigJsonValue
(single-layer reads of the workstream-aware planning dir only — no root fallback, no schema
default). runtime-slash.cts records its reason for bypassing loadConfig —
the normalize-and-write-back side effect — and that reason expired when persist:false shipped
(#3648); a child adopting the owner there must delete the stale comment with the code.
Excluded, with reasons: src/config.cts setters, src/capability-writer.cts's pre-write parse
check and src/planning-snapshot.cts's snapshot field are writes and probes, not resolutions;
hooks/msd-config-reload.js and hooks/msd-context-monitor.js watch the file for change
detection and display. None of them may grow a merge or a precedence branch; the guard below still
watches them.
Child boundaries. Each is its own issue, opened and approved separately — this Phase-0 child's
type: chore authorizes none of them.
| Child | Scope | Epic criterion it closes |
|---|---|---|
| C1 | Introduce the owner: module, typed result, family declarations, fast-check layer × type matrix over (absent/present) × (array, object, number, boolean, string, null) asserting value and layer; register the verb in scripts/lint-resolution-provenance.cjs's REGISTRY. No call sites change |
"resolver's return type carries the producing layer"; "layer × type matrix" |
| C2 | The encoding boundary: extract parseConfigArgValue, share it with --default, route every cmdConfigGet arm through one encoder, freeze raw. Failing-first regressions for #4262 and #4382 |
"one encoder owns --raw / --default / default output"; "encoder round-trip property"; "/msd-code-review runs in a project that has set no config keys at all" |
| C3 | Family A adoption + deletion: the six family-A precedence implementations above reduced to delegation or deleted, then the single-layer readers. Failing-first regression for #4071 | "one implementation of layer precedence; the other merge paths deleted" |
| C4 | Family B adoption: resolveEffectiveBaseRef returns a resolution, evaluateWorktreeBaseDegrade branches on layer instead of on the bare value. Failing-first regression for #4090 |
"consumers that branch on it read it rather than re-infer it" |
| C5 | The ratchet: local/no-adhoc-config-merge, allowlist drained to empty, and a deliberately reintroduced copy demonstrated going red |
"local/no-adhoc-config-merge runs with an empty allowlist" |
The epic's remaining criterion — a failing-first regression per absorbed issue — is carried by the child that owns each symptom: #4262 and #4382 in C2, #4071 in C3, #4090 in C4. Each writes the test first, watches it fail on the unfixed tree, and says so in its PR.
The guard (eslint-rules/no-adhoc-config-merge.cjs). Follows the no-adhoc-markdown-parsing /
no-unconfined-path-join pattern already in the tree: a rule plus a JSON allowlist validated by
scripts/lib/allowlist-ratchet.cjs, which fails on stale entries so the list can only shrink. It
flags two shapes: (a) constructing a layer-file path — path.join(…, '.msd', 'defaults.json'),
path.join(planningDir(…)|planningRoot(…), 'config.json'), path.join(…, '.claude', 'settings*.json')
— outside the resolver boundary; (b) spread-merging two parsed config objects.
Two exemption kinds, kept apart in the file and not interchangeable:
- Resolver-internal, permanent:
src/config-value-resolver.ctsandsrc/config-loader.ctsare the boundary and are exempt by construction. - Temporary migration, each carrying its adopting child's issue number, each removed by that child. C5 lands only when the temporary section is empty. Write and probe sites (the exclusions above) are exempted by rule shape — they parse or stat without merging, so they never match — rather than by allowlist entry, so the list is not padded with entries that can never drain.
Validation. Hermetic fixtures with a redirected HOME and an authored .planning/ tree, plus
the real published entry points (msd-core/bin/msd-tools.cjs, bin/install.js — the installer
already imports both install-time resolvers), never a mocked CLI. C5 additionally demonstrates the
guard failing: reintroduce one deleted merge, watch the rule go red, remove it again. A guard
that has never been observed red is an assumption.
The #4588 boundary, restated
#4588 separately investigates what a current Claude Code harness actually honours when it forks a
worktree. This amendment delivers provenance — the evaluator will know that a head came from
the user/global layer. It does not assert what the harness does with that fact. C4 preserves
today's isolationMode semantics (#3659, #48) unless #4588 changes them; if #4588's finding and the
epic's expected evaluator behaviour conflict, that is resolved with the maintainer before C4 locks
an assertion, not inside C4.
Limits of this amendment
- No runtime code, configuration behaviour or generated artifact changes here. Every symptom in #4071, #4090, #4262 and #4382 stays live until C2–C4 land.
- The child issues do not exist yet; C1–C5 are proposed boundaries, and each needs its own approval.
- Decision 4 freezes raw output (ruled 2026-09-20). A lossless raw mode would reverse a decision recorded here and needs its own issue before C2 is planned.