# LayerFS CoW Fork

Date: 2026-08-13
Status: Canonical

This specification adds **zero-copy layer fork** and **server-side chain reads** to Drive9 LayerFS. Product surface stays **layer** (`/v1/layers`). Checkpoint, commit, and rollback keep their V1 names and meanings.

Minimize break: without a layer binding, `/v1/fs` stays identical to today.

Builds on:

- [Layered Filesystem V1 Design](./layered-filesystem-v1-design.md)
- [Layered Filesystem Research](./layered-filesystem-research.md)
- [Layered Filesystem Feature Matrix](./layered-filesystem-feature-matrix.md)

---

## 1. Goals and non-goals

### 1.1 Goals

| Capability | Meaning |
| --- | --- |
| **Fast fork** | O(1) metadata child layer from an existing layer. No copy of `file_nodes` or object bodies. |
| **Chain read** | Child view: self → parent@`origin_seq` → … → main, merged on the **server**. |
| **Checkpoint / commit / rollback** | Same verbs as V1. `commit` applies the effective view to main. |
| **Delete + pin GC** | Layers can be deleted. A child's origin pin retains parent entries/objects. |
| **Default flat disk** | No layer attachment ⇒ behavior identical to today. |

### 1.2 Non-goals

- Merge / rebase / three-way merge **between layers**, or commit-into-parent
- `git add` / staging / index
- Versioning main tables with `workspace_id`; do **not** change `file_nodes` / `inodes` / `contents` UNIQUE(path) current-state semantics
- Tenant / serverless DB branch as the share-mode filesystem fork path
- Always-on layer; live layer switch on a mounted FUSE instance
- Defaulting the classic disk to write into an overlay; checkpoints on main
- A real Git object store; cloning vault/journal with an FS fork
- A `publish` product verb instead of `commit`
- Renaming the product surface to **branch** (`fs branch`, `/v1/branches`)
- Renaming tables `fs_layer_*` → `fs_branch_*`
- Snapshot isolation that freezes **main** at fork time (the pin freezes the parent overlay only; see §5.5)

### 1.3 Document role

This file is the implementation contract for CoW fork on the server. Dedicated (standalone) and shared (`fs_id`) tenant schemas must stay in lockstep.

---

## 2. Decisions

1. **Engine.** Evolve today's LayerFS overlay into a linear forkable chain (`parent_layer_id` + `origin_seq`). Do not rebuild a versioned main-table FS.
2. **Name.** The user-visible entity remains **layer**. Tables and HTTP stay `fs_layer_*` / `/v1/layers`.
3. **`create` / `fork`.** `create` opens a root layer on main. `fork` opens a child from a layer (zero-copy pin).
4. **`checkpoint`** is an in-layer history point and does not change main. **`commit`** applies the effective view to main. There is no `publish` verb.
5. **Usage.** Default `/v1/fs` is flat. Layer reads/writes go through `/v1/layers/{ref}/…`.
6. **Share-mode tenant “fork”.** Tenant `POST /v1/fork` on a shared-pool tenant returns **409** and points at `POST /v1/layers/{ref}/fork`.

**Vs Git:** `layer commit` means “apply this layer’s effective view to main”, not `git commit`. Recoverable in-layer points are checkpoints.

### 2.1 Frozen rules

| ID | Topic | Rule |
| --- | --- | --- |
| **D1** | Default fork pin | `origin_seq = MAX(parent.entry_seq)` in the same transaction as `SELECT parent FOR UPDATE`. Do **not** use `fs_layers.durable_seq` (`durable_seq` advances only on checkpoint). |
| **D2** | Checkpoint fork | If `checkpoint_id` is set: checkpoint must belong to the parent; `origin_seq = checkpoint.durable_seq`; store `origin_checkpoint_id`. |
| **D3** | Child-gate | Gates **delete / physical GC** only. Does **not** gate commit. |
| **D4** | Who may commit | Any `active` (or V1-eligible) layer may commit **to main only**. No commit-into-parent. |
| **D5** | Child commit | Flatten the **effective view** before apply. Do not replay only the child's own log. |
| **D6** | Root commit with children | Allowed. Parent entries stay until pins release. |
| **D7** | Isolation | Pin freezes the **parent overlay** (`entry_seq ≤ origin_seq`), not main. Sibling commits into main remain visible via main fallback. |
| **D8** | Chain read | Server-side resolve. Acceptance surface in §5.6. |
| **D9** | Share tenant fork | **409** + text pointing at `POST /v1/layers/{ref}/fork`. No auto-downgrade that writes main. |
| **D10** | Checkpoint mount | `--checkpoint` is **read-only** and must still restore the overlay view. Writable history = `fork --checkpoint`. |
| **D11** | Apply ledger | Not required for this phase. Commit stays V1 preflight + best-effort apply. Optional later hardening. |
| **D12** | Branch branding | Out of scope. |
| **D13** | Forkable parent states | Only `active` or `sealed`. Reject `committing` / `committed` / `abandoned` / `conflicted`. Check under parent `FOR UPDATE`. |
| **D14** | Inheritance | Child **must** inherit `base_root_path` and `durability_mode`. `depth = parent.depth+1`. `root_layer_id` is the chain root. Tags/actor do not inherit by default. |
| **D15** | Depth | Reject `depth > 8` by default; hard cap 16; **409**. |
| **D16** | Sibling roots | Same as today: multiple roots may commit to main; conflicts surface at preflight. No extra base-root lease. |
| **D17** | DELETE lifecycle | This phase: **logical `abandoned` only**. Do not physically delete `fs_layers` rows or `entry_seq ≤ pin` entries while `still_pins(L)`. Physical delete is a later GC worker when `¬still_pins`. |
| **D18** | Commit apply set | `≡ DiffFSLayerVsMain(L)` (effective view − live main). Skip paths that already match main. Remaining same-path drafts keep the write-time/copy-up snapshot for V1 OCC (stale base → 409). Do **not** restamp those claims from live main at commit (that is last-write-wins). Skip rather than reuse ancestor `base_revision` for apply CAS when the path already matches live main. |
| **D19** | Materialize | Replay the chain log **in order** into an in-memory tree (preserve upsert→chmod/rename), then `tree_diff` vs live main. Do **not** treat raw latest-per-path ops as apply input (chmod-only latest drops content). Main-backed rename must remain a real rename (or equivalent copy-up + whiteout), not a dest chmod. |
| **D20** | Hop landed | A dest hop has landed on live main iff its recording layer is `committed` **or** `commit_applied=1` (survives D17 abandon), **or** some applied descendant was pinned through that recording layer with `origin_seq ≥ hop.entry_seq`. Compare `origin_seq` only in the parent it pins. GET remap and commit skip share this predicate. |

---

## 3. Concepts

| Term | Implementation | Meaning |
| --- | --- | --- |
| **main / base** | `file_nodes` / `inodes` / `contents` | Live published current state |
| **layer** | `fs_layers` row + entries | CoW draft over main (or parent@layer pin) |
| **create** | `POST /v1/layers` | Root layer on main |
| **fork** | `POST /v1/layers/{ref}/fork` | Child from a layer tip or checkpoint |
| **checkpoint** | `fs_layer_checkpoints` | Durable in-layer point; does not change main |
| **commit** | `POST …/commit` | Apply effective view to main |
| **rollback** | `POST …/rollback` | Discard this layer (see §5.8 if pinned) |
| **delete** | `DELETE …/layers/{ref}` | Abandon; child-gate / cascade in §5.8 |

No staging: writes belong to the current active layer immediately. Default `status` / `diff` is effective view vs **live main**.

```text
main (live)
  ├── layer exp-a      create :/ --name exp-a
  │     ├── checkpoint cp1, cp2, …
  │     └── layer exp-b   fork from exp-a @ origin_seq
  └── layer exp-c      sibling root on main

Allowed:
  exp-a commit → main          (even if exp-b is still active)
  exp-b commit → main          (flatten effective view → main; not merge into exp-a)
  exp-c commit → main          (parallel with exp-a; conflicts at preflight)

Forbidden:
  exp-b merge/commit → exp-a
  exp-a merge → exp-c
```

---

## 4. Mechanics

```text
POST /v1/layers {base_root_path, name}
  ≈  INSERT fs_layers(..., parent_layer_id='', depth=0, root_layer_id=self)

GET/PUT /v1/layers/{exp-a}/objects?path=
  read:  Resolve(exp-a) chain → main
  write: only exp-a (copy-up / whiteout, §5.7)

POST /v1/layers/{exp-a}/fork {name}
  in one transaction:
    SELECT parent FOR UPDATE
    require parent.state ∈ {active, sealed}
    origin_seq := MAX(entry_seq) of parent          -- tip pin (D1)
    -- or checkpoint_id → origin_seq := cp.durable_seq (D2)
    INSERT child (parent_layer_id, origin_seq, depth=parent.depth+1,
                  root_layer_id=parent.root, base_root_path=parent.base_root, …)
  no entry copy

POST /v1/layers/{L}/commit:
  compute effective view (§5.7)
  preflight vs live main (base_revision CAS, V1 philosophy)
  apply → main; L → committed
  if children exist: do not delete L's entries (pin retains them)
```

Creation is not a full-tree copy. It is lazy per-path CoW plus a chain pin.

---

## 5. Server: data model and contracts

### 5.1 Principles

- Do not change main-table current-state semantics or UNIQUE(path).
- CoW state stays in **`fs_layer_*`**; chain support is **additive**.
- Shared and dedicated schemas stay in lockstep. TiDB and DB9 variants stay in lockstep. `dump-init-sql` (tidb_zero / tidb_cloud_native / db9) is the export source.
- Existing tenants: online `ALTER` plus backfill
  `parent_layer_id=''`, `origin_seq=0`, `depth=0`, `root_layer_id=layer_id`, `origin_checkpoint_id=''`.
  Until an explicit `dump-init-sql` backfill runs, scan-time code treats empty
  `root_layer_id` as `layer_id`.

### 5.2 `fs_layers` additive columns

| Column | Meaning |
| --- | --- |
| `parent_layer_id` | Parent layer; `''` = root |
| `origin_seq` | Child may see parent entries with `entry_seq ≤ origin_seq` |
| `origin_checkpoint_id` | Set on checkpoint pin |
| `root_layer_id` | Chain root (`layer_id` for roots) |
| `depth` | Root = 0; child = parent + 1 |
| `origin` | Audit: `create` \| `fork` |
| `commit_applied` | Set on successful apply to main; kept when DELETE abandons a committed layer (D17/D20) |

Indexes: `(parent_layer_id, state)`; `(root_layer_id)` as needed.

Do **not**: cross-tenant parents; materialize entries at fork; add merge-target columns.

### 5.3 Other tables

| Table | Change |
| --- | --- |
| `fs_layer_entries` | No schema change; chain uses existing `*AtSeq` |
| `fs_layer_checkpoints` | Unchanged |
| `fs_layer_events` | Keep. Child FUSE watchers only need **self** (ancestor pin is immutable) |
| `fs_layer_tags` | Keep |
| apply ledger | Optional later; not a product verb |

### 5.4 Fork

In one transaction:

1. `SELECT … FROM fs_layers WHERE layer_id=? FOR UPDATE` (parent).
2. State ∈ {`active`,`sealed`} or **409**.
3. Same tenant; `depth+1` ≤ 8 (hard 16) or **409**.
4. **Tip pin:**
   `origin_seq` = latest committed parent `entry_seq`
   (`ORDER BY entry_seq DESC LIMIT 1 FOR UPDATE`; empty → 0)
   — **not** `fs_layers.durable_seq`. A snapshot `MAX()` after the
   parent-row lock is not a current read in TiDB pessimistic txns.
5. **Checkpoint pin:** checkpoint belongs to parent; `origin_seq = checkpoint.durable_seq`; set `origin_checkpoint_id`.
6. Child inherits `base_root_path` and `durability_mode`; `depth` / `root_layer_id` per D14; `state=active`.
7. Do not require advancing parent `durable_seq`.

Concurrency: same parent-row lock as `UpsertFSLayerEntry` serializes fork vs parent writes. Parent entries after the pin are invisible to that child (intended).

Auth: write on the parent `base_root_path`. Scoped-token allowlist includes `…/fork`, `…/chain`, `DELETE …/layers`.

### 5.5 Isolation (user-facing)

| Guaranteed | Not guaranteed |
| --- | --- |
| Child does not see parent overlay entries written **after** the fork (`entry_seq > origin_seq`) | A snapshot of **main** at fork time |
| Pinned parent overlay stays readable under pin GC | After another layer commits to main, those changes appear via main fallback |
| Same-path upper entry / whiteout hides lower layers and main | A hard-isolated sandbox (would need a future main snapshot) |

**Fork pin freezes the parent overlay sheet, not the published book.**

### 5.6 Chain resolve

Reads must use a **folded** effective view (ordered replay), not raw latest-per-path. A later `chmod` on the same path must not hide the prior upsert body.

#### 5.6.1 Resolve

```text
Resolve(path, layerID):
  materialize overlay tree for the chain (tip optionally capped by max_seq)
  if path is whiteout: return WHITEOUT
  if path is present:  return HIT(folded entry)
  return MISS → read main(path)
```

Tip limit is current tip (or checkpoint `durable_seq`). Each ancestor A is limited by the direct child's `origin_seq`.

Parentless layers with no `rename` / `whiteout` / `chmod` drafts may resolve via
an indexed path-hash point lookup plus main fallback (same HIT/WHITEOUT/MISS
outcomes). Any view-affecting draft, or a parented chain, must still materialize.

**ListDir:** union names top-down; whiteouts remove names; then merge with main readdir.

#### 5.6.2 Required chain-aware entry points

| Entry | Required |
| --- | --- |
| FUSE restore / lookup / readdir / open (`--layer`) | Yes (server API or shared datastore merge). Checkpoint mounts stay read-only **and** still restore. |
| `GET …/layers/{ref}/entries` and single-path entry/stat | Yes |
| layer **diff / status** (default vs main) | Yes: effective view − main |
| layer object/content GET | Yes. Whiteout → **404**. |
| search overlay | Should walk the chain |
| WebDAV + layer | Remain V1 unsupported |

Implement `ResolveFSLayerPath` / merged list / overlay materialize in datastore/server. **Do not** assemble the chain with client-side multi-hop RPC.

Ancestor entries with `entry_seq ≤ origin_seq` are immutable and cacheable as `(layer_id, origin_seq, path)`. FUSE events subscribe to **self** only.

#### 5.6.3 diff / status

- **HTTP `GET …/diff` on a parented layer:** folded effective overlay entries
  (self ∪ ancestors@origin_seq). It does **not** subtract live main.
- **HTTP `GET …/diff` and `GET …/entries?path=` on a parentless root
  (V1 continuity):** default listing / dest lookup stay latest-per-path
  (`MAX(entry_seq)` per path). `replay=1` / `mode=log` returns the ordered
  log. Object GET still uses the folded view (or the upsert-only
  point-lookup equivalent), so a later `chmod` does not hide an upsert body
  on read.
- **Commit apply set (D18):** overlay − live main.
- Optional later: `vs=parent` (self vs parent@origin_seq), and a `/diff` mode
  that matches the commit apply set.

### 5.7 Writes, copy-up, commit

#### 5.7.1 Live writes

- Only the **top active** layer is writable.
- `mkdir` (or `upsert` with `kind=dir`) of a path that already exists in the
  layer view — overlay node or visible live main — is **409**. A later rename
  of that directory must be a main-backed rename draft, not an overlay-local
  mkdir+whiteout (which cannot commit a non-empty directory).
- Edit of a path whose visible body is **main**: copy-up records main `base_inode_id` + `base_revision` (V1).
- Dest-space write (path under an uncommitted ancestor rename dest) stamps that remapped live-main identity, not the raw dest path. A client `base_revision` is checked against that remapped identity at write time. V1: this remap applies to inline and single-shot object upserts. Multipart `uploads/initiate` stats the raw dest path, so a dest-space multipart entry is claim-less.
- Write of an exact uncommitted rename dest (`mv a b; write b`) does not attach a dest-path claim; OCC stays on the source whiteout.
- Edit of a path whose visible body is an **ancestor entry**: copy content into this layer; main claim = **current main revision** if the path exists on main, else `base_revision=0`.
- Delete: whiteout on this layer.

Optional live-write CAS matches V1. Skip is allowed when the source is an ancestor and main has no such path. **Commit preflight compares live main to the write-time/copy-up snapshot** (V1 OCC). D18 only skip-if-equal; it does not restamp claims from live main.

#### 5.7.2 Commit → main

1. Layer must be allowed to enter V1 `committing` (usually `active`).
2. Children do **not** block commit.
3. **Materialize (D19):**
   ```text
   tree = empty
   for A in chain_from_root_to_L:
     limit = tip(A) if A == L else origin_seq of the direct child on this chain
     for op in ListFSLayerEntryLogAtSeq(A, limit) in entry_seq order:
       apply_op_to_tree(tree, op)    # V1 upsert/chmod/rename/whiteout
   plan = tree_diff(tree, live_main)
   ```
   - **Root, no parent:** today's ordered log replay is allowed **iff** it matches `tree_diff`. Tests must cover upsert→chmod and rename.
   - **Has parent:** must materialize the chain. Must not replay only the child log. Must not apply raw latest-per-path rows. **Main-backed rename** must emit a real `rename` (or copy-up dest + whiteout src), never dest-as-chmod that then skips.
4. **Apply set (D18) = that plan:**
   - Paths equal to live main → **skip** (parent already committed, child unchanged → empty apply / success).
   - Symlinks compare target text.
   - Claims are the copy-up snapshot from the overlay draft (write time). If live main revision/inode differs, preflight returns 409 and the layer becomes `conflicted`. Do not restamp from live main.
   - V1: copy-up/whiteout claims do not follow a path across an ancestor directory rename in the same apply set. An external write to a descendant the layer also moved is last-write-wins at the post-move path. File hops (`mv a b`) keep the source claim.
   - V1: a child write of a parent's uncommitted file rename dest, recorded before that hop lands, has no dest-path claim. After the hop lands, commit may 409 `base path exists`. Record the write after the hop lands, or commit the child first.
   - V1: file upserts bind `base_inode_id` + `base_revision` in the apply write transaction. Whiteout/chmod/rename re-check identity then mutate; a replacement that lands in that window is not CAS-deleted. Dest-space write-time CAS is checked at fill. Commit re-checks that claim once the ancestor hop has already landed; if the same apply set also carries the hop, the dest upsert is touch-suppressed and is last-write-wins.
5. Apply → main; `committed` (or `conflicted` on failure, V1). On apply or finalize failure, rollback the pre-apply snapshot (this is not `POST …/rollback`): relocate `ours` inodes to their snapshotted paths first (one move per rescan, shallower homes first, temp hop for occupancy cycles, no-replace rename). Relocate never deletes a foreign occupant of the home path; if the original cannot be homed without clobbering, rollback fails (5xx). Then restore. Same-inode file restore is FileID-bound. A foreign occupant is left. Missing files are create-if-absent (a racing create is left). Created FileIDs are those returned by the apply write/mkdir/symlink transaction, not a later live stat. Rollback delete of a created file is `DELETE ... file_id = expected`; an empty created directory is deleted only if it still names that inode; directory relocate uses `RenameDirNoReplace` so an existing dest, including an empty one, is conflict rather than replacement. The no-replace file and directory moves also bind the expected source FileID/inode ID in the same transaction after locking the old path, so a replacement at `from` is not relocated. Directory extras are pruned only when the live child's FileID was created by this apply (rename-source trees, including nested dirs). A tree-walk `ReadDir` error fails the snapshot and the commit never applies. Rename-tree descendants are metadata-only (path/FileID/mode); file bodies are snapshotted only for upsert/whiteout/symlink apply entries. V1: an `ours` inode that cannot be relocated without a body snapshot fails rollback rather than synthesizing empty content.
6. Do not delete this layer's entries on commit if `still_pins`.

Apply order: creates/renames shallow-first; whiteouts deep-first (so directory deletes see an empty tree).

Forbidden: commit target = parent layer; layer merge.

### 5.8 Delete, rollback, pin GC

#### 5.8.1 Shared predicates

```text
exists_row(X) := fs_layers still has X (including abandoned/committed audit rows)

descendants(L) := { X | exists_row(X) ∧ X reaches L via parent_layer_id }

still_pins(L) := exists X in descendants(L) such that
  X.state ∈ {active, sealed, committing, conflicted}
  ∨ (X.state ∈ {abandoned, committed} ∧ still_pins(X))

# Conservative implementation is also valid:
# still_pins(L) := descendants(L) is non-empty

pin_seq(P) := MAX(C.origin_seq) over direct children C with exists_row(C)
              (no children ⇒ no pin)
```

`abandoned` / `committed` do **not** mean “ancestors may drop the chain”. If an active grandchild still walks through an abandoned middle layer, keep the ancestor row and `entry_seq ≤ pin_seq` entries.

`Resolve(D)` on `P → C(abandoned) → D(active)` must still read `P@C.origin_seq`.

#### 5.8.2 `DELETE /v1/layers/{ref}`

| Case | Behavior |
| --- | --- |
| `still_pins(ref)` | Default **409** (list blocking descendants). `?cascade=true`: DFS logical abandon of the subtree, then self. |
| `¬still_pins(ref)` | `state → abandoned` only. Do not physically delete the row in this phase. |
| Physical row / `entry_seq ≤ pin` delete | GC worker only, when `¬still_pins` |

Object bodies live as long as some non-GC'd layer entry still references the `storage_ref`.

#### 5.8.3 Rollback

- `¬still_pins`: V1 → `abandoned`; may enqueue cleanup.
- `still_pins`: → `abandoned`, **keep** `entry_seq ≤ pin_seq` entries and the row. Do not truncate a pinned prefix.
- Later `rollback --to <checkpoint>`: `active` only; `to_seq < pin_seq` → **409**.

#### 5.8.4 GC worker (minimum)

Only after `abandoned` or `committed` (per policy):

| Object | Condition |
| --- | --- |
| entries with `entry_seq > pin_seq(L)` | Layer no longer actively written, **or** no pin |
| entries with `entry_seq ≤ pin_seq(L)` | Only `¬still_pins(L)` |
| `fs_layers` row | Only `¬still_pins(L)` and retention policy allows |
| storage objects | No remaining entry references |

Never delete an **active** layer's post-fork writes because `entry_seq > pin_seq`. After all descendants are gone, ancestor pin release must be testable.

### 5.9 V1 mapping

| V1 API | This spec |
| --- | --- |
| `POST …/checkpoints` | Unchanged; advances that layer's `durable_seq` |
| `POST …/commit` | To main; any layer; flatten if parented |
| `POST …/rollback` | §5.8.3 |
| Single-layer read | Root unchanged; **child must use the chain** |

`committed` / `abandoned` cannot be mounted writable. `--checkpoint` is read-only and still restores the overlay.

---

## 6. HTTP

### 6.1 Compatibility

- No layer binding: **`/v1/fs` unchanged**.
- Keep `/v1/layers`. Responses may add `parent_layer_id`, `origin_seq`, `depth`, `root_layer_id` (old clients ignore them).
- Do **not** add `/v1/branches`.

### 6.2 Endpoints

| Endpoint | Notes |
| --- | --- |
| `POST /v1/layers` | Create root; unchanged |
| `POST /v1/layers/{ref}/fork` | New. Body: `name?`, `checkpoint_id?` |
| `GET /v1/layers/{ref}/chain` | New. Root→tip: `layer_id, name, state, parent_layer_id, origin_seq, origin_checkpoint_id, depth, root_layer_id, base_root_path, created_at` |
| `DELETE /v1/layers/{ref}?cascade=` | New. §5.8.2 |
| `GET …/entries` and object reads | Chain-aware (§5.6) |
| `POST …/checkpoints\|commit\|rollback` | Paths unchanged; semantics §5.7–5.8 |
| List layers | Include parent fields |

### 6.3 Tenant `POST /v1/fork`

- Dedicated + branch-capable: unchanged.
- Share / non-branch: **409** pointing at `POST /v1/layers/{ref}/fork` (or create then fork).
- The server does not auto-create a layer.

---

## 7. HTTP API

### 7.1 Endpoints

```http
POST /v1/layers
POST /v1/layers/{ref}/fork
GET  /v1/layers/{ref}/chain
GET  /v1/layers/{ref}
GET  /v1/layers/{ref}/diff
GET  /v1/layers/{ref}/entries?path=
GET  /v1/layers/{ref}/objects?path=
POST /v1/layers/{ref}/entries
POST /v1/layers/{ref}/checkpoints
POST /v1/layers/{ref}/commit
POST /v1/layers/{ref}/rollback
DELETE /v1/layers/{ref}
DELETE /v1/layers/{ref}?cascade=1
```

### 7.2 create vs fork

| Call | Meaning |
| --- | --- |
| `POST /v1/layers` with `base_root_path` | Parent empty; covers main |
| `POST /v1/layers/{ref}/fork` | Tip-pin child |
| `POST /v1/layers/{ref}/fork` with `checkpoint_id` | Checkpoint pin |

### 7.3 Object reads

| Case | Behavior |
| --- | --- |
| Overlay hit | Return layer/chain entry (whiteout → 404) |
| Overlay miss | Fall back to live main (D7) |
| Remapped source | Re-check read scope on the resolved main path before serving bytes, unless that dest hop has already landed (D20). The same check applies to parented `GET …/entries?path=` when the folded entry names a rename source other than the request path. A source-side entry GET that names an out-of-scope dest redacts `content_text`. |
| `max_seq` | Caps only the tip layer; ancestors stay pin-limited |

### 7.4 Before commit

- Main is unchanged; only the layer view shows drafts.
- **`commit` writes main.** `rollback` / `delete` drop this layer under pin rules.

---

## 8. Relation to V1 LayerFS

| V1 | This spec |
| --- | --- |
| Verbs | Kept. Chain commit = effective view → main |
| Single layer | Plus fork chain |
| Workspace / publish / branch rename | Not done |
| Commit-into-parent | Not done |
| Share tenant fork | 409 → layer fork |

---

## 9. Phasing

| Phase | Scope |
| --- | --- |
| **This release** | Additive schema (standalone + shared + db9); tip/checkpoint fork; server chain resolve (entries + objects + diff); any-layer commit + child flatten; delete/cascade + pin rules; rollback keeps pins; share 409; HTTP API e2e; tests in §9.1 |
| **Follow-up** | Apply ledger / failpoint hardening; ancestor pin cache; `schema dump-init-sql` export refresh; AGFS single-layer lookup parity |
| **Later** | ctx-attached fork; rollback-to-checkpoint; `diff vs=parent`; squash (depth valve, not merge); branch alias if ever desired |

### 9.1 Required tests

1. Create root → write **without** checkpoint → tip fork → child sees all parent entries (D1).
2. Parent writes more → child does **not** see post-fork parent entries.
3. Child writes → commit child → main updated (D4/D5/D18).
4. Parent commit succeeds while a child is active; child still reads the pin prefix (D3/D6).
5. After parent commit, child commit with no extra edits → success / empty apply (D18).
6. Parent `upsert` + `chmod` same path → fork → child GET/commit keeps **body and mode** (D19).
7. Sibling root commits: same conflict behavior as V1.
8. Delete with pinned descendants → 409; `?cascade=1` succeeds; abandoned middle + active grandchild → DELETE ancestor 409 (D17).
9. Rollback parent with pinned child → parent abandoned, child still reads pin prefix.
10. depth > 8 → 409.
11. Share tenant fork → 409 pointing at layer fork.
12. Fork from `committed`/`abandoned` → 409; fork with `checkpoint_id` is writable; object GET with `max_seq` is the restored view.
13. Search overlay walks the chain (or an explicit known-gap, never a silent miss treated as pass).
14. Root ordered replay ≡ tree_diff materialize (upsert→chmod, rename).
15. Main-backed rename on a child commit: dest exists with content, src gone.
16. Directory whiteouts apply children-before-parent.

---

## 10. Risks

| Risk | Mitigation |
| --- | --- |
| Chain-read amplification | Depth cap; server merge; ancestor pin cache; self-only events |
| Pinning `durable_seq` by mistake | Code and tests use `MAX(entry_seq)`; test #1 |
| Non-atomic commit | Match V1; optional later ledger |
| Users treating fork as a main snapshot | Document §5.5 |
| Flatten bugs | Overlay tests; rename + chmod cases |
| Pin leak / mistaken GC | `pin_seq`; tests #8/#9 |
| Schema drift | Standalone/shared/db9 stay in lockstep; `dump-init-sql` including `tidb_cloud_native_shared` + drift tests |

---

## 11. Open (does not block this release)

1. Whether search overlay is a hard guarantee vs documented best-effort.
2. Whether to ever alias layer → branch in HTTP.
3. Whether a future phase should snapshot **main** at fork time.
4. UX copy when a child commit is a no-op because the parent already landed.

---

## 12. Summary

LayerFS grows a tip-pinned linear CoW chain: `create` on main, `fork` for children. The pin freezes the parent overlay, not main. Reads merge on the server. Any layer may `commit` to main; the apply set is the ordered effective view minus live main. `still_pins` is transitive; delete is logical abandon. Share tenant fork is 409 toward layer fork. Classic flat-disk paths stay unchanged.
