Files
msd-core/docs/adr/3574-install-materialization-primitives.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
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.
2026-10-06 01:47:40 +02:00

21 KiB

ADR-3574: Install materialization shares primitives, not one writer

Context

Epic #2866 Phase 6 was scoped on the premise that "materialize this layout" is implemented three times and skipped once, and that the remedy is to extract the preserve → prune → stage → copy → restore choreography into one module with the three sites becoming callers.

That premise was measured against the tree on 2026-08-16, after Phase 5 (#2874) landed. It does not hold. The three sites overlap in shape and diverge in mechanism:

step installRuntimeArtifacts
src/install-engine.cts:770-958
applySurface
src/surface.cts:359-452
agent loop
bin/install.js:11120+
preserve snapshot-based, skills kind only (_snapshotDir) none none
prune _removeMsdEntries, prefix-scoped wipe pruneSkillDirs — allow-list, never wipes stale msd-* unlink
stage copies straight into dest temp dir first, then syncs inline transform, no staging dir
restore _restoreDir the snapshot none none

The divergence is deliberate on at least one side. applySurface's prune is allow-list precisely so that it structurally cannot delete a user's files — its own doc comment ties that to the #2973/#3664 user-directory-preserving fix. installRuntimeArtifacts instead wipes a prefix-scoped set and restores a snapshot of the one user-owned directory it knows about.

A single writer must pick one of these. Forcing applySurface onto snapshot-restore would replace a design that cannot lose user files with one that deletes them and puts them back — trading a structural guarantee for a procedural one. Forcing installRuntimeArtifacts onto temp-staging adds a staging hop it does not need.

Two further premises of the original scoping are stale:

  • The agents bypass is shrinking, not static. _DESCRIPTOR_AGENTS_RUNTIMES (bin/install.js:11152) already routes ten runtimes — cursor, windsurf, augment, trae, codebuddy, copilot, antigravity, qwen, kimi, zcode — through the descriptor. The inline _hostBehaviors() dispatch survives only for codex, cline, hermes and generic runtimes.
  • The duplication comment is stale in the opposite direction. It lives at src/runtime-artifact-layout.cts:277-297 (not the range #2875 cites) and reads: "That duplication is deliberate until the second layout.kinds consumer — applySurface … — mirrors the legacy agent pipeline." That condition has partly been met, via agentCtx / stageAgentsForRuntimeWithConverter in applySurface.

Separately and independently, #1874-F19 is confirmed real: preserveUserArtifacts (src/install-engine.cts:168-179) builds an in-memory Map<string,string> via readFileSync, and restoreUserArtifacts (:187-195) writes it back. Nothing touches disk in between. Any process death between the intervening wipe and the restore loses the content outright, at four call sites (install-engine.cts:633, :705; bin/install.js:8658, :11056).

Decision

1. There will be no single materializer module

The three choreographies stay distinct. This ADR explicitly declines Phase 6's first acceptance criterion as written — "One module writes a Layout; the three former call sites delegate to it" — because satisfying it requires breaking one of two mechanisms that are each correct for their own caller.

Recording the refusal is the point: the next reader who notices three similar-looking loops should find this file rather than re-derive the extraction and rediscover the conflict.

2. What IS extracted: durable user-artifact staging (F19)

preserveUserArtifacts / restoreUserArtifacts move to a shared module and stage to a durable on-disk path before any wipe, reusing copyPreservingSymlink (src/installer-migrations.cts:166-177) — a pure two-argument function with no migration-specific state, already used by the backup-and-remove migration action in exactly this copy-strictly-before-delete order.

copyPreservingSymlink is the correct primitive for a second reason beyond durability: it never dereferences a symlink target. Its own doc comment records why — dereferencing could copy the bytes behind a link like ~/.ssh/id_rsa into the backup tree. A hand-rolled copyFileSync here would reintroduce that.

The journal / backupRoot / runId scaffolding around it is migration-specific and is not extracted. Only the primitive is shared.

3. What IS extracted: the retired-kind prune

pruneRetiredRuntimeArtifacts is already called by both installRuntimeArtifacts and applySurface with the same intent. That is genuine shared behavior rather than parallel evolution, and it is the one step where a single owner costs nothing.

4. The agents bypass is closed on its own terms

Removing the inline _hostBehaviors() agent dispatch so the descriptor is authoritative for every runtime is independent of the prune question and proceeds regardless. It is the part of Phase 6 whose evidence survived scrutiny intact, and ten runtimes have already made the trip.

5. Placement and content ownership are unchanged

Per ADR-3660, placement knowledge outside runtime-artifact-layout is drift; the extracted primitives consume Layout, never re-derive it. Per ADR-1508, "Layout owns placement; this module owns content", and the conversion module imports nothing upward. The primitives sit downstream of both and introduce no upward dependency.

Per ADR-58, these primitives are on the adapter side of the pure-policy/thin-adapter split — they execute IO. Phase 5 routed that IO through an injectable seam and established that the write-confinement decisions (hasExistingSymlinkBetween, assertDestWithinConfigHome) stay outside the adapter, so a fake cannot certify an install the real filesystem would refuse. The extraction must not relocate those decisions.

What this ADR does not decide

  • Whether the three choreographies ever unify. The applySurface descriptor-agents migration is partly landed; when it completes, the shapes may converge enough that the question is worth reopening on evidence. Revisit then, not before — and not by re-deriving the extraction this file declines.
  • The prune model itself. Whether allow-list or prefix-scoped-wipe is the better default across the installer is a real question and a separate one. Nothing here endorses either as canonical.
  • USER_OWNED_ARTIFACTS' membership. #2875 names USER-PROFILE.md as at risk; that could not be confirmed in this codebase state — dev-preferences.md is confirmed at three of four call sites. The implementing phase must enumerate the list rather than inherit the claim.

Consequences

  • Phase 6's acceptance criterion 1 is not met as written, deliberately. #2875 needs a scope update to match this decision before implementation. That is the cost of having measured the premise instead of executing it.
  • Three loops that look duplicated remain, now with a recorded reason. Future reviews should treat this file, not the loops, as the answer.
  • The agents bypass closes; the runtime-artifact-layout.cts:277-297 comment is rewritten rather than deleted, because its "deliberate until X" framing is stale in a way a plain deletion would not capture.
  • F19's durability fix lands here rather than in #1874, per maintainer direction on #2875. #1874's F5, F6 and F18 are untouched.
  • The regression test for F19 must inject the crash window by monkeypatching the fs method and restoring in a finally — never via chmod/permission tricks, which root bypasses, yielding a test that passes with zero coverage in root Docker and CI.

Alternatives considered

  1. One materializer, three callers delegate — Phase 6 as originally scoped. Rejected on the measured evidence above: it forces either applySurface off its non-wiping prune or installRuntimeArtifacts into unnecessary temp-staging.
  2. One materializer with a preservation-strategy parameter. Rejected. It preserves both behaviors but makes the module own two concepts and defer the choice to its callers — the exact widening ADR-2866 warns future reviews to resist, and a strategy flag is how a shared module becomes two modules wearing one name.
  3. Do nothing; leave F19 to #1874. Rejected. This phase rewrites that precise choreography, so landing the durability fix elsewhere means two conflicting passes over the same code.
  4. Delete the duplication comment as no longer true. Rejected. It is not simply false — it is stale in a specific, informative way, and its "deliberate until X" condition is now partly met. A rewrite carries that; a deletion loses it.

A note on the evidence

Blast-radius figures for this seam are not reliable and were not used to justify anything above. get_impact on installRuntimeArtifacts resolved to a same-named test helper (tests/adapter-declarative-equivalence.test.cjs:52) and reported zero affected — the same name-collision failure mode that produced a misleading clean radius during #3544. applySurface returned CRITICAL / 184+ from one tool and LOW / 0 from another, disambiguating to two different in-file matches of the same name. The decision above rests on read code, not on those numbers.

References

  • Epic: #2866; this phase: #2875; this ADR: #3574
  • Durability finding: #1874-F19 (and its closed child #1878 — do not re-file)
  • Placement seam: ADR-3660 · content seam: ADR-1508 · policy/adapter split: ADR-58
  • The epic's own frame: ADR-2866, which mandated that this module owe its own ADR
  • User-directory preservation this ADR protects: #2973, #3664

Amendment (2026-08-17, #2875): four factual claims corrected by implementation

Implementing this ADR as Phase 6 disproved four of the statements it rests on. The central decision — §1, no single materializer — is unaffected and stands; the divergence table that justified it was measured correctly. What follows corrects the surrounding claims, because a reader who acts on them will be misled.

This is the same failure mode the ADR itself warns about in "A note on the evidence": conclusions reached by reading code without executing it. Three of the four corrections below are cases where inspection produced a confident, wrong answer.

1. §Decision 3 is void — the retired-kind prune already had a single owner

The ADR says the prune "is extracted" and is "already called by both installRuntimeArtifacts and applySurface". Measured: pruneRetiredRuntimeArtifacts already lives alone in src/retired-artifact-cleanup.cts, already exports a single function, already routes every fs call through installFs(), and has three callers — installRuntimeArtifacts, uninstallRuntimeArtifacts and applySurface.

There was nothing to extract. No refactor was invented to satisfy this decision. A future reader should treat §3 as already-satisfied, not as outstanding work.

2. The agents-bypass runtime set was wrong, and §Decision 4 was the hardest part, not the easiest

The ADR states the inline dispatch "survives only for codex, cline, hermes and generic runtimes", and calls closing it "the part of Phase 6 whose evidence survived scrutiny intact".

Both are wrong. _DESCRIPTOR_AGENTS_RUNTIMES (bin/install.js) held ten runtimes; every other runtime reached the inline loop — seven of them: claude (the flagship), cline, codex, hermes, kilo, opencode and kimi-code. (pi is excluded separately by its pluginOnlyInstall branch.)

Even this correction undercounted. It originally said six. kimi-code was found only when a golden install-tree fixture went red mid-implementation — not by any amount of reading. That is the third time this phase's enumeration was short (four call sites → seven; six runtimes → seven), and every miss shares one cause: counting by symbol or set membership when the thing that matters is a behavior. Fixtures and executed tests found what inspection did not.

The set and the loop are both gone as of this phase; the descriptor is authoritative for agents on every runtime, so there is no longer an allow-list to join.

Worse, closing the bypass could not be done "on its own terms". It required three new pieces of descriptor contract, because the descriptor pipeline had no per-agent resolution context:

gap consumer
a frontmatter-extensions step (effort, disallowedTools) claude
per-agent model-override resolution threaded to the converter kilo, opencode
a named branding converter (the data was already declared; the converter was not) hermes

Every one of those failed silently if migrated without the contract work — wrong bytes, nothing thrown. §Decision 4's framing as independent and low-risk should not be relied on.

3. Three of the four blockers in runtime-artifact-layout.cts were already stale

The ADR treats that comment's blocker list as current. Measured, only one was:

blocker status
Copilot's .agent.md filename rename stale — #2099 dropped the ternary; the suffix comes from hostBehaviors.agentFileExtension
cross-cutting path-prefix rewrite + attribution stale — stageAgentsForRuntimeWithConverter already does both when agentCtx is present
stale-file cleanup stale — _removeMsdEntries prunes more broadly than the loop's extension-gated check
config-reading steps real — and it was the entire remaining gap (see §2 above)

4. F19 is seven call sites, not four — and the helper was the wrong thing to search for

The ADR names four call sites, found by locating callers of preserveUserArtifacts. There are seven. Three of them never call the helper at all; they open-code the same readFileSync → wipe → writeFileSync.

The generalizable lesson: the defect is the pattern "user data held only in memory across a wipe", not the helper. Searching for callers of the helper under-counts by construction. The three extra sites were found by sweeping for the pattern — a read shortly before a wipe and a write shortly after.

The ADR also understates the severity. The worst site is the mainline install path, where the window spans the entire msd-core tree rebuild inside copyWithPathReplacement, not a single rmSync. Any interruption of a normal install destroys the file.

5. Resolved: USER_OWNED_ARTIFACTS

"What this ADR does not decide" records its membership as unconfirmable. It is confirmed: src/install-engine.cts defines it as exactly ['USER-PROFILE.md'], with a docblock recording the invariant that a file is either manifest-tracked distribution or a preserved user artifact, never both (#2771). dev-preferences.md is preserved at other sites by explicit name. That open question is closed.

§Decision 2 directs reusing it, and that is still the right primitive for the reason given (it never dereferences a symlink). But it used raw fs for all five of its calls, while its new caller sits on the install path Phase 5 routed through an injectable seam. Verbatim reuse would have punched a hole through that seam — the partial-adapter trap install-fs-adapter.cts documents. It was routed through installFs() as part of the extraction; its existing migration caller is unaffected, since the ambient default resolves to real fs.

A caution for anyone extending this module: that same fall-through is a live hazard. A missing method on an injected adapter does not fail loudly — it silently reaches the real filesystem. Adding a new installFs() call to a routed path without extending every adapter is a real-IO bug that passes typecheck.

Amendment (2026-08-18, #2866 complete): reconciled against the shipped environment

Epic #2866 is finished — phases 0-7 all closed. This ADR was written mid-epic and describes a tree that no longer exists in three material ways. Reconciled below against the code as merged.

1. There are now TWO choreographies, not three

The Context section's table compares three: installRuntimeArtifacts, applySurface, and bin/install.js's agent-staging loop. The third no longer exists. Phase 6 (#2875) deleted that loop and its _DESCRIPTOR_AGENTS_RUNTIMES gate outright; every runtime now materializes agents from its capability descriptor. All that survives at the old site is a comment recording the deletion.

So §Decision 1's refusal — "there will be no single materializer" — now governs a two-way divergence, not a three-way one.

2. This ADR's own revisit condition has been MET, and revisiting does not change the answer

"What this ADR does not decide" said:

Whether the three choreographies ever unify. The applySurface descriptor-agents migration is partly landed; when it completes, the shapes may converge enough that the question is worth reopening on evidence. Revisit then, not before.

That migration completed in Phase 6. Revisited, on evidence:

The shapes did not converge on the axis that mattered. The refusal rested on the prune, and the prune is untouched by phases 6 and 7 — Phase 6 changed agent materialization, Phase 7 removed exports. applySurface still prunes through pruneSkillDirs, described in its own source as "the single point of truth", allow-list scoped so it structurally cannot delete a user's file; the sibling branch still carries the note that "the unscoped prune deleted user-owned command files". installRuntimeArtifacts still wipes a prefix-scoped set and restores a snapshot.

A single writer would still have to give up one of those guarantees. §Decision 1 stands, now for a narrower and better-evidenced reason: not "three loops differ" but "two loops hold incompatible guarantees about user data, and one of them cannot lose it by construction."

Phase 6 strengthened rather than weakened that reasoning. Implementing #1874-F19 found seven call sites holding user files in memory across a wipe, not the four recorded — a design that cannot delete user files is worth more than one that promises to put them back.

3. Delivery status of each decision

§ Decision Status
1 No single materializer Stands — see above; now a two-way, not three-way, refusal
2 Durable user-artifact staging (F19) Delivered in #2875 — src/user-artifact-staging.cts, seven call sites, recovery wired into both install and uninstall
3 Extract the retired-kind prune Was already true when measured; nothing was extracted, and no refactor was invented to satisfy it
4 Close the agents bypass Delivered in #2875 — but it was the hardest part, not the independent one this ADR predicted
5 Placement/content ownership unchanged Holds — ADR-3660 and ADR-1508 seams intact

4. What #2875's AC1 still says

The issue text still reads "One module writes a Layout; the three former call sites delegate to it." That criterion is deliberately unmet, and now doubly stale: there are no longer three call sites. Anyone reconciling the tracker should treat this ADR as the governing decision and #2875's AC1 as superseded, not outstanding.

Phase 7 (#2876) took bin/install.js from 197 exports to 127, retiring 9 dead names and 61 pass-throughs. Any future work in this area should reach the extracted modules through their own interfaces; the installer no longer re-exports them. See ADR-1508's 2026-08-17 amendment for why that compatibility spine existed and why it turned out to have no production consumer.