<?xml version="1.0" encoding="UTF-8"?>
<spec xmlns="https://vibevm.org/spec/1">
  <title id="root">PROP-022 — Materialization modes</title>
  <status stage="impl" state="done" comment="C 2026-07-25: the Materialization system ships (enum + doctests + install machinery + destructive guard); motivation, rejected and out-of-scope facts stay spec-stage; fact grain 2026-07-24"/>
  <p p="1"><fact id="status-line" status="impl/done">**Status: IMPLEMENTED** (specified 2026-06-24 in an owner-requested design
session; verified against the tree 2026-07-25 by the spec-actualization
campaign). The `Materialization` enum ships with `Copy` as the default (wire `copy`; named `Snapshot`/`snapshot` until the 2026-08-13 terminology ruling),
`InPlace` beside it and a doctested `is_in_place` (`vibe-core` `package.rs`); the
hardlink / in-place machinery runs through `vibe-install` (`plan.rs` /
`fetched.rs` / `apply.rs`, the `materialise_in_place` seam); submodule
snapshot-embedding lands in `git_package_registry/fetch.rs`; and the destructive
guard sits in `commands/uninstall.rs`. R1 successor `6d606ef2`/`1cf4f189`
reconciles `copy`/`hardlink` refresh through the strict slot record without
touching unrecorded outputs. One of four orthogonal specs from the
bridge-packages design (siblings:
[PROP-020](PROP-020-install-hooks.xml) install hooks,
[PROP-021](../vibe-registry/PROP-021-submodule-sources.xml) submodule sources,
[PROP-023](../vibe-registry/PROP-023-bridge-packages.xml) bridge packages).
Materialization mode is a property of *any* package; a huge git package wants
`in-place` with no bridge in sight.</fact></p>
  <p p="2"><fact id="related" status="spec/done">**Related:** [PROP-009](PROP-009-loading-model.xml) (the materialise step into
`vibedeps/`), [PROP-007](PROP-007-workspace.xml) (`vibedeps/` layout),
[PROP-010](../vibe-registry/PROP-010-local-package-cache.xml) (the live-git
cache + `.git`-stripped content tree the copy modes draw from),
[PROP-019 §2.15](../../common/PROP-019-version-manager.xml#instances) (the VVM
`placer` diff-copy/hardlink — direct prior art for `hardlink`),
[PROP-019 §2.16](../../common/PROP-019-version-manager.xml#provenance) (VVM
"sources by reference" — prior art for `in-place`),
[PROP-020 §2.1](PROP-020-install-hooks.xml#phases) (hook-edit reset rides on the
mode).</fact></p>
  <section id="motivation" title="1. Motivation">
    <section id="problem" title="1.1 The problem — one materialisation policy does not fit every package">
      <list ordered="false" p="3">
        <item><fact id="today-single-policy" status="impl/done">Today every package is materialised the same way: clone into the live-git
  cache, strip `.git` into a content tree, then **full recursive copy** of that tree
  into the `vibedeps/&lt;group&gt;.&lt;name&gt;/&lt;version&gt;/` slot (identity-keyed — owner ruling 2026-08-13; the slot carried `&lt;kind&gt;-&lt;name&gt;` before that ruling, which collided same-named packages of different groups and moved a package on a kind change).</fact></item>
        <item><fact id="snapshot-right-for-ordinary" status="impl/done">This is right for ordinary
  packages and gives the lockfile a stable `content_hash` and a committable,
  offline-reproducible vendored slot.</fact></item>
      </list>
      <p p="4"><fact id="two-scales-lead" status="spec/done">It fails at two different scales of "big":</fact></p>
      <list ordered="true" p="5">
        <item><fact id="BIG-IN-BYTES" status="spec/done">**Big in bytes** — a package with a few large binary assets pays a full byte
   copy per install/update of data that did not change.</fact></item>
        <item><fact id="BIG-IN-FILE-COUNT" status="spec/done">**Big in file count** — a package with *millions* of small files (think the
   Chromium source tree) is killed not by bytes but by **per-file syscalls**:
   copying — or even hardlinking — five million files takes hours, barely
   faster than re-fetching over the network. The full-tree walk (`content_hash`
   reads every file too) is itself the cost.</fact></item>
      </list>
      <p p="6"><fact id="FIX-DECLARED-MODE" status="impl/done">The fix is to make materialisation a **declared mode** on the package, and to
borrow the two cost-avoidance primitives VVM already proved
([PROP-019 §2.15/§2.16](../../common/PROP-019-version-manager.xml#instances)).</fact></p>
    </section>
  </section>
  <section id="decisions" title="2. Decisions">
    <section id="modes" title="2.1 Three modes, declared in the descriptor">
      <p p="7"><fact id="req-modes" status="impl/done">`req r1`</fact></p>
      <p p="8"><fact id="MODE-FIELD" status="impl/done" action="continue" actionstage="doc" audience="author">`[package].materialization` selects how the package lands on disk:</fact></p>
      <fence lang="toml" p="9">[package]
materialization = "copy"       # default | "hardlink" | "in-place"</fence>
      <list ordered="false" p="10">
        <item><fact id="SNAPSHOT-DEFAULT" status="impl/done" action="continue" actionstage="doc" audience="author">`copy` is the default and the only mode an ordinary package needs. (It was named `snapshot` until the owner's 2026-08-13 terminology ruling reserved that word for the unfrozen version — PROP-044 §2b; the legacy spelling is refused with the rename recipe, never aliased.)</fact></item>
        <item><fact id="MODE-IN-DESCRIPTOR" status="impl/done">The
  mode is published in the descriptor so a consumer sees, before installing, how
  a package will be placed.</fact></item>
      </list>
    </section>
    <section id="snapshot" title="2.2 `copy` — the vendored full copy (default)">
      <p p="11"><fact id="req-snapshot" status="impl/done">`req r1`</fact></p>
      <list ordered="false" p="12">
        <item><fact id="SNAPSHOT-PIPELINE" status="impl/done">Initial placement is live-git cache → `.git`-stripped shippable tree → full recursive copy into the slot. Refresh is record-aware: the incoming tree is reconciled against `.vibe-slot.toml` rather than replacing the directory.</fact></item>
        <item><fact id="STRIP-EXTENDED" status="impl/done">[PROP-024 §2.2](../../common/PROP-024-code-bearing-packages.xml#shippable-tree)
  extends the `.git` strip to build output (`.vibe/`, `target/`, `node_modules/`,
  `.vibeignore` globs) for code-bearing packages, so such a package vendors its
  source, never its build artifacts.</fact></item>
        <item><fact id="SLOT-SELF-CONTAINED" status="impl/done">The slot is a self-contained tree, identified by
  `content_hash` (§2.5), vendored into the project's git (§2.7).</fact></item>
        <item><fact id="SUBMODULE-EMBEDDED" status="impl/done">Submodule
  content is embedded into the copied tree
  ([PROP-021 §2.3](../vibe-registry/PROP-021-submodule-sources.xml#snapshot-embedding)).</fact></item>
        <item><fact id="HOOK-RESET-REMATERIALISE" status="impl/done">For `copy` and `hardlink` slots, update/reinstall restores the materialiser-owned payload by diffing the incoming shippable tree against `.vibe-slot.toml`: changed owned files are replaced, stale owned files are removed, equal files stay untouched, and unrecorded build output is preserved. Hooks rerun exactly when that materialisation diff is nonempty, per PROP-020 §2.1.</fact></item>
      </list>
    </section>
    <section id="hardlink" title="2.3 `hardlink` — per-file hardlink, copy on change">
      <p p="13"><fact id="req-hardlink" status="impl/done">`req r1`</fact></p>
      <list ordered="false" p="14">
        <item><fact id="HARDLINK-MODE" status="impl/done">For packages **big in bytes but modest in file count**. Initial placement may hardlink source/cache files into the slot, falling back to copy when linking is unavailable. Refresh uses the common `.vibe-slot.toml` path→SHA-256 footprint: equal files are left in place and a changed destination is unlinked before its replacement is staged, so writing a slot can never mutate the source/cache inode through a hardlink.</fact></item>
        <item><fact id="HARDLINK-FALLBACK" status="impl/done">A hardlink that fails
  (cross-volume / unsupported filesystem) falls back to copy.</fact></item>
        <item><fact id="HARDLINK-CONTRACT" status="impl/done">The slot still presents a full shippable tree and its source identity remains `content_hash`. The slot record is reconciliation and integrity metadata, not a second source identity; unrecorded paths such as `target/` remain outside the materialiser-owned footprint.</fact></item>
        <item><fact id="HARDLINK-NOT-GIANT" status="impl/done">This mode does **not** help the file-count case — the per-file
  syscall remains — so it is not the giant-repo answer (§2.4 is).</fact></item>
      </list>
    </section>
    <section id="in-place" title="2.4 `in-place` — git-native, project-local, no copy">
      <p p="15"><fact id="req-in-place" status="impl/done">`req r1`</fact></p>
      <p p="16"><fact id="IN-PLACE-LEAD" status="impl/done">For packages **big in file count** (and incidentally bytes), where even one
full tree walk is unacceptable. vibevm never walks the tree:</fact></p>
      <list ordered="false" p="17">
        <item><fact id="IP-CLONE-DIRECT" status="impl/done">**`git clone --recurse-submodules` lands directly in the slot**, bypassing
  both the cache clone and the snapshot copy — **one** physical copy on the
  machine, not three (decisive when disk cannot hold several copies of a giant).</fact></item>
        <item><fact id="IP-GIT-MANAGES" status="impl/done">**git manages it in place**: update is `git fetch` + checkout (incremental,
  touches only changed objects/files); a hook's edits are reset with
  `git clean -dfx` in the slot.</fact></item>
        <item><fact id="IP-PROJECT-LOCAL" status="impl/done">**Project-local, never shared.** Each project gets its own clone in its own
  `vibedeps/`; there is deliberately no cross-project sharing, which removes
  the concurrent-mutation problem a shared global clone would create.</fact></item>
        <item><fact id="IP-UNVERSIONED-PATH" status="impl/done">**The slot path is not version-qualified.** An `in-place` slot is
  `vibedeps/&lt;group&gt;.&lt;name&gt;/` (no `/&lt;version&gt;/`): one working clone whose version
  is the current git ref. Versioning the path would mean two on-disk copies of
  the giant — the opposite of the goal.</fact></item>
        <item><fact id="IP-REQUIRES-GIT" status="impl/done" action="continue" actionstage="doc" audience="author">**Requires a git source.** Incremental update and `git clean` reset both need
  git; a non-git source has no `in-place` story (§4).</fact></item>
      </list>
    </section>
    <section id="identity" title="2.5 Identity follows the mode">
      <p p="18"><fact id="req-identity" status="impl/done">`req r1`</fact></p>
      <list ordered="false" p="19">
        <item><fact id="ID-COPY-MODES" status="impl/done">**`copy` / `hardlink`** — source identity remains `content_hash`; `.vibe-slot.toml` independently records the owned destination footprint and exact per-file SHA-256 values used for reconciliation and verification.</fact></item>
        <item><fact id="ID-IN-PLACE" status="impl/done">**`in-place`** — **`resolved_commit`**, not `content_hash`. The slot is a
  mutable git working tree (hooks edit it), so a content hash is neither stable
  nor affordable to compute; the git commit *is* the identity, known in O(1).
  The lockfile already records `resolved_commit`, so no new field is needed.</fact></item>
      </list>
    </section>
    <section id="destructive-guard" title="2.6 Destructive operations on an `in-place` slot need confirmation">
      <p p="20"><fact id="req-destructive" status="impl/done">`req r1`</fact></p>
      <list ordered="false" p="21">
        <item><fact id="DESTRUCTIVE-CONFIRM" status="impl/done" action="continue" actionstage="doc" audience="user">An `in-place` slot may be a multi-hour download. Any **destructive** operation
  on it — `uninstall`, `reinstall --force`, a version switch that requires a
  re-clone, or slot removal — must be confirmed: interactively a `y/n`, and in a
  non-interactive run it requires an explicit flag (`--force`) or it **aborts**
  rather than silently deleting an expensive resource.</fact></item>
        <item><fact id="HOOKS-EXEMPT" status="impl/done">**Hooks and their reset
  (`git clean -dfx`) are exempt** — they are routine and trusted (the hook author
  is assumed competent, [PROP-020](PROP-020-install-hooks.xml)).</fact></item>
        <item><fact id="GUARD-PURPOSE" status="impl/done">The guard
  protects against accidental loss, not against the package's own lifecycle.</fact></item>
      </list>
    </section>
    <section id="vendoring" title="2.7 Vendoring differs by mode">
      <p p="22"><fact id="req-vendoring" status="impl/done">`req r1`</fact></p>
      <list ordered="false" p="23">
        <item><fact id="VENDORED-COPY-MODES" status="impl/done" action="continue" actionstage="doc" audience="user">**`copy` / `hardlink`** are vendored — the slot is committed into the
  project's git and is offline-reproducible from it (a `hardlink` slot's bytes
  are materialised into git on `git add` like any file); a `copy` slot trivially so.</fact></item>
        <item><fact id="IN-PLACE-NOT-VENDORED" status="impl/done" action="continue" actionstage="doc" audience="user">**`in-place`** is **not** vendored — the slot (a nested `.git` plus possibly
  millions of files) is `.gitignore`d in the project; restoration is a re-clone
  at the lockfile's `resolved_commit`. The honest trade: `in-place` packages
  need the network to restore, where `snapshot` packages do not.</fact></item>
      </list>
    </section>
  </section>
  <section id="rejected" title="3. Rejected alternatives">
    <list ordered="false" p="24">
      <item><fact id="REJ-HASH-IN-PLACE" status="spec/done">**Content-hashing an `in-place` package** — rejected for the same reason VVM
  rejected hashing distributions ([PROP-019 §9.2](../../common/PROP-019-version-manager.xml#rationale-no-hash)):
  hashing millions of files / gigabytes per operation is prohibitive, and the
  tree is mutable anyway. Identity is the commit (§2.5).</fact></item>
      <item><fact id="REJ-SHARED-GLOBAL-CLONE" status="spec/done">**A shared global `in-place` clone** (like the registry cache) — rejected:
  two projects mutating one giant working tree via hooks would collide;
  project-local clones make the problem disappear (§2.4).</fact></item>
      <item><fact id="REJ-HARDLINK-FOR-GIANTS" status="spec/done">**Hardlink as the giant-repo answer** — rejected: per-file syscalls still
  cost hours at millions of files; only `in-place` (no per-file work) solves
  the file-count axis (§2.4).</fact></item>
      <item><fact id="REJ-REFLINK" status="spec/done">**reflink / CoW placement** — deferred (§4): not portable; hardlink is the
  portable byte-sharing primitive, matching VVM's choice.</fact></item>
    </list>
  </section>
  <section id="out-of-scope" title="4. Out of scope">
    <list ordered="false" p="25">
      <item><fact id="OOS-REFLINK" status="spec/done">**reflink / CoW** placement where the filesystem supports it — far-backlog,
  as in [PROP-019 §6](../../common/PROP-019-version-manager.xml#far-backlog).</fact></item>
      <item><fact id="OOS-NON-GIT-IN-PLACE" status="spec/done">**`in-place` for a non-git source** — needs git for incremental update and
  `git clean` reset; a binary/path source has no `in-place` mode.</fact></item>
      <item><fact id="OOS-CACHE-GC" status="spec/done">**Automatic cache garbage collection** for the live-git cache — owned by
  [PROP-010](../vibe-registry/PROP-010-local-package-cache.xml).</fact></item>
    </list>
  </section>
  <section id="acceptance" title="5. Acceptance">
    <list ordered="false" p="26">
      <item><fact id="ACC-FIELD-PARSES" status="impl/done">`[package].materialization` parses to `copy` (default) / `hardlink` /
  `in-place`; an unknown value is a manifest error, and the legacy `snapshot`
  is refused with the rename recipe.</fact></item>
      <item><fact id="ACC-MODE-BEHAVIORS" status="impl/done">`copy` and `hardlink` reconcile their recorded footprint without touching unrecorded paths; `hardlink` may share initial files with copy fallback and replaces changed destinations without mutating source inodes. `in-place` clones once into an unversioned, `.gitignore`d slot managed by git.</fact></item>
      <item><fact id="ACC-IN-PLACE-IDENTITY" status="impl/done">`in-place` identity is `resolved_commit`; no full-tree hash is computed.</fact></item>
      <item><fact id="ACC-DESTRUCTIVE-GUARD" status="impl/done">A destructive op on an `in-place` slot confirms interactively / requires
  `--force` non-interactively; hooks and `git clean` are exempt.</fact></item>
      <item><fact id="ACC-FLOOR-GREEN" status="impl/done">Full `self-check.sh` green; conform 0/0/0; specmap clean.</fact></item>
    </list>
  </section>
</spec>
