# PROP-022 — Materialization modes {#root}

@status:impl/done

[p01] @fact:status-line **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. @status:impl/done

[p02] @fact:related **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). @status:spec/done

## 1. Motivation {#motivation}

### 1.1 The problem — one materialisation policy does not fit every package {#problem}

- [p03] @fact:today-single-policy 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/<group>.<name>/<version>/` slot (identity-keyed — owner ruling 2026-08-13; the slot carried `<kind>-<name>` before that ruling, which collided same-named packages of different groups and moved a package on a kind change). @status:impl/done
- @fact:snapshot-right-for-ordinary This is right for ordinary
  packages and gives the lockfile a stable `content_hash` and a committable,
  offline-reproducible vendored slot. @status:impl/done

[p04] @fact:two-scales-lead It fails at two different scales of "big": @status:spec/done

1. [p05] @fact:BIG-IN-BYTES **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. @status:spec/done
2. @fact:BIG-IN-FILE-COUNT **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. @status:spec/done

[p06] @fact:FIX-DECLARED-MODE 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)). @status:impl/done

## 2. Decisions {#decisions}

### 2.1 Three modes, declared in the descriptor {#modes}

[p07] @fact:req-modes `req r1` @status:impl/done

[p08] @fact:MODE-FIELD `[package].materialization` selects how the package lands on disk: @status:impl/done

[p09]
```toml
[package]
materialization = "copy"       # default | "hardlink" | "in-place"
```

- [p10] @fact:SNAPSHOT-DEFAULT `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.) @status:impl/done
- @fact:MODE-IN-DESCRIPTOR The
  mode is published in the descriptor so a consumer sees, before installing, how
  a package will be placed. @status:impl/done

### 2.2 `copy` — the vendored full copy (default) {#snapshot}

[p11] @fact:req-snapshot `req r1` @status:impl/done

- [p12] @fact:SNAPSHOT-PIPELINE 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. @status:impl/done
- @fact:STRIP-EXTENDED [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. @status:impl/done
- @fact:SLOT-SELF-CONTAINED The slot is a self-contained tree, identified by
  `content_hash` (§2.5), vendored into the project's git (§2.7). @status:impl/done
- @fact:SUBMODULE-EMBEDDED Submodule
  content is embedded into the copied tree
  ([PROP-021 §2.3](../vibe-registry/PROP-021-submodule-sources.xml#snapshot-embedding)). @status:impl/done
- @fact:HOOK-RESET-REMATERIALISE 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. @status:impl/done

### 2.3 `hardlink` — per-file hardlink, copy on change {#hardlink}

[p13] @fact:req-hardlink `req r1` @status:impl/done

- [p14] @fact:HARDLINK-MODE 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. @status:impl/done
- @fact:HARDLINK-FALLBACK A hardlink that fails
  (cross-volume / unsupported filesystem) falls back to copy. @status:impl/done
- @fact:HARDLINK-CONTRACT 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. @status:impl/done
- @fact:HARDLINK-NOT-GIANT 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). @status:impl/done

### 2.4 `in-place` — git-native, project-local, no copy {#in-place}

[p15] @fact:req-in-place `req r1` @status:impl/done

[p16] @fact:IN-PLACE-LEAD For packages **big in file count** (and incidentally bytes), where even one
full tree walk is unacceptable. vibevm never walks the tree: @status:impl/done

- [p17] @fact:IP-CLONE-DIRECT **`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). @status:impl/done
- @fact:IP-GIT-MANAGES **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. @status:impl/done
- @fact:IP-PROJECT-LOCAL **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. @status:impl/done
- @fact:IP-UNVERSIONED-PATH **The slot path is not version-qualified.** An `in-place` slot is
  `vibedeps/<group>.<name>/` (no `/<version>/`): 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. @status:impl/done
- @fact:IP-REQUIRES-GIT **Requires a git source.** Incremental update and `git clean` reset both need
  git; a non-git source has no `in-place` story (§4). @status:impl/done

### 2.5 Identity follows the mode {#identity}

[p18] @fact:req-identity `req r1` @status:impl/done

- [p19] @fact:ID-COPY-MODES **`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. @status:impl/done
- @fact:ID-IN-PLACE **`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. @status:impl/done

### 2.6 Destructive operations on an `in-place` slot need confirmation {#destructive-guard}

[p20] @fact:req-destructive `req r1` @status:impl/done

- [p21] @fact:DESTRUCTIVE-CONFIRM 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. @status:impl/done
- @fact:HOOKS-EXEMPT **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)). @status:impl/done
- @fact:GUARD-PURPOSE The guard
  protects against accidental loss, not against the package's own lifecycle. @status:impl/done

### 2.7 Vendoring differs by mode {#vendoring}

[p22] @fact:req-vendoring `req r1` @status:impl/done

- [p23] @fact:VENDORED-COPY-MODES **`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. @status:impl/done
- @fact:IN-PLACE-NOT-VENDORED **`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. @status:impl/done

## 3. Rejected alternatives {#rejected}

- [p24] @fact:REJ-HASH-IN-PLACE **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). @status:spec/done
- @fact:REJ-SHARED-GLOBAL-CLONE **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). @status:spec/done
- @fact:REJ-HARDLINK-FOR-GIANTS **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). @status:spec/done
- @fact:REJ-REFLINK **reflink / CoW placement** — deferred (§4): not portable; hardlink is the
  portable byte-sharing primitive, matching VVM's choice. @status:spec/done

## 4. Out of scope {#out-of-scope}

- [p25] @fact:OOS-REFLINK **reflink / CoW** placement where the filesystem supports it — far-backlog,
  as in [PROP-019 §6](../../common/PROP-019-version-manager.xml#far-backlog). @status:spec/done
- @fact:OOS-NON-GIT-IN-PLACE **`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. @status:spec/done
- @fact:OOS-CACHE-GC **Automatic cache garbage collection** for the live-git cache — owned by
  [PROP-010](../vibe-registry/PROP-010-local-package-cache.xml). @status:spec/done

## 5. Acceptance {#acceptance}

- [p26] @fact:ACC-FIELD-PARSES `[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. @status:impl/done
- @fact:ACC-MODE-BEHAVIORS `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. @status:impl/done
- @fact:ACC-IN-PLACE-IDENTITY `in-place` identity is `resolved_commit`; no full-tree hash is computed. @status:impl/done
- @fact:ACC-DESTRUCTIVE-GUARD A destructive op on an `in-place` slot confirms interactively / requires
  `--force` non-interactively; hooks and `git clean` are exempt. @status:impl/done
- @fact:ACC-FLOOR-GREEN Full `self-check.sh` green; conform 0/0/0; specmap clean. @status:impl/done

