The index, folders, both ways of holding a file, and the delete
semantics — everything iOS's `LocalVault` does, minus the screen.
🔴 One deliberate divergence, and it is a fix rather than a port.
`descendants(of:)` on iOS bounds a cyclic index with `guardRail < 10_000`.
That terminates and then returns ten thousand copies of the same two ids
— and the screen calling it is the one offering "delete this folder and
everything in it", so a corrupt index turns that into an offer to delete
10,000 things. Bounding by what has already been visited terminates AND
returns the right answer, with no magic number. Filed as ios#66.
The test that found it writes a real cycle into a real index. It matters
because the index is a file on disk: the input is not only ever our own
writes.
⚠️ The index file is device-local and is NOT an interchange format. The
node FIELDS match iOS's exactly so a courier transfer stays a copy rather
than a translation; the JSON around them does not. iOS encodes dates as
Swift's reference-date doubles, which is an accident of `JSONEncoder`
rather than a decision, and pinning Kotlin to it would promote an accident
to a format. Anybody building vault migration should move nodes through a
declared wire shape, not by copying this file.
🔴 `ReferenceResolver` is required with no default. A no-op default would
make every reference row read as "missing" in an app that simply forgot to
wire it up — a vault that looks like it has lost half its contents, with
nothing anywhere saying why.
Two tests worth naming:
- **Nothing on disk contains the plaintext.** A round trip passes just as
happily if `add` wrote the file out unencrypted and read it straight
back, so the bytes are searched for across the whole container, and the
index is checked for the filename.
- **Deleting a reference row does not reach the person's storage at all.**
Asserting only that their file survived would be a test that cannot
fail: the delete path holds no call to remove it, and the protection is
structural — the resolver has no way to delete anything. Asserting zero
resolver calls is the property that constrains future code, and it was
verified by routing the delete path through the resolver and watching it
go red.
29 tests. Each guard proved by breaking the code: the label key salted
with the level alone, a node key wrapped to its parent's label, and a bulk
delete that ignores unselected survivors each fail exactly the test that
names them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>