Files
suaveplan/docs/system-review.md
2026-05-27 23:29:53 +01:00

339 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# System Review — Drive · Storage · Network
A critical pass over the design captured in [`docs/storage/`](./storage/), [`docs/drive/`](./drive/), [`docs/network/`](./network/), and [`docs/drive-storage-network.md`](./drive-storage-network.md). Identifies concrete flaws, proposes fixes, and aligns the system with the in-conversation requirements: bitpacker-encoded chunk-state bitsets, batched mesh requests, dehydrated drive templates, CDN fallback with reversed-ID URL layout, per-file policy flags, and lock-icon UX.
The review is structured as 16 numbered findings, each with: **diagnosis** (the gap), **severity**, and **fix** (the concrete change). Where the fix has a corresponding new document, that doc is referenced and contains the formal spec.
---
## Severity legend
- **Blocker** — without this, a real deployment will fail. Must land before any drive/network code ships.
- **Major** — produces incorrect behavior, wasted bandwidth, or UX gaps that users will notice. Land early.
- **Minor** — design hygiene, future-proofing, or convention. Land opportunistically.
---
## §1 — Chunk request granularity is wasteful — **Major**
**Diagnosis.** The current spec (`drive-mesh-port`, `network-plugin-content-delivery`) describes `cd:request` as one bus event per chunk. A 1,000-chunk file with 800 missing chunks generates 800 envelopes, 800 round-trips, 800 routing decisions. Even with the 13-byte protocol header, the framing overhead is ~16 KiB of pure noise per file — and the worst case (every peer requesting the same 800 chunks) saturates the gossip layer.
**Fix.** Define a unified `ChunkRequestEnvelope` with three forms:
1. `{ kind: 'whole' }` — request everything the holder has (replaces empty-array ambiguity).
2. `{ kind: 'specific'; chunkIds: number[] }` — sparse selection (use when ≤ 64 chunks).
3. `{ kind: 'bitset'; bytes: Uint8Array }` — dense selection (use when > 64 chunks; bytes encoded via `@suaveplan/bitpacker-compressed` for sparse runs).
The selection threshold (64) matches the 8-byte break-even where `bitpacker-compressed` outperforms a `chunkIds` array.
Spec'd in: [`drive/mesh-protocol.md`](./drive/mesh-protocol.md).
---
## §2 — Bitset advertisements not standardized to bitpacker — **Major**
**Diagnosis.** `MESH-GOSSIP-01` says "gzipped bitset advertisements" but does not pin a schema or codec. With 10 bitpacker packages already shipped (`@suaveplan/bitpacker`, `bitpacker-compressed`, `bitpacker-network`, `bitpacker-schema`, `bitpacker-streaming`, `bitpacker-strategies`, `bitpacker-delta`, `bitpacker-game`, `bitpacker-validation`, `bitpacker-crypto`), every mesh implementer would otherwise pick a different encoding and fragment the protocol.
**Fix.** Pin the canonical advertisement format:
- Codec: `@suaveplan/bitpacker-compressed` for the bitset payload (RLE + gzip — typical 800-chunk presence map encodes to <100 bytes when most chunks are present).
- Schema: `@suaveplan/bitpacker-schema` for envelope validation.
- Wire transport: `@suaveplan/bitpacker-network` for the on-wire framing.
Spec'd in: [`drive/mesh-protocol.md`](./drive/mesh-protocol.md) §3.
---
## §3 — No "dehydrated drive" concept — **Blocker**
**Diagnosis.** The current docs assume drives are populated by writes. They have no concept of "here is the structure of the drive (folders, files, sizes, hashes) — go fetch the bytes from peers/CDN as needed." This is a real production scenario: a game ships with a drive template describing 2 GB of assets across 5,000 files; the user starts the game and the drive lazily populates from mesh peers, falling back to a CDN, while showing per-file progress in the UI.
**Fix.** Introduce `DriveTemplate` — a server-signed, schema-versioned config containing:
- Folder structure (recursive `FolderTemplate`).
- Per-file `FileTemplate` stubs with `fileId`, `size`, `chunkCount`, `checksum`, and policy flags.
- Optional inline-wrapped `fileKey` for immediate decryption, OR a `keyAuthority` peer ID for retroactive grant.
- Server signature over canonical serialization, with `expiresAt` and a 16-byte `nonce` for replay protection.
`Drive.hydrateFrom(template)` instantiates the DirIndex and FileLocationIndex from the template; per-file bytes arrive over the mesh/CDN later.
Spec'd in: [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md).
---
## §4 — CDN fallback URL convention unspecified — **Major**
**Diagnosis.** `drive-transport-https` is described as "server fallback when WebRTC isn't available" but no URL layout is defined. Without a convention, every deployment writes its own — and the chunks plugin can't fetch from a CDN it doesn't know how to address.
**Fix.** Adopt the **reversed-ID URL scheme** for CDN-backed chunk pools:
```text
<base-url>/<reversed[0]>/<reversed[1]>/<reversed[2]>/<fileId>_<chunkIdPadded>.chunk
```
Where `reversed = fileId.split('').reverse().join('')` and `chunkIdPadded` is the chunk ID zero-padded to 9 digits. Example with `fileId = "abcdef1234567890..."`:
```text
/0/9/8/abcdef1234567890.../000000001.chunk
```
Reversing avoids hot directories when fileIds share prefixes (sequential timestamps, `f-` prefixes, etc.). Three directory levels × 16-character hex alphabet = 4,096 leaf directories — distributes 1 M files at ~250 per directory.
`DriveTemplate.cdnFallback` carries `{ baseUrl, levels, padding }` so the layout is declarative, not hardcoded.
Spec'd in: [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md) §4.
---
## §5 — Per-file policy flags missing — **Blocker**
**Diagnosis.** The drive layer has no per-file controls for the three policies users care about:
- **autoDownload** — should this file be eagerly fetched, or wait until first read?
- **meshAllowed** — may we request this from peers, or must we hit the server only?
- **cdnAllowed** — may we fall back to the CDN, or only the mesh / only local?
Today these are global drive settings or implicit. The user's brief calls for them to be per-file, signed in the template, and enforced at runtime.
**Fix.** Add `PerFilePolicy` to `FileLocation` and `FileTemplate`:
```ts
interface PerFilePolicy {
autoDownload: boolean;
meshAllowed: boolean;
cdnAllowed: boolean;
retention?: 'permanent' | 'lru' | { maxAgeMs: number };
}
```
The drive enforces these in the read middleware:
- `autoDownload=true` triggers a fetch on `Drive.hydrateFrom()` not on first read.
- `meshAllowed=false` skips the `cd:request` bus emit.
- `cdnAllowed=false` skips the `drive-transport-https` fallback.
- All-disabled means the file is **local-only** — `read()` throws `DRIVE.UNAVAILABLE` if the bytes aren't present.
Spec'd in: [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md) §6.
---
## §6 — No `getKeyStatus()` / no lock-icon UX contract — **Major**
**Diagnosis.** The user's brief calls for visual lock states (locked, unlocked-with-key, unencrypted) per file. The drive surface has no method exposing this — the chunks plugin knows about encryption, the sharing plugin knows about KEKs, but `Drive.list()` returns DirEntries that don't aggregate the answer.
**Fix.** Add `Drive.getKeyStatus(path): KeyStatus` returning:
```ts
type KeyStatus = 'unencrypted' | 'encrypted-locked' | 'encrypted-unlocked';
```
And include it on every `DirEntry` returned by `list()`:
```ts
interface DirEntry {
// ... existing fields ...
keyStatus?: KeyStatus; // present for type === 'file'
presence?: ChunkPresence; // see §7
}
```
UX maps directly: 🔒 for `encrypted-locked`, 🔓 for `encrypted-unlocked`, no icon for `unencrypted`.
Spec'd in: [`drive/architecture.md`](./drive/architecture.md) (will be updated).
---
## §7 — `Drive.list()` lacks presence/progress data — **Major**
**Diagnosis.** The user wants `███░░░░░░░ 33%` next to file listings. Currently `Drive.list()` returns DirEntries with `size` and `mtime` but no `chunksPresent / chunksTotal`. The mesh plugin tracks this internally but doesn't expose it.
**Fix.** Add `ChunkPresence` to every file DirEntry:
```ts
interface ChunkPresence {
chunksPresent: number; // count of chunks locally available
chunksTotal: number; // total chunks in the file
sourceHints: SourceHint[]; // ordered: where missing chunks could come from
}
type SourceHint = 'local' | 'mesh' | 'cdn';
```
Renderable as a progress bar in any UI. Updated lazily — list() is allowed to return a snapshot from the last gossip cycle, not a fresh probe.
Spec'd in: [`drive/architecture.md`](./drive/architecture.md) update.
---
## §8 — Retroactive key distribution undefined — **Major**
**Diagnosis.** The user's brief: "optional decryption keys could be included as part of the signed config, or would have to be retro-actively added to the drive for files to be unlocked." The current sharing plugin handles partition KEKs but not per-file `fileKey` retro-grants from arbitrary peers.
**Fix.** Two distribution paths, both first-class:
1. **Inline grant** (`FileTemplate.encryption.keyDistribution.kind === 'inline'`) — the wrapped `fileKey` is in the signed template. Drive can decrypt immediately on `hydrateFrom()`.
2. **Retroactive grant** (`FileTemplate.encryption.keyDistribution.kind === 'retroactive'`) — template names a `keyAuthority` (peer pubkey). The peer issues a `KeyGrantEnvelope` whenever access is granted:
```ts
interface KeyGrantEnvelope extends MessageEnvelope {
type: 'key:grant';
payload: {
fileId: string;
wrappedKey: Uint8Array; // wrapped to recipient X25519 pubkey
issuer: string; // peer pubkey
issuerSignature: Uint8Array; // Ed25519 over (fileId, wrappedKey, recipient, expiresAt)
expiresAt: number;
};
}
```
On receipt, the drive validates the issuer signature against the template's `keyAuthority`, unwraps, and stores the `fileKey` in its KeyRing. The lock icon flips from 🔒 to 🔓.
Spec'd in: [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md) §7.
---
## §9 — No cache retention strategy — **Major**
**Diagnosis.** A drive that auto-downloads everything will fill the disk forever. The current docs don't address GC, LRU, or retention windows. Users in dehydrated-drive scenarios (game assets, media libraries) need predictable storage budgets.
**Fix.** Add three retention modes to `PerFilePolicy.retention`:
- `'permanent'` — never evict (default for explicit user-saved files).
- `'lru'` — drive maintains a global byte budget; LRU evicts chunks (not whole files; preserves manifest+index for re-download).
- `{ maxAgeMs }` — chunks older than N ms eligible for eviction.
Plus `DriveOptions.maxLocalCacheBytes` for the global budget. Eviction is at chunk granularity; the file remains visible (with reduced `chunksPresent`) and re-fetches on read.
Spec'd in: [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md) §6.
---
## §10 — Mesh advertisement trust unbounded — **Major**
**Diagnosis.** `MESH-GOSSIP-01` says peers advertise their chunk inventory. A malicious or buggy peer can advertise chunks it doesn't have, wasting our request budget. The current spec has `MESH-SCORE-01` (bad-sig sets score to `-Infinity`), but only after we've already paid the round-trip.
**Fix.** Probabilistic verification + adaptive trust:
- On a peer's first N requests for a given file, accept their advertisements as truth.
- After the first M responses, compute their "advertised-but-missing" rate. If > 5%, demote to "unverified" — their advertisements are still listened to, but probabilistically skipped (P(skip) ∝ miss rate).
- Bad sig immediately drops to `-Infinity` (existing `MESH-SCORE-01`).
- Trust score per `(peerId, fileId)` so a peer good for one file doesn't get blanket trust.
Spec'd in: [`drive/mesh-protocol.md`](./drive/mesh-protocol.md) §6.
---
## §11 — Chunk response doesn't piggyback presence — **Minor**
**Diagnosis.** The current `cd:response` returns chunk bytes only. But the responding peer is in the perfect position to tell us its full presence bitset for this file — saving us a separate `cd:advertise` round-trip in many cases.
**Fix.** Add `presence: Uint8Array` (bitpacker-compressed bitset) to every `cd:response` payload. Receiver updates its peer-index for that `(peerId, fileId)` for free.
Spec'd in: [`drive/mesh-protocol.md`](./drive/mesh-protocol.md) §4.
---
## §12 — CDN write-side authentication unspecified — **Major**
**Diagnosis.** The CDN URL convention (§4) covers reads. Writes are unspecified — anyone with the URL would otherwise be able to upload chunks, poisoning the cache. The current `drive-transport-https` proposal doesn't address this.
**Fix.** CDN writes use **server-signed upload tokens**, not direct PUT:
- The drive POSTs a chunk to `<base-url>/upload` with a `CdnUploadToken` (server-signed, 60-second expiry).
- Token contains `(fileId, chunkId, chunkHash, uploaderPubkey)`. CDN validates the token, validates the chunk against the hash, and stores at the reversed-ID URL.
- Ed25519 sig from the chunk's writer is preserved end-to-end; readers verify it when fetching.
CDNs that don't want to validate signatures can decline writes (read-only CDN); the drive falls back to peer storage in that case.
Spec'd in: [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md) §5.
---
## §13 — Drive template lacks replay protection — **Major**
**Diagnosis.** A `DriveTemplate` signed once is valid forever unless its `expiresAt` is hit. An attacker could re-issue an old (less-restrictive) template they captured. There's no nonce or sequence number making each issuance unique.
**Fix.** Required `nonce: Uint8Array(16)` and `issuedAt: number` on every template. Signature covers both. Drives reject templates with:
- `nonce` already seen (16-byte LRU of recent nonces, ~1 KiB cost).
- `issuedAt` more than 24h in the past.
- `expiresAt` in the past (existing).
Spec'd in: [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md) §3.
---
## §14 — `kind: 'inline'` files miss signature scaffolding — **Minor**
**Diagnosis.** The `FileLocation.storageHints` `kind: 'inline'` case (small files <1 KiB stored directly in the FileLocationIndex) skips the chunks-plugin pipeline. That means no per-chunk Ed25519 sig and no manifest sig. For internal indexes and tiny configs this is fine, but if an inline file is shared, the integrity story breaks.
**Fix.** Inline files must still carry a signature: `{ kind: 'inline'; bytes: Uint8Array; sig: Uint8Array }` where `sig = Ed25519-sign(driveSecretKey, fileId ‖ bytes)`. Verify on read. The cost is +64 bytes per inline file; trivial.
Spec'd in: [`drive/index-and-paths.md`](./drive/index-and-paths.md) (will be updated).
---
## §15 — No "stuck" state surface — **Minor**
**Diagnosis.** When a chunk is unavailable from local + mesh + CDN, the drive read currently throws `DRIVE.UNAVAILABLE`. But the UI wants to know *before* the user clicks: "this file is partially-stuck — 12 chunks unfetchable." Today there's no signal.
**Fix.** Add `ChunkPresence.stuckChunks: number` (chunks tried-and-failed across all configured sources within the last N minutes). Reset on retry-success or peer-population. UI can show ⚠ next to a file with stuckChunks > 0.
Spec'd in: [`drive/architecture.md`](./drive/architecture.md) update.
---
## §16 — Folder template recursion unbounded — **Minor**
**Diagnosis.** `FolderTemplate.children: (FolderTemplate | FileTemplate)[]` is recursive with no depth cap. A maliciously-crafted template could blow the stack on validation.
**Fix.** Cap recursion at depth 32 (conservative; real filesystems rarely exceed 16). Validate during template parsing; reject deeper. The depth cap is in `DriveTemplateSchema` (Zod).
Spec'd in: [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md) §3.
---
## Summary by severity
| Severity | Count | Findings |
|---|---|---|
| **Blocker** | 2 | §3 (dehydrated drive), §5 (per-file policy flags) |
| **Major** | 9 | §1, §2, §4, §6, §7, §8, §9, §10, §12, §13 |
| **Minor** | 5 | §11, §14, §15, §16 |
The two **Blockers** are about features the user explicitly asked for and the system does not yet have. They produce two new docs:
- [`drive/dehydrated-drives.md`](./drive/dehydrated-drives.md) — `DriveTemplate`, policy flags, CDN convention, key distribution.
- [`drive/mesh-protocol.md`](./drive/mesh-protocol.md) — chunk request envelope forms, bitset advertisements, response piggyback.
The **Major** items either land inside those two docs or as additive sections to existing docs (`drive/architecture.md` gains `getKeyStatus`/`getPresence`; `drive/index-and-paths.md` gets the inline-sig fix).
The **Minor** items are hardening; they ride on the same change set without their own structural impact.
---
## Implementation order
1. **Land `enterprise-types-cross-cutting`** — already archived 2026-05-02. ✓
2. **Resolve `enterprise-drive` vs `drive-port`** — already SUPERSEDED 2026-05-02. ✓
3. **Author new spec changes** for the two new docs:
- `drive-mesh-protocol-v1` — formalize the request/response/advertise envelopes (§1, §2, §10, §11).
- `drive-template-v1` — formalize `DriveTemplate`, policy flags, key distribution, CDN convention (§3, §4, §5, §8, §9, §12, §13, §16).
4. **Update existing capability specs** — `openspec/specs/drive/spec.md` adds `getKeyStatus` / `getPresence` requirements (§6, §7); `openspec/specs/network/spec.md` references the new mesh-protocol spec (§1, §2, §10, §11).
5. **Land `drive-port` + `drive-plugin-chunks-port` + `drive-mesh-port` + `drive-transport-https-port`** in dependency order, each conforming to the new specs.
6. **Land `network-plugin-content-delivery`** consuming the mesh-protocol envelopes.
7. **Port the discovery + transport plugins** from `main`.
---
## What this review does NOT touch
Three areas where the existing design is sound and needs no change:
- **Storage capability matrix.** Five flags (`partialReads`, `partialWrites`, `nativeStreams`, `atomicWrites`, `contentAddressable`) cover the access patterns; no new flag needed for any finding here.
- **Tier model & failover.** Tier 0/1/2 + four hosting models + sibling-pocket structure already accommodates the user's mesh/online-player scenarios. The mesh-protocol changes plug in without altering topology.
- **Cryptographic primitives.** AEAD + Ed25519 + BLAKE3 + X25519 sealed-box is sufficient. No new primitives proposed.
The stack as documented is a sound foundation. The findings above harden it for the production scenarios the user described — they are sharp edges to round, not load-bearing rewrites.