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.
409 lines
37 KiB
Markdown
409 lines
37 KiB
Markdown
# 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:
|
||
|
||
- **`loadConfig` is a 9-patch `try/catch` ladder** (#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 canonical `findProjectRoot` (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:
|
||
|
||
1. **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.
|
||
2. **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.
|
||
3. **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** — `loadConfig` exposes a `ConfigResolution { config, source: 'workstream' | 'root' | 'global-defaults' | 'builtin-defaults', degraded: boolean }`. Introduced additively (`loadConfigResolved`) so the ~16 existing `loadConfig` call sites, SDK parity, and the generated `.cjs` are unaffected until they opt in.
|
||
- **Project-Root Resolution Module** — absorbs the nearest-`.planning/` ancestor as a first-class heuristic; `resolvePlanningCwd` and 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-rolled `warnings[]` 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 additive `loadConfigResolved`; 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 `resolvePlanningCwd` and local `AgentSkillsReason`/`AgentSkillsDiagnostics` are 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-skills` is the first adopter — the `value` field is added additively to its `--json` IR with the flat fields retained for back-compat; `source`/`degraded` remain 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 expose `warnings[]` + `errors[]`; `configured`/`reason` appear 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`'s `ConfigResolution { config, source, degraded }` is the first adopter (#1880): genuine absence keeps `degraded:false`; an unusable config sets `degraded:true` and adds a `reason`. `Resolution<T>` (`src/resolution.cts`) has **no** value for this case today — its documented vocabulary is `resolved` / `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 `stderr` diagnostic** naming the file and the errno. This covers `getRoadmapPhaseInternal` (#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 — and `extractFrontmatter` (#1882), which returns `{}`. The repo's existing seam is `config-loader.cts`'s `_warnedUnknownConfigKeys` guard around `process.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 `_warnedUnknownConfigKeys` precedent 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:
|
||
|
||
- **`--default` is already correct under `--raw`.** `--raw --default '[]'` prints `[]` and
|
||
`--raw --default 5` prints `5`, 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 to `output()`.
|
||
- **`--raw` cannot round-trip today, for present values, by construction.** `output()`'s raw arm is
|
||
`String(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 number `7`). 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.
|
||
|
||
```ts
|
||
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_UNREADABLE` and 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_REASON` is
|
||
already frozen, and `tests/config-loader.test.cjs` already 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`,
|
||
as `cmdConfigGet` already does, so `__proto__` / `constructor` resolve 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 expanding `GLOBAL_DEFAULTS_RESOLUTION_KEYS` or revisiting the `loadConfig`
|
||
whitelist, 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:
|
||
|
||
- `layer` names the highest layer that contributed at least one leaf;
|
||
- `composite` maps each leaf key to the layer that produced it, at whatever depth the declaration
|
||
says the merge is deep (`effort.agent_overrides` and `effort.routing_tier_defaults` are 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.
|
||
|
||
- **`null` parses to the value `null`. Unsetting is a setter action, not a parse result.** The two are
|
||
conflated today only because `cmdConfigSet` happens to branch on `parsedValue === null` *after*
|
||
parsing. That branch stays in the setter, where it is a documented "clear" action (#2046). The
|
||
getter's `--default null` must emit JSON `null` and 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_code` re-reading `val` verbatim so `"007"`
|
||
does not collapse to `7`, and every `assertEnumValue` / 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 of
|
||
`cmdConfigGet`: present key, root-inherited key (#2702), `--default`, schema default (#2256), and
|
||
the masked-secret arm. Today those arms reach `output()` through two different helpers, and the
|
||
`--default` one 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. `emitResolvedDefault`
|
||
already 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`'s `configString()`, which treats the literal four characters
|
||
`null` as "unset" — a live contract on raw output — and the `config-get … --raw` command strings
|
||
that `src/runtime-artifact-conversion.cts` bakes 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:
|
||
|
||
1. **JSON mode (no `--raw`) — the property holds for every supported type and every layer**, including
|
||
the `--default` and 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`.
|
||
2. **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: `5` and `"5"` collide, and `"007"` can be decoded as the number `7`.
|
||
|
||
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.cts` and `src/config-loader.cts` are
|
||
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.
|