# In-Memory File System — decision log

The Phase 5 answer key. Every entry is a question an interviewer actually asks ("why is that a
separate class?") and the answer that earns the mark: **what change the seam anticipates, and what
the change would cost without it.**

Eight classes, none over ~110 lines. `InMemoryFileSystem` holds the *sequence* of an operation —
ask, decide, commit — and no rule that a requirement could reach.

| File | One reason to change |
|---|---|
| `InMemoryFileSystem` | the *sequence* of one of the eight operations changes |
| `PathSyntax` | what makes a path well-formed changes |
| `PathResolver` | never — it is a seam, not a policy |
| `TreeResolver` | how the tree is walked or mutated changes |
| `Node`, `FileNode`, `DirectoryNode` | what a node is made of changes |
| `Nodes` | never — see below |

---

## Seam 1 · The tree, not a flat map — this is the whole problem

**The plausible wrong answer** is `Map<String, Node>` keyed by the full path string. It looks
simpler for a while: `write` and `read` are one map operation each, and `exists` is
`map.containsKey`. It falls apart on exactly the three operations this problem is built to
demand:

- **`ls`** needs every key with a given prefix and no further `/` in it — a scan of the whole map,
  every time, and a class of off-by-one bugs in deciding where the prefix ends.
- **`mv` of a directory** needs every key with the old prefix rewritten to the new one — again a
  full scan, and now a write to every one of them, when nothing about most of those files actually
  changed.
- **recursive `delete`** needs every key with that prefix removed — a third full scan, and the
  only way to discover "does this directory have children" is yet another one.

**With a real tree, all three are free.** `ls` is `DirectoryNode.childNames()` — the directory
already only has its own children. `mv` reassigns one child reference in the destination's parent
and removes one child reference from the source's parent; nothing under either one is touched,
walked, or even looked at, because a subtree is reachable through its root and moving the root is
moving the subtree. `delete` is the same removal; the rest of the subtree becomes unreachable and
is reclaimed by the garbage collector, which is what "recursive" costs when a directory's children
are actually stored *inside* it rather than reconstructed from string prefixes.

This is the single decision the whole design serves, and it is worth being able to say the sizes
out loud: the map is O(everything in the file system) for `ls`, `mv` of a directory, and
recursive `delete`; the tree is O(what that one directory or file actually has).

## Seam 2 · `PathSyntax` — is this a legal sentence, kept apart from what it points at

**Anticipates:** any change to what counts as a well-formed path — a reserved character, a length
limit, case-folding. None of those are asked for here, and that is the point: nothing in
`TreeResolver` would need to change if one were.

`segments(path)` answers exactly one question — is this string absolute, and does it split into
non-empty names — before anything asks what, if anything, exists there. Folding this into
`TreeResolver.get` would mean every one of its early returns is doing two different jobs at once:
"is this well-formed" and "does this exist," which is exactly the distinction the contract's
exception hierarchy depends on (`IllegalArgumentException` for the first, a `FileSystemException`
for the second). Keeping them apart is what makes that distinction free rather than a discipline
someone has to remember at every call site.

## Seam 3 · `PathResolver` — the storage and lookup abstraction, and the reason the curveballs were cheap

**Anticipates:** anything that changes what "resolve a path" means, as opposed to what an
operation does with what it found. This is the seam the syllabus calls out by name (**B5**), and
it is also the decomposition the problem statement asks for directly: resolution, the tree, and
the eight operations are three different jobs, and this interface is the boundary between the
first two and the third.

**The decision worth defending: none of the eight operations in `InMemoryFileSystem` ever holds a
`Node` reference it found by walking children itself.** Every one of them asks `PathResolver` —
`get`, `getDirectory`, `makeDirectories`, `bind`, `unbind`, `find` — and acts on the answer. The
measurable payoff showed up three separate times:

| Curveball | What changed in `InMemoryFileSystem` | Why so little |
|---|---|---|
| shortcuts | one method (`write`'s existing-leaf branch) plus one new method | `read`, `ls`, `kindOf`, `exists`, `mkdir`, `mv` and `delete` all got shortcut-awareness with **zero lines changed**, because they only ever asked `PathResolver` "what is here," and that is the one place that learned to keep looking when the answer is a pointer |
| storage quotas | two one-line insertions into `write`'s two existing commit branches | quotas needed a way to ask "what does this directory's subtree total," which is a read over the tree `PathResolver` already exposes the shape for — no new resolver method, no change to any other operation |
| glob search | one brand-new method, nothing else | the search needed a starting point (`getDirectory("/")`) and nothing more; it walks children through the same public reads `ls` already uses |

**Cost without it.** If every operation walked `dir.child(name)` chains inline, "make every
operation aware of a new kind of node" is eight edits instead of one, because there would be eight
places that assumed a name maps straight to the thing itself rather than possibly to a pointer at
it.

## Seam 4 · `Node`, sealed over `FileNode` and `DirectoryNode` — and why `Nodes.kindOf` is the whole argument

**A node is a file or a directory, and this problem gives no third option** — until a curveball
asks for one. That is exactly the situation `sealed` is for: closed today, and if it ever stops
being closed, every place that assumed it was closed has to say so, at compile time, rather than
silently keep working on the two cases it knew about.

`Nodes.kindOf` is the one place in this reference that pattern-matches over every variant, and it
exists as its own tiny class for a specific reason: **so there is exactly one place, not several,
for a new variant to break.** The shortcuts curveball adds `SymlinkNode` to the `permits` clause,
and the build fails at this one switch until a case is added — a two-line fix pointing at the
exact file, rather than a wrong answer somewhere a test would have to notice on its own. An
`instanceof FileNode` / `instanceof DirectoryNode` chain instead of a switch would have compiled
just as happily with the new type silently falling through to neither branch, which is the failure
mode a sealed hierarchy with an exhaustive switch is built to rule out.

## Seam 5 · `DirectoryNode` — the one invariant, enforced on the object, not trusted to the caller

**A directory can never hold two children under the same name, and a directory and a file can
never share a name in the same parent.** `DirectoryNode.addChild` refuses a clashing name every
time it is called, not just when the call sites currently in this reference happen to check first.
`TreeResolver.bind` still checks ahead of calling it, because only the caller has the full path to
put in the nicer, contract-facing `PathAlreadyExistsException` — but that is a better error
message, not the thing standing between this directory and an invalid state. Even a future call
site that forgot to check could not corrupt it.

## Errors · four leaves under one root, and the fifth kind of failure that is not in it on purpose

- **Four types, not one with four messages,** for the same reason vending-machine's five are four
  separate types and not a `RuntimeException` with a `Reason` enum: a caller acts differently on
  "nothing is there" than on "there's a file where you needed a directory," and forcing both
  through one type forces every caller to parse a string to tell them apart.
- **A common abstract root**, `FileSystemException`, so a caller who does not care which of the
  four happened can still catch one thing, and the hierarchy can grow a fifth leaf (as the shortcut
  curveball's `SymlinkLoopException` does) without disturbing anyone catching the root.
- **Malformed path syntax is deliberately outside this hierarchy.** `IllegalArgumentException` is
  a caller bug — a string that could never have named anything, no matter what the tree contained
  — while every member of `FileSystemException` is an outcome of walking an otherwise well-formed
  path through a tree whose actual contents decided the answer. Collapsing the two would mean a
  caller catching "path problems" as one group has to sort caller error from domain outcome by
  hand anyway, which is the exact problem a hierarchy is supposed to remove.
- **`exists` answering `false` instead of throwing is the D2 decision worth naming out loud.** A
  missing path is not a failure when the question being asked is "is it there" — it is the answer.
  `TreeResolver.find` is built on exactly this distinction: it delegates to `get` and turns every
  `FileSystemException` into `null`, while deliberately letting `IllegalArgumentException`
  propagate, because a malformed path is still a caller bug even when the caller only wanted to
  know if something exists.

## What was deliberately not built

- **No symbolic links, no quotas, no search in the base problem.** All three are real, common
  filesystem features, and all three are exactly what the curveballs ask for — the base problem
  scopes them out so that the seam being measured is whether they can be *added* without disturbing
  what already works, not whether they were anticipated in the initial design.
- **No `Path` value type.** A path is a `String` at the boundary and a `List<String>` the moment
  `PathSyntax` is done with it; a wrapping class with no behaviour beyond holding those two facts
  would be a class earning nothing, the same call `vending-machine` makes about a `Product` type.
- **No locks.** The contract states one caller at a time. The shared mutable state, if this ever
  grew a network interface, is the tree rooted at `TreeResolver`'s `root` field and nothing else —
  every read and write goes through `PathResolver`, which is also exactly where a lock would go.
- **No factory.** Nothing about what a standard file system is *made of* varies — there are no
  policies to wire, no starting contents, no configuration — so `Entry.create()` calls
  `InMemoryFileSystem`'s constructor directly. A factory earning its keep needs a decision to hide;
  this problem's construction has none, which is the same reasoning `parking-lot` used to drop
  its own factory.
