Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

50
docs/drive/README.md Normal file
View File

@@ -0,0 +1,50 @@
# Drive
The **drive** layer is the file-level abstraction that sits on top of [storage](../storage/README.md). It turns "key→bytes" into "path→file" and adds the things real users expect from a drive: directories, stat, streams, plugins for chunking/encryption/integrity, partition-based sharing, and peer-to-peer transfer.
The genesis design is **n drives**, each parameterised by:
- one or more `AbstractStorage` backends (its substrate),
- a plugin chain (chunks, sharing, mesh, transports, custom middleware),
- an optional network identity (so the drive can both serve and consume chunks across the mesh).
Drives can talk to each other. A drive on a phone, a drive on a laptop, and a drive on a server can all hold the same logical file, agree on the same manifest, and serve chunks to whichever drive needs them — encrypted end-to-end, signed by the original writer, verified on every read.
## Documents in this folder
| File | Purpose |
| --- | --- |
| [`architecture.md`](./architecture.md) | The `Drive` class, the `DriveStorage` view, plugin lifecycle, capability fallbacks, and the genesis decision to keep drive a thin VFS core with everything else as plugins. |
| [`index-and-paths.md`](./index-and-paths.md) | The directory metadata index and the file→location index — the two indexes that turn opaque storage keys into a navigable filesystem. Why nested directories are stored as packed metadata objects, and how a path resolves to a chunk manifest. Includes the on-disk-shape comparison across backends. |
| [`plugins.md`](./plugins.md) | The plugin chain (`drive-plugin-chunks`, `drive-sharing`, `drive-mesh`, `drive-transport-https`, `drive-transport-webrtc`, custom). Hook ordering, middleware semantics, and what each plugin's responsibility actually is. |
| [`encryption.md`](./encryption.md) | On-the-fly encrypt/sign/decrypt/verify. The chunked AEAD pipeline, the manifest signature, and where keys come from. Why storage never sees plaintext. |
| [`mesh-and-sharing.md`](./mesh-and-sharing.md) | How drives find each other, share files, and replicate chunks. The boundary between `drive-mesh` (per-file scheduling) and `network-plugin-content-delivery` (mesh-wide redundancy). |
| [`mesh-protocol.md`](./mesh-protocol.md) | The wire-protocol spec for chunk requests, advertisements, and responses. Three request forms (whole / specific / bitset), bitpacker-compressed presence bitsets, response piggyback, trust model. |
| [`dehydrated-drives.md`](./dehydrated-drives.md) | Server-signed `DriveTemplate` — pre-configured folder structure with bytes arriving later from peers and CDN. Per-file policy flags (`autoDownload` / `meshAllowed` / `cdnAllowed`), CDN reversed-ID URL convention, retroactive key grants, lock-icon UX. |
| [`implementation-status.md`](./implementation-status.md) | What's shipped, what's pending, and the architectural decisions (`drive-port` over `enterprise-drive`, `enterprise-types-cross-cutting` archived, `drive-network-integration` boundary contract). |
> **System review:** [`../system-review.md`](../system-review.md) — 16 numbered findings (2 blockers, 9 majors, 5 minors) covering chunk request batching, dehydrated drives, CDN fallback, per-file policies, lock-icon UX, key distribution, retention, trust validation, and replay protection. Each finding cross-references the doc that resolves it.
## One-paragraph summary
`Drive` is a generic class parameterised by a storage map. It exposes `read`, `write`, `delete`, `stat`, `list`, `createReadStream`, `createWriteStream`, plus `usePlugin(plugin)` and `useMiddleware(mw)`. It owns its lifecycle (`initialize`, `dispose`) and tracks which storages it connected itself, so calling `dispose()` only closes what `Drive` opened. All file-level intelligence — splitting a file into chunks, signing them, encrypting them, finding them on peers, reassembling them — lives in plugins. The core knows nothing about chunks, signatures, or peers; it only knows how to walk a middleware chain on its way to and from `AbstractStorage`. This is what lets the same `Drive` class run on a server (with `S3` and `network-plugin-webrtc`), in a browser (with `OpfsStorage` and `drive-transport-webrtc`), or on disk (with `FileStorage` and no network at all).
## How n drives work
A drive is identified by a stable id (the public key it signs with, plus an instance discriminator). Two drives that share the same root partition (same partition key) are mirrors of each other; two drives that hold different partitions are siblings on the mesh. Every drive in a deployment can:
1. **Hold local data** — bytes live in its `AbstractStorage` backends.
2. **Serve to peers** — exposes its chunk inventory through `network-plugin-content-delivery`.
3. **Consume from peers** — when a chunk is missing locally, `drive-mesh` resolves it from the mesh.
4. **Reconcile** — the manifest is authoritative; conflicts in chunk content are detected by signature and discarded.
There is **no central drive**. There is sometimes a central server (which is just another drive with elevated authority — see [`../network/topology-and-failover.md`](../network/topology-and-failover.md)), but the whole stack works without it.
## Reading order
1. [`architecture.md`](./architecture.md) — what a `Drive` actually is.
2. [`index-and-paths.md`](./index-and-paths.md) — how paths become bytes.
3. [`plugins.md`](./plugins.md) — what each plugin does.
4. [`encryption.md`](./encryption.md) — the crypto pipeline.
5. [`mesh-and-sharing.md`](./mesh-and-sharing.md) — how drives find each other.
6. [`../drive-storage-network.md`](../drive-storage-network.md) — the whole composition.

156
docs/drive/architecture.md Normal file
View File

@@ -0,0 +1,156 @@
# Drive Architecture
Canonical capability spec: [`openspec/specs/drive/spec.md`](../../openspec/specs/drive/spec.md) (`DRIVE-01`..`DRIVE-09`).
## The shape genesis chose
Two competing drive designs exist in the OpenSpec change folder:
| Design | Folder | Stance |
| --- | --- | --- |
| **Monolith** | `enterprise-drive` | Drive is a fat package — chunks, CoW, dedup, snapshots, encryption, transforms, plugins all inside. |
| **Split** | `drive-port` *(genesis-canonical)* | Drive is a thin VFS core. Chunks, mesh, transports, sharing live in sibling packages. |
The genesis branch follows the **split**. The reason is mechanical: the canonical `openspec/specs/drive/spec.md` is the split-design spec (`DRIVE-01..09`), the migrated `@suaveplan/drive` package on genesis already implements the thin core, and the chunks layer is its own migrated package (`@suaveplan/drive-plugin-chunks`). The `enterprise-drive` proposal should be archived and the few unique requirements it carries (CoW, snapshots) re-proposed as their own plugin packages.
## The core class
```ts
class Drive<TStorageMap extends Record<string, DriveStorage> = {}> {
constructor();
constructor(defaultStorage: DriveStorage, options?: DriveOptions);
constructor(options: DriveOptions & { defaultStorage?: DriveStorage; plugins?: DrivePlugin[] });
use<K extends string, S extends DriveStorage>(id: K, storage: S): Drive<TStorageMap & Record<K, S>>;
usePlugin(plugin: DrivePlugin): this;
useMiddleware(mw: MiddlewareHandler): this;
initialize(): Promise<void>;
dispose(): Promise<void>;
read(path: string, opts?: ReadOptions): Promise<Uint8Array>;
write(path: string, bytes: Uint8Array, opts?: WriteOptions): Promise<void>;
delete(path: string): Promise<boolean>;
stat(path: string): Promise<DriveStats>;
list(prefix: string, opts?: ListOptions): Promise<DriveEntry[]>;
createReadStream(path: string): ReadableStream<Uint8Array>;
createWriteStream(path: string): WritableStream<Uint8Array>;
getStorage<K extends keyof TStorageMap>(id: K): TStorageMap[K];
}
```
Five things are worth understanding.
### 1. `DriveStorage` is the narrowed view of `AbstractStorage` (DRIVE-01)
```ts
interface DriveStorage extends AbstractStorage {
readonly id: string;
onDriveAttached?(drive: Drive): Promise<void>;
onDriveDetached?(drive: Drive): Promise<void>;
}
```
There is no separate `StorageAdapter` shim. The drive consumes any `AbstractStorage` directly; it just needs an `id` so the drive can address it inside a multi-storage map and (optionally) lifecycle hooks. This is the line that breaks the temptation to wrap storage in another interface.
### 2. Lifecycle is ownership-tracking (DRIVE-02, DRIVE-03)
```text
initialize() → for each storage, call connect() iff !isConnected(); record in connectedByMe: WeakSet
dispose() → for each storage in connectedByMe, call close()
→ leave pre-connected storages alone
```
A drive that didn't open the connection doesn't close it. This matters for shared backends (e.g., a server with one S3 client serving many drives): the first drive opens it, the others reuse it, and the *last* drive to be disposed is the one that actually closes the connection (or none of them, if the client owns the lifetime).
### 3. Operations after `dispose()` throw (DRIVE-04)
`read`, `write`, `delete`, `stat`, `list`, `createReadStream`, `createWriteStream` all throw `DriveError(DRIVE.DISPOSED)` after `dispose()`, and `DriveError(DRIVE.NOT_INITIALIZED)` before `initialize()`. The drive is a state machine: `pending → initialized → disposed`, and the storage layer below it is left in a known state.
### 4. Plugin middleware order is well-defined (DRIVE-06)
```text
drive.usePlugin(A);
drive.usePlugin(B);
drive.useMiddleware(M);
```
Outbound (request path): `A → B → M → core operation`.
Inbound (response path): `core operation → M → B → A`.
`plugin.dispose()` is called in **reverse** registration order, so the most-recently-installed plugin gets to clean up first. Plugin install/dispose throws are wrapped in `DriveError(DRIVE.PLUGIN_FAILED, { cause })` so consumers see one error type at the boundary.
### 5. Capability fallbacks remove runtime probing (DRIVE-07)
```text
createReadStream(path):
if storage.capabilities.nativeStreams === true:
return storage.createReadStream(...)
else:
return AbstractStorage.defaultCreateReadStream(...) // chunked read of get()
```
Same rule for `createWriteStream`. The drive never inspects the backend at runtime to decide whether to stream — it reads `capabilities` once and routes accordingly. This is why the storage layer's capability matrix is load-bearing.
## Plugin and middleware contracts
Both contracts live in `@suaveplan/types` (the cross-cutting types package) so plugins and middleware never import drive directly:
```ts
interface DrivePlugin {
readonly name: string;
readonly hooks: Record<string, PluginHook>; // e.g. { 'drive:read': fn, 'drive:write': fn }
readonly priority?: number;
}
type PluginHook = (context: unknown, next: NextFunction) => Promise<unknown>;
type NextFunction = () => Promise<void>;
interface MiddlewareHandler {
(context: MiddlewareContext, next: NextFunction): Promise<void>;
}
interface MiddlewareContext {
operation: string; // 'read' | 'write' | 'delete' | 'stat' | 'list'
path: string;
[key: string]: unknown; // open index — drive attaches { drive, args, ... }
}
```
The `[key: string]: unknown` index signature is deliberate: it lets `Drive` attach domain-specific fields to the context (`{ drive: this, args: [path, bytes] }`) without forcing `@suaveplan/types` to import `Drive`. That is the wire that breaks the otherwise-circular dependency.
## Generic storage typing (DRIVE-08)
```ts
const drive = new Drive()
.use("local", new MemoryStorage())
.use("remote", new S3({ ... }));
drive.getStorage("local"); // typed as MemoryStorage
drive.getStorage("remote"); // typed as S3
drive.getStorage("nope"); // TS error
```
`Drive<TStorageMap>` narrows on each `use<K, S>(id, storage)` call so `getStorage(id)` returns the exact backend type, not a widened `DriveStorage`. Plugins that need to call backend-specific APIs (e.g., the chunks plugin asking S3 for a presigned URL) get type-safe access without casts.
## Constructor overloads (DRIVE-09)
```ts
new Drive() // no default storage; must add via .use()
new Drive(memoryStorage) // registers as id "default"
new Drive(memoryStorage, { plugins: [chunks] }) // default + options
new Drive({ defaultStorage: memoryStorage, plugins: [chunks] }) // bag-of-options form
```
The bare-storage form is a thin sugar over `{ defaultStorage }` — registers the storage under id `"default"`. Every other form is just option assembly.
## Why this shape
The narrow core is what makes "n drives" work. Every feature you might want — chunking, encryption, sharing, mesh sync, CoW snapshots, dedup — is a plugin. A drive that doesn't need the network skips the network plugins. A drive in a browser without OPFS uses `IndexedDbStorage` and the chunks plugin's `chunk-keys` layout. A drive on a server with petabytes of S3 uses the `sparse-file` layout and skips drive-mesh because there's only one replica anyway. None of those configurations require code changes in `Drive` itself.
## Reading further
- [`index-and-paths.md`](./index-and-paths.md) — how paths become storage keys.
- [`plugins.md`](./plugins.md) — what each plugin contributes.
- [`../storage/architecture.md`](../storage/architecture.md) — the layer below.

View File

@@ -0,0 +1,470 @@
# Dehydrated Drives — Templates, Policies, and CDN Fallback
A **dehydrated drive** is a drive whose folder structure and file metadata are pre-configured by a server-signed template, while the actual file bytes arrive later from peers, the CDN, or local writes. The user sees the full directory tree on day one — files have correct names, sizes, and lock states — and chunks populate transparently as they're requested.
This is the answer to "ship a 2 GB game asset bundle to N players" and "subscribe a user to a curated drive without copying everything up front." It is the resolution of [`system-review.md`](../system-review.md) §3, §4, §5, §8, §9, §12, §13, §16. The spec belongs in `openspec/specs/drive-template/spec.md` once the proposal lands.
---
## 1. The lifecycle
```text
┌──────────────────────────────────────────────────────────────────┐
│ Server │
│ - Curates the drive: which files, which folders, which chunks │
│ - Computes hashes, sizes, chunk counts │
│ - Signs DriveTemplate with server Ed25519 key │
│ - Publishes template (via API, share link, or peer broadcast) │
└──────────────────────────┬───────────────────────────────────────┘
│ signed DriveTemplate (~10-100 KB)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Client │
│ drive.hydrateFrom(template) │
│ ├─ verify server signature │
│ ├─ instantiate DirIndex from FolderTemplate tree │
│ ├─ instantiate FileLocationIndex from FileTemplate stubs │
│ │ (every entry's `storageHints` starts with kind:'mesh' and │
│ │ optionally kind:'cdn') │
│ ├─ unwrap inline keys → KeyRing (lock icon flips per file) │
│ ├─ for each file with policy.autoDownload === true: │
│ │ schedule a fetch via mesh-protocol (see mesh-protocol.md) │
│ │ on miss, fall through to CDN │
│ └─ emit `drive:hydrated` event │
└──────────────────────────────────────────────────────────────────┘
```
After hydration, the drive looks identical to one that was populated by writes. The user calls `read("/photo.jpg")` the same way; the read pipeline transparently fetches missing chunks. The dehydrated state is invisible at the API surface — only the `presence` field on DirEntries hints that bytes haven't arrived.
---
## 2. The `DriveTemplate` shape
```ts
interface DriveTemplate {
schemaVersion: 1; // pinned; future versions are new packages
driveId: string; // unique drive identity (32-byte hex)
rootFolder: FolderTemplate;
cdnFallback?: CdnFallbackConfig;
policy: GlobalPolicy;
issuer: string; // server pubkey (Ed25519, 32-byte hex)
issuedAt: number; // unix ms
expiresAt: number; // unix ms
nonce: Uint8Array; // 16 bytes; replay protection
serverSignature: Uint8Array; // Ed25519 over canonicalSerialize(this without sig)
}
interface FolderTemplate {
name: string; // single path component
children: (FolderTemplate | FileTemplate)[];
// depth is capped at 32 (validated by DriveTemplateSchema)
}
interface FileTemplate {
name: string;
fileLocId: string; // primary key into FileLocationIndex
fileId: string; // 32-byte hex; stable across versions
size: number; // plaintext bytes
totalChunks: number;
chunkSize: number;
checksum: string; // 'blake3:<hex>' over plaintext
flags: PerFilePolicy;
encryption?: FileEncryption;
manifestKey: string; // where the chunk manifest lives
manifestSig: Uint8Array; // detached Ed25519 sig over the manifest's content
}
interface PerFilePolicy {
autoDownload: boolean; // eagerly fetch on hydrate?
meshAllowed: boolean; // may we cd:request from peers?
cdnAllowed: boolean; // may we hit the CDN?
retention?: RetentionPolicy;
}
type RetentionPolicy =
| 'permanent' // never evict
| 'lru' // global byte-budget LRU
| { maxAgeMs: number }; // chunks older than N ms eligible for eviction
interface FileEncryption {
algorithm: 'xchacha20-poly1305';
keyDistribution:
| { kind: 'inline'; wrappedKey: Uint8Array } // shipped in template, see §7
| { kind: 'retroactive'; keyAuthority: string }; // peer pubkey will issue grant later
}
interface GlobalPolicy {
defaultPolicy: PerFilePolicy; // applied to files whose `flags` is partial
maxLocalCacheBytes?: number; // global LRU budget (excludes 'permanent' files)
maxConcurrentFetches?: number; // backpressure on hydrate
trustedKeyAuthorities?: string[]; // pubkeys whose key:grant we accept
}
interface CdnFallbackConfig {
baseUrl: string; // e.g. 'https://chunks.example.com'
pathScheme: 'reverse-id'; // see §4 for the URL convention
levels: number; // directory depth (typically 3)
padding: number; // chunk ID zero-pad width (typically 9)
uploadEndpoint?: string; // for write-back of newly-fetched chunks
}
```
---
## 3. Signature, replay protection, and validation
### Canonical serialization
The server computes the signature over a deterministic byte representation:
```text
canonicalSerialize(template) =
schemaVersion(varint) ‖
driveId(32) ‖
rootFolder(canonical-folder) ‖
cdnFallback(canonical-cdn-or-empty) ‖
policy(canonical-policy) ‖
issuer(32) ‖
issuedAt(8) ‖
expiresAt(8) ‖
nonce(16)
```
Encoded via `@suaveplan/bitpacker-schema` with deterministic field order. JSON is **not** used — it doesn't have a canonical form across implementations.
### Validation order
`drive.hydrateFrom(template)` validates in this exact order; any failure aborts before instantiating anything:
1. `schemaVersion === 1` (else `DRIVE.TEMPLATE_VERSION_UNSUPPORTED`).
2. `Date.now() < expiresAt` (else `DRIVE.TEMPLATE_EXPIRED`).
3. `Date.now() - issuedAt < 24*60*60*1000` (24-hour issuance window — `DRIVE.TEMPLATE_STALE`).
4. `nonce` not seen in last 1024 templates (LRU cache; `DRIVE.TEMPLATE_REPLAY`).
5. `issuer` ∈ `globalPolicy.trustedKeyAuthorities` (or matches drive-creation key — `DRIVE.TEMPLATE_UNTRUSTED_ISSUER`).
6. Ed25519 verify `serverSignature` over `canonicalSerialize(template - sig)` (`DRIVE.TEMPLATE_SIG_INVALID`).
7. Recursive folder depth ≤ 32 (`DRIVE.TEMPLATE_TOO_DEEP`).
8. Per-file: `chunkSize > 164` (header overhead), `totalChunks * chunkSize >= size`, `manifestKey` non-empty (`DRIVE.TEMPLATE_MALFORMED`).
Only after all eight pass does hydration begin.
### Why nonce + issuedAt
A captured template, even one within its `expiresAt`, cannot be replayed against the same drive because the nonce is recorded. A client moving between devices must request a fresh template; this is by design — it gives the server a chance to update policy.
---
## 4. CDN fallback URL convention
When `policy.cdnAllowed === true` and a chunk is not available from the mesh, the chunks plugin falls back to HTTPS via `drive-transport-https`. The URL convention is **reversed-ID** with configurable depth and padding:
```text
<baseUrl>/<reversed[0]>/<reversed[1]>/...<reversed[levels-1]>/<fileId>_<chunkIdPadded>.chunk
```
### Worked example
```text
fileId = "abcdef1234567890fedcba0987654321abcdef1234567890fedcba0987654321"
reversed = "1234567890abcdef1234567890abcdef1234567890fedcba0987654321fedcba"
levels = 3
padding = 9
chunkId = 47
URL = https://chunks.example.com/1/2/3/<full-fileId>_000000047.chunk
```
### Why reversed
File IDs in practice cluster by prefix:
- Sequential issuance (`f-000001`, `f-000002`, ...) makes prefix-based hashing put millions of files in the same directory.
- Timestamp-prefixed IDs (`1729a4f72c3b...` from a sortable UUID) cluster by issuance time.
- Hash prefixes are uniform but humans tend to name files in consistent ways (creation tools embed magic bytes, etc.).
Reversing distributes leaves randomly across the directory tree regardless of how IDs were generated. The first three reversed characters give 16³ = 4,096 leaf directories — enough to keep any single directory under 250 entries even at 1 M files.
### Configuration trade-offs
| `levels` | Leaf dirs | Chunks/dir at 1M files | When to use |
|---|---|---|---|
| 2 | 256 | 3,906 | <100 K files; minimize HTTP path length |
| 3 | 4,096 | 244 | **default — good balance** |
| 4 | 65,536 | 15 | >10 M files; inode budget allows |
Padding (9 digits) supports up to 999,999,999 chunks per file (~64 PB at 64 KiB chunks). Not the bottleneck.
### Read protocol
```text
GET https://chunks.example.com/1/2/3/<fileId>_000000047.chunk
Range: bytes=0- (optional — supports partial chunk fetch)
Accept: application/octet-stream
```
Response body is the locked v0x0001 chunk format (`metadata(100) ‖ sig(64) ‖ ciphertext`). Same bytes the mesh would have returned. The drive's `chunks` plugin verifies the Ed25519 sig and AEAD tag identically — the CDN never sees plaintext.
### CDN as cache vs CDN as authority
The CDN is treated as a **cache, not an authority**. If a chunk's CDN copy fails signature verification, the drive:
1. Discards the bytes.
2. Falls back to the mesh.
3. Optionally reports the corruption to a configured endpoint.
This means a compromised CDN cannot poison a drive — it can only deny service.
---
## 5. CDN write-back
`CdnFallbackConfig.uploadEndpoint` (optional) lets a drive contribute newly-fetched chunks back to the CDN. This is the path that lets a small group of users seed a dehydrated drive without needing every chunk on the CDN at issuance time.
```text
POST <baseUrl>/upload
Content-Type: application/octet-stream
Authorization: Bearer <CdnUploadToken>
Body: <chunk bytes — same locked v0x0001 format>
```
```ts
interface CdnUploadToken {
fileId: string;
chunkId: number;
chunkHash: string; // BLAKE3 of the chunk bytes (the CDN will verify)
uploaderPubkey: string;
expiresAt: number; // 60-second expiry by default
serverSignature: Uint8Array; // Ed25519 by the drive's issuing server
}
```
The CDN:
1. Validates the token signature against the drive's known issuer key.
2. Hashes the body, compares to `chunkHash` — reject on mismatch.
3. Writes to the reversed-ID URL.
A CDN that doesn't validate signatures can decline writes (read-only mode); the drive falls back to peer-only seeding for that file.
### Why server-issued tokens
Direct PUT (no token) lets anyone poison the CDN. CORS won't help — attackers bypass it. The token couples the upload to a server-attested intent and a specific `(fileId, chunkId, chunkHash)`, and expires fast enough that a stolen token isn't useful.
---
## 6. Per-file policies in detail
### `autoDownload`
- `true` → on `hydrateFrom()`, schedule a fetch for all chunks not present locally. Honors `maxConcurrentFetches`.
- `false` → wait until first `read()`. The read triggers the same fetch path; the user pays the latency on first access.
UX: a drive showing a directory listing renders progress for `autoDownload=true` files even if the user hasn't opened them yet.
### `meshAllowed`
- `true` → `drive-mesh` may emit `cd:request` for this file's chunks.
- `false` → mesh layer skipped entirely; only local + CDN paths attempted.
Use `meshAllowed=false` for confidential files where peers shouldn't even *know* the file exists. The drive's gossip stops at hydration; a peer scraping the network bus never sees a request for this file.
### `cdnAllowed`
- `true` → `drive-transport-https` may fetch from the CDN.
- `false` → CDN path skipped. Use for files restricted to peer-only distribution (e.g., user-shared content that the CDN doesn't host).
### `retention`
The cache eviction policy. See [`../system-review.md`](../system-review.md) §9.
| Mode | Behavior |
|---|---|
| `'permanent'` | Never evict. The drive's job is to keep these chunks forever. |
| `'lru'` | Eligible for global LRU eviction. When `maxLocalCacheBytes` is exceeded, evict oldest-touched chunks (NOT whole files) until under budget. The file remains visible with reduced `chunksPresent`; re-fetches on next read. |
| `{ maxAgeMs }` | Chunks older than N ms eligible for eviction at the next GC pass. Useful for ephemeral content (tournaments, event streams). |
Eviction is at chunk granularity, never file granularity. The DirEntry, FileLocation, and manifest stay; only the bytes go. This means a "dehydrated" file gracefully transitions back to "needs-fetch" without index churn.
### Defaults via `GlobalPolicy.defaultPolicy`
A `FileTemplate.flags` may omit any field; the missing fields fill from `defaultPolicy`. This keeps the template compact for drives where every file shares the same policy.
---
## 7. Encryption keys — inline vs retroactive
### Inline (key shipped in template)
```ts
encryption: {
algorithm: 'xchacha20-poly1305',
keyDistribution: {
kind: 'inline',
wrappedKey: Uint8Array, // wrapped to recipient's X25519 pubkey via crypto_box_seal
},
}
```
On `hydrateFrom()`, the drive unwraps `wrappedKey` with the recipient's secret key (held in `@suaveplan/crypto-vault`). On success, the unwrapped `fileKey` lands in the drive's KeyRing under `fileId`. The lock icon flips from 🔒 to 🔓 immediately.
This is the simple case: drive operator decides at template-issuance time who gets the key, and the recipient has it from day one.
### Retroactive (key arrives later)
```ts
encryption: {
algorithm: 'xchacha20-poly1305',
keyDistribution: {
kind: 'retroactive',
keyAuthority: '<peer-pubkey>', // the peer that will issue grants
},
}
```
On `hydrateFrom()`, the drive:
1. Records the file as encrypted-locked.
2. Subscribes to `bus:key:grant` events filtered by `fileId`.
3. Renders 🔒 in the UI.
When the key authority decides to grant access (or the user requests it via a side channel — e.g., paying for content, joining a group), it issues:
```ts
interface KeyGrantEnvelope extends MessageEnvelope {
type: 'key:grant';
payload: KeyGrantPayload;
}
interface KeyGrantPayload {
fileId: string;
recipient: string; // recipient pubkey
wrappedKey: Uint8Array; // wrapped to recipient via crypto_box_seal
issuer: string; // keyAuthority pubkey from the template
issuerSignature: Uint8Array; // Ed25519 over (fileId, recipient, wrappedKey, expiresAt)
expiresAt: number; // optional — for time-limited grants
}
```
The drive validates:
1. `issuer === template.encryption.keyAuthority` for this file.
2. `recipient === ourPubkey`.
3. Ed25519 sig.
4. `Date.now() < expiresAt` (if set).
On success, unwrap, store in KeyRing, flip 🔒 → 🔓, emit `drive:key:granted` event. UI updates.
### Revocation
A retroactive key grant can carry an `expiresAt`. After that timestamp:
- The drive evicts the unwrapped key from the KeyRing.
- The lock icon flips back 🔓 → 🔒.
- A new grant can be issued to extend access.
This is forward revocation only — anything the recipient already decrypted, they have. (See [`mesh-and-sharing.md`](./mesh-and-sharing.md) §"What revocation actually does".) For destructive revocation, rotate the `fileKey` server-side and re-issue the template with a new `manifestSig`.
### Trust roots
`GlobalPolicy.trustedKeyAuthorities` is the set of issuer pubkeys whose `key:grant` envelopes the drive will accept. Typically just `[server-pubkey]`, but multi-issuer setups (a game with multiple content vendors) can list several. The set is fixed at template issuance — adding new issuers requires a fresh template.
---
## 8. The `Drive.hydrateFrom()` API
```ts
class Drive {
hydrateFrom(template: DriveTemplate, options?: HydrateOptions): Promise<HydrateResult>;
}
interface HydrateOptions {
signal?: AbortSignal;
onProgress?: (event: HydrateProgressEvent) => void;
preFetch?: 'none' | 'metadata-only' | 'auto-download-files';
}
interface HydrateResult {
filesAdded: number;
bytesScheduled: number; // sum of sizes of autoDownload files
unlocked: number; // count of inline-key files unlocked
locked: number; // count of files awaiting retroactive grant
}
type HydrateProgressEvent =
| { kind: 'verified' }
| { kind: 'index-built'; entries: number }
| { kind: 'fetch-scheduled'; fileId: string }
| { kind: 'key-unlocked'; fileId: string };
```
`preFetch` controls hydration aggressiveness:
- `'none'` — instantiate indexes only; fetch on first read.
- `'metadata-only'` — fetch all manifests but no chunks. Lets the drive verify file integrity claims without the byte cost.
- `'auto-download-files'` (default) — honor each file's `policy.autoDownload`.
---
## 9. Worked example — game asset bundle
A game wants to ship a 2 GB asset bundle to 10K players. The server:
1. Chunks every asset file at 64 KiB. ~32K chunks across 200 files.
2. Computes Ed25519-signed chunk format for each chunk; uploads to the CDN at `/chunks.game.example.com/<reversed-id>/<file>_<chunk>.chunk`.
3. Computes a `DriveTemplate` listing every asset path, fileId, size, manifest, encryption (none for assets, inline for premium DLC).
4. Signs the template; expiry 30 days; nonce per player.
5. Distributes the template via the game launcher.
On the player's machine:
```ts
const drive = new Drive(opfsStorage)
.usePlugin(chunksPlugin({ ... }))
.usePlugin(meshPlugin({ networkNode }))
.usePlugin(transportWebrtcPlugin({ ... }))
.usePlugin(transportHttpsPlugin({ baseUrl: 'https://chunks.game.example.com' }));
await drive.initialize();
const result = await drive.hydrateFrom(template, {
preFetch: 'auto-download-files',
onProgress: (e) => updateLoadingScreen(e),
});
// result.filesAdded === 200; result.bytesScheduled === 2_000_000_000;
```
What happens next:
- For files with `autoDownload=true` and `meshAllowed=true`: drive-mesh broadcasts `cd:request`. Other players already running the game (with chunks cached) respond. Bandwidth comes from peers, not the CDN.
- For files no peer has yet (newest player): drive-transport-https hits the CDN.
- For files with `autoDownload=false`: deferred until the player navigates to the corresponding game zone; first-access pays a fetch.
- For DLC with inline encryption: keys unwrap immediately; UI shows 🔓.
- For event-bound DLC (retroactive keys, e.g., "this skin unlocks during the seasonal event"): UI shows 🔒. When the season starts, the server broadcasts `key:grant`; UIs flip to 🔓.
The CDN's bandwidth bill drops dramatically once the player base has any seed. The mesh handles the cold-start of new geographic clusters. The drive UX is identical regardless of where bytes came from.
---
## 10. Test contract
The `drive-template-v1` change folder will ship:
- `tests/template-roundtrip.test.ts` — canonicalSerialize / sign / verify cycle.
- `tests/template-replay.test.ts` — second hydrate with same nonce rejected.
- `tests/template-expired.test.ts` — expired template rejected with correct error code.
- `tests/template-tampered.test.ts` — bit flip anywhere in the template breaks the sig.
- `tests/policy-meshallowed-false.test.ts` — `meshAllowed: false` skips bus emit (verified via test bus interceptor).
- `tests/policy-cdnallowed-false.test.ts` — same for CDN.
- `tests/retention-lru.test.ts` — eviction respects `maxLocalCacheBytes`; permanent files never evicted.
- `tests/inline-key-unlock.test.ts` — hydrate flips lock icon; `read()` returns plaintext.
- `tests/retroactive-key-grant.test.ts` — incoming `key:grant` flips lock icon; `read()` works.
- `tests/cdn-reverse-id-url.test.ts` — URL builder produces correct path for various `levels`/`padding` combos.
- `tests/cdn-bad-sig.test.ts` — corrupted CDN response falls back to mesh.
---
## 11. Reading further
- [`mesh-protocol.md`](./mesh-protocol.md) — the chunk-request/response/advertise envelopes that hydration uses.
- [`encryption.md`](./encryption.md) — chunk format that the templates reference.
- [`mesh-and-sharing.md`](./mesh-and-sharing.md) — partition-based sharing as the alternative to dehydrated drives.
- [`../system-review.md`](../system-review.md) — the review this doc resolves.
- [`../drive-storage-network.md`](../drive-storage-network.md) — the whole composition map; hydration plugs into the read pipeline at the `mesh` layer.

141
docs/drive/encryption.md Normal file
View File

@@ -0,0 +1,141 @@
# Drive Encryption — Encrypt / Sign / Decrypt / Verify on the Fly
The user's question: *"to be able to encrypt / sign / decrypt / verify on-the-fly."*
The drive layer does this through `@suaveplan/drive-plugin-chunks`, which streams a file through a five-stage pipeline on the way down to storage and reverses it on the way up. Storage never sees plaintext.
Canonical specs:
- [`openspec/specs/packer/spec.md`](../../openspec/specs/packer/spec.md) — chunked AEAD format.
- [`openspec/changes/drive-plugin-chunks-port/specs/drive-plugin-chunks/spec.md`](../../openspec/changes/drive-plugin-chunks-port/specs/drive-plugin-chunks/spec.md) — drive-side requirements.
## The chunk binary format (locked, v0x0001)
Every chunk on disk is exactly:
```text
metadata(100) ‖ sig(64) ‖ ciphertext(variable)
```
| Field | Size | Description |
| --- | --- | --- |
| version | 3 | `0x0001` (locked v1; `0x0002+` reserved for future formats) |
| fileId | 32 | 32-byte stable file identifier |
| chunkId | 4 | 0-indexed chunk number within the file |
| totalChunks | 4 | Total chunks in the file |
| fileSize | 8 | Total file size in bytes |
| chunkSize | 4 | Configured chunk size (e.g. 65536) |
| payloadLen | 4 | Actual ciphertext length for this chunk |
| flags | 2 | Reserved (compression, alg agility — currently 0) |
| nonce | 24 | XChaCha20 nonce, deterministic from `(fileId, chunkId)` |
| aeadTag | 16 | Poly1305 authentication tag |
| **sig** | **64** | **Ed25519 signature over `metadata ‖ aeadTag`** |
| ciphertext | `payloadLen` | XChaCha20-Poly1305 encrypted payload |
Header total: 100 bytes. Signature: 64 bytes. Header + sig = **164 bytes per chunk** of overhead, regardless of chunk size.
Non-final chunks are exactly `chunkSize - 164` plaintext bytes. The final chunk is naturally short (no zero padding).
## The pipeline
### Write side
```text
plaintext stream
│
├── split into chunks of (chunkSize - 164) plaintext bytes (chop)
│
├── for each chunk:
│ ├── nonce = HKDF(fileKey, "chunk-nonce", fileId ‖ chunkId)
│ ├── ciphertext, aeadTag = XChaCha20-Poly1305-encrypt(fileKey, nonce, plaintext, AAD=metadata)
│ ├── sig = Ed25519-sign(driveSecretKey, metadata ‖ aeadTag)
│ └── chunk = metadata ‖ sig ‖ ciphertext (164 + payload)
│
└── store each chunk:
if storage.capabilities.partialWrites:
storage.setPartial(fileKey, chunkId * chunkSize, chunk) // sparse-file layout
else:
storage.set(`${fileKey}/${chunkId}`, chunk) // chunk-keys layout
│
├── manifest = { fileId, totalChunks, version, entries: [{ chunkId, hash, payloadLen }, ...] }
├── manifestSig = Ed25519-sign(driveSecretKey, canonicalSerialize(manifest))
└── store manifest at `manifests/${fileId}-v${version}`
```
### Read side
```text
storage read of one chunk
│
├── parse 164-byte header (extract metadata, sig, AAD)
│
├── verify Ed25519 sig over (metadata ‖ aeadTag) using driveId's pubkey
│ → reject with CHUNKS.SIG_INVALID if it doesn't match
│
├── decrypt ciphertext with XChaCha20-Poly1305(fileKey, nonce, AAD=metadata)
│ → reject with CHUNKS.AEAD_FAILED if the tag doesn't match
│
├── reassemble plaintext from contiguous chunkIds
│
└── verify whole-file BLAKE3 hash matches the manifest's checksum
→ reject with CHUNKS.INTEGRITY_FAILED otherwise
```
The verify-then-decrypt order is intentional: the signature is over the metadata + AEAD tag, *not* the plaintext, so signature verification can happen on bytes the storage layer just produced — no plaintext needed. If the signature is wrong (tampered chunk, wrong drive), we reject before spending CPU on decrypt.
## Where the keys come from
Two key materials per file:
1. **`fileKey`** — symmetric AEAD key. 32 bytes. Used for XChaCha20-Poly1305. Never leaves the drive in plaintext.
2. **`driveSecretKey`** — Ed25519 signing key. The drive's identity. Used to sign every chunk and every manifest.
Where they live:
- `driveSecretKey` is stored in the **vault** (`@suaveplan/crypto-vault`). The vault is unlocked with a BIP39 mnemonic + Argon2id-hardened KDF; on lock or process exit, all derived keys are zeroized. The public key — `driveId` — is derived from this and is what every other drive uses to verify signatures.
- `fileKey` is per-file, generated fresh on first write. It is **wrapped** with the partition KEK (key-encryption-key) by `drive-sharing` and stored alongside the manifest. Authorized peers unwrap it; unauthorized peers see only ciphertext.
For shared files (multiple drives can read), `drive-sharing` rewraps the `fileKey` with each authorized drive's public key (X25519 + sealed-box). Revocation rotates `fileKey` on the next write.
## End-to-end encryption properties
| Property | Mechanism |
| --- | --- |
| Confidentiality at rest | XChaCha20-Poly1305 ciphertext is what storage stores. Any backend (S3, OPFS, FAT) sees only ciphertext. |
| Confidentiality in transit | Same ciphertext flows through `drive-transport-*`. Network plugins never see plaintext. |
| Authenticity | Per-chunk Ed25519 sig — only the drive that wrote the chunk could have produced it. |
| Integrity | (a) AEAD tag per chunk, (b) BLAKE3 over the whole file in the manifest, (c) Ed25519 over the manifest. Three layers; any one catches tampering. |
| Anti-substitution | Manifest signature pins the exact `(chunkId, hash, payloadLen)` tuple. A peer can't swap chunk 3's content for an older chunk 3 of the same length. |
| Forward secrecy on revocation | Rotating `fileKey` on the next write means revoked peers can decrypt only what they've already cached, not future content. |
| Replay resistance | Manifest version monotonically increases; old manifests are rejected once a newer one is signed by the same drive. |
## The on-the-fly part
Both directions are streaming. The chunks plugin uses `@suaveplan/streams` (which uses WHATWG `TransformStream`). A 1 GiB file never fully lands in memory:
- **Write** — `createWriteStream(path)` returns a `WritableStream` that the plugin pipes through `chop → encrypt+sign → store`.
- **Read** — `createReadStream(path)` returns a `ReadableStream` that pulls chunks lazily, verifies and decrypts each, and yields plaintext bytes.
The pipeline is backpressure-safe end-to-end: if the consumer slows down, the storage reads stop; if the producer is slow, the encrypt stage idles.
## What the storage layer sees
A 1 GiB file written through this pipeline, with `chunkSize = 65536`:
- Sparse-file layout: one storage key (`files/<fileId>`), 1 GiB of opaque ciphertext, plus one key (`manifests/<fileId>-v<version>`) of ~16 KiB.
- Chunk-keys layout: 16,384 storage keys (`files/<fileId>/<chunkId>`), each ~64 KiB ciphertext, plus the manifest key.
In **neither** case can the storage backend reconstruct the plaintext, the file size in plaintext, the file path, the owner, or the file's relationship to other files. Storage is a black box that holds ciphertext.
## Why this fits enterprise scenarios
- **Encrypted backups** — set the drive's storage to S3, configure server-side encryption *also* if you want defence-in-depth. The bytes hitting S3 are already AEAD-sealed; SSE is icing.
- **Browser drives without trust in the storage origin** — OPFS in a third-party context or IndexedDB in a shared browser is fine: the bytes are ciphertext.
- **Multi-tenant servers** — `NamespacedStorage` per tenant, plus `drive-sharing` partitions, plus per-file `fileKey` — three layers of isolation.
- **Peer-to-peer chunk distribution** — peers serve ciphertext bytes they cannot themselves read (unless they hold the wrapped `fileKey`). The mesh forwards verifiable bytes; only authorised consumers decrypt.
## Reading further
- [`plugins.md`](./plugins.md) — where the chunks plugin sits in the chain.
- [`mesh-and-sharing.md`](./mesh-and-sharing.md) — partition-based key wrapping and grant/revoke.
- [`../network/security-and-tokens.md`](../network/security-and-tokens.md) — message-level signing and the mesh/session token model.

View File

@@ -0,0 +1,71 @@
# Drive — Implementation Status
Source of truth: [`STATUS.md`](../../STATUS.md), the canonical [`openspec/specs/drive/spec.md`](../../openspec/specs/drive/spec.md), and the in-flight changes under `openspec/changes/`.
## Shipped on `genesis`
| Package | Notes |
| --- | --- |
| `@suaveplan/drive` | Thin VFS core. `Drive`, `DriveStorage`, plugin chain, lifecycle, capability fallbacks. Spec-conformance for `DRIVE-01..09`. |
| `@suaveplan/drive-plugin-chunks` | Locked v0x0001 chunk format. Sparse-file + chunk-keys layouts. AEAD encrypt + Ed25519 sign + manifest. 12 spec-conformance tests. |
## Pending — to be ported from `main`
| Package | Description |
| --- | --- |
| `@suaveplan/drive-sharing` | Partition-based access control + envelope encryption + grant/revoke/tokens. |
| `@suaveplan/drive-react-sharing` | React UI — `<ShareDialog>`, `<PartitionBadge>`, `useSharing()`. |
| `@suaveplan/drive-webrtc` | Pre-packaged "browser drive" plugin: chunks + transport-webrtc + signalling, in one. |
## Planned — new on `genesis`
| Package | Why it's new |
| --- | --- |
| `@suaveplan/drive-mesh` | Per-file chunk scheduling, peer scoring, bitset advertisement. The proposal is in `openspec/changes/drive-mesh-port/`. |
| `@suaveplan/drive-transport-https` | HTTPS chunk transport. `GET /v1/chunks/<fileId>/<chunkId>` with Range requests. Server fallback. Proposal in `drive-transport-https-port`. |
| `@suaveplan/drive-transport-webrtc` | WebRTC datachannel chunk transport. `[uint32 BE frameLen][uint8 frameType][payload]` framing. Proposal in `drive-transport-webrtc-port`. |
| `@suaveplan/drive-plugin-cow` | Copy-on-write versioning + snapshots. Re-proposed from the `enterprise-drive` monolith but as its own plugin. |
| `@suaveplan/drive-plugin-dedup` | Content-addressed dedup. Builds on `ContentAddressedStorage`. |
## The architectural conflict to resolve
Two competing proposals targeting the same package name:
| Proposal | Stance | Status |
| --- | --- | --- |
| [`enterprise-drive`](../../openspec/changes/enterprise-drive/proposal.md) | Monolith — chunks/CoW/dedup/snapshots/transforms all in one fat package. | 0% impl. 25 ADDED requirements. **Conflicts with the migrated drive design.** |
| [`drive-port`](../../openspec/changes/drive-port/proposal.md) | Split — drive is a thin VFS core, everything else is sibling packages. | The genesis-canonical design. Already implemented in the migrated `@suaveplan/drive`. Spec is `DRIVE-01..09`. |
**Recommendation:** archive `enterprise-drive`. Re-propose CoW and snapshot from it as `drive-plugin-cow` (a sibling plugin, not part of drive core). Re-propose its useful spec deltas (Zod option schemas, OTel coverage, AbortSignal everywhere) as small additive changes against `openspec/specs/drive/spec.md`.
## In-flight clarification changes (no code, all spec)
| Change | What it resolves |
| --- | --- |
| [`drive-network-integration`](../../openspec/changes/drive-network-integration/proposal.md) | The boundary between `drive-mesh`, `network-plugin-registry`, and `network-plugin-content-delivery`. Pure spec — no code, no spec-delta directory; resolves a 3-way ambiguity in one document. |
| [`enterprise-types-cross-cutting`](../../openspec/changes/enterprise-types-cross-cutting/proposal.md) | Adds `ChunkSource`, `ChunkRef`, `DrivePlugin`, `MiddlewareHandler` to `@suaveplan/types` so plugins don't redeclare them. **Mostly implemented; awaits archive.** |
## Critical-path order
1. **Land `enterprise-types-cross-cutting`** (archive it). This is the unlock for everything below — drive-mesh, drive-transport-*, drive-webrtc, network-plugin-* all reference these types.
2. **Resolve `enterprise-drive` vs `drive-port`** (decide; archive the loser). The genesis recommendation is keep `drive-port` as canonical, archive `enterprise-drive`.
3. **Land `drive-network-integration`** (no-code spec change). Establishes the contract that lets steps 4–7 land independently.
4. **Port `storage-browser-opfs`, `storage-browser`, `storage-file`** (parallel work, all from `main`). Unblocks every browser/server drive scenario.
5. **Implement `drive-transport-https`** (proposal already written). Server-fallback transport — needed before `drive-mesh` ships, because mesh races mesh peers against the server fallback.
6. **Implement `drive-mesh`** (proposal already written). Per-file scheduling. Depends on chunks (shipped), transport-https (step 5), and the network registry (in `network-suite`).
7. **Implement `drive-transport-webrtc`** + port `drive-webrtc`. Browser-to-browser chunk transfer.
8. **Port `drive-sharing` + `drive-react-sharing`**. Access control on top.
9. **Implement `drive-plugin-cow` and `drive-plugin-dedup`**. Sugar; not on the critical path.
Steps 1–3 are zero-code. Step 4 is parallel three-way work. Steps 5–9 are sequential because each depends on the previous interface.
## Test debt to inherit / avoid
The `docs/old-network-planning/test-failures-analysis.md`, `test-isolation-fix-plan.md`, and `test-isolation-implementation-guide.md` document a real test-isolation bug class on the main branch:
- Singleton transport registries that persist across tests (`MemoryTransport._instances`).
- Singleton timer controllers that share state.
- Real `setTimeout` in time-sensitive tests instead of `FakeClock`.
- Voting / partition handlers that retain history between tests.
The genesis testing rule (from `CLAUDE.md`): **no mocks of internal services, real services only.** And the test-infrastructure design (`@suaveplan/testing-integration`) explicitly enforces fixture lifecycle (parallel start, reverse stop, idempotent teardown). Drive tests follow the same rule: every drive test gets a fresh `MemoryStorage`, a fresh `Drive` instance, and a fresh `NetworkNode` if the test exercises the mesh. No shared state between tests.

View File

@@ -0,0 +1,197 @@
# The Two Indexes — Directory Metadata and File Location
The user's question: *"a metadata object of nested directory indexes for efficiency and a file with an index to its location within the nested directories."*
This document specifies that. There are exactly **two indexes** at the drive layer, and both live in storage as bitpacker-encoded blobs at well-known keys.
## Why two indexes
A naive drive uses one storage key per file path. Listing a directory then means scanning every key and filtering by prefix — O(n) over the whole drive. A 1M-file drive becomes painful fast, and a content-addressed backend (`ContentAddressedStorage`) loses path information entirely because the key is a hash of the content.
Two indexes solve this:
1. **Directory index** — for efficient listing and metadata lookup.
2. **File location index** — for resolving a logical path to whatever the storage layer actually keeps.
Both are bitpacker-encoded (`@suaveplan/bitpacker`) so the on-disk size is small, deltas are cheap to gossip across the mesh, and writes are a single `setPartial` against a known offset.
## Index 1 — Directory index
A nested-tree object, stored as a single bitpacker blob at the drive's root metadata key (`<drive-id>/__meta/dirs`). One node per directory:
```ts
interface DirEntry {
name: string; // single path component, no '/'
type: 'dir' | 'file';
fileLocId?: string; // present iff type === 'file'; key into the file location index
childIds?: string[]; // present iff type === 'dir'; ordered list of child DirEntry ids
perm?: number; // POSIX-ish bits, optional
mtime?: number; // unix ms, optional
size?: number; // bytes, optional (mirrored from file metadata for fast list)
}
interface DirIndex {
rootId: string;
entries: Map<string, DirEntry>; // id → DirEntry
version: number; // monotonic; bumps on every write
}
```
A path like `/photos/2026/wedding.jpg` resolves by walking from the root: read `entries[rootId]`, find child with `name === 'photos'`, follow its id, find child `'2026'`, follow, find child `'wedding.jpg'` (type `'file'`, with a `fileLocId`).
### Properties
- **`list(prefix)` is O(depth + children)**, not O(total files), because we only walk the relevant subtree.
- **Stat-without-read** is O(depth) — the size and mtime live on the entry, no need to fetch the file location.
- **Rename is O(1)** — change the `name` field of one entry, increment `version`, write.
- **Move is O(1)** — remove a `childId` from one parent's list, add it to another's.
- **Hardlinks are possible** — two parents can share the same child id (the file location is unaffected). The drive layer policies whether to allow them.
### Concurrency
The directory index is one blob, so concurrent writes need a CAS. Three strategies:
1. **Optimistic CAS** — read, edit, write with `if-match: version`. Loop on conflict.
2. **Per-directory blobs** — split the index into one blob per directory; only the directory being modified takes a CAS. Better at scale, more keys.
3. **CRDT** — use `@suaveplan/crdt` to make the index conflict-free. Right for multi-writer drives where mesh reconciliation matters more than single-write throughput.
Strategy 1 is the genesis default. Strategy 2 kicks in when a single directory regularly exceeds 10k entries. Strategy 3 is opt-in when the drive is multi-writer.
## Index 2 — File location index
A flat map from `fileLocId → FileLocation`, also bitpacker-encoded, stored at `<drive-id>/__meta/locs`:
```ts
interface FileLocation {
fileId: string; // stable, unique per file (not per version)
version: number; // CoW version; bumps on every write
size: number;
checksum: string; // 'blake3:<hex>' for whole-file integrity
storageHints: StorageHint[]; // ordered: fastest first
}
type StorageHint =
| { kind: 'inline'; bytes: Uint8Array } // tiny files (<1 KiB) live in the location entry itself
| { kind: 'whole'; storageId: string; key: string } // whole-blob layout: one storage key holds the file
| { kind: 'sparse-file'; storageId: string; key: string; chunkSize: number } // chunks plugin sparse layout
| { kind: 'chunk-keys'; storageId: string; manifestKey: string } // chunks plugin per-chunk keys + manifest
| { kind: 'mesh'; manifestKey: string }; // mesh-only: this drive doesn't have local copy
```
The `storageHints` array lets a single file have **multiple representations** simultaneously. A file might be:
- inline if tiny,
- in `S3` with `kind: 'whole'` for cold storage,
- in `OpfsStorage` with `kind: 'sparse-file'` for hot local access,
- and known to the mesh (`kind: 'mesh'`) so peers can serve chunks.
When `drive.read(path)` runs, the read middleware resolves path → fileLocId → FileLocation, then walks `storageHints` in order until one yields bytes. The chunks plugin reads `manifestKey` to learn the per-chunk layout and reassembles. The mesh plugin tries peers for any missing chunk.
## How a write happens
```text
drive.write("/photos/2026/wedding.jpg", bytes)
↓
[middleware: chunks plugin] split bytes into chunks, encrypt each (XChaCha20-Poly1305),
sign each (Ed25519), build manifest, sign manifest
↓
[middleware: dedup plugin (optional)] hash content; if it already exists in the
content-addressed pool, skip the write and
just point at the existing chunks
↓
[core: storage write] write each chunk to the chosen storage layout
(sparse-file or chunk-keys), write the manifest
↓
[middleware: chunks plugin (post)] return new FileLocation with the chunk pointer
↓
[drive: index update] CAS-update the file location index with the new FileLocation
CAS-update the directory index with the new DirEntry
(creating intermediate directories as needed)
↓
[middleware: mesh plugin (post)] gossip the new chunk inventory to peers
via NetworkBus 'cd:advertise'
```
## How a read happens
```text
drive.read("/photos/2026/wedding.jpg")
↓
[drive: index resolve] walk dir index from root → DirEntry → fileLocId → FileLocation
↓
[middleware: chunks plugin (pre)] load manifest, parse chunk references
↓
For each chunk:
[middleware: cache plugin (optional)] check local cache
[core: storage read] try storageHints in priority order:
'inline' → return bytes
'whole' → storage.get(key)
'sparse-file' → storage.getPartial(key, chunkOffset, chunkOffset + chunkSize)
'chunk-keys' → storage.get(`${storageId}/${chunkId}`)
'mesh' → ask drive-mesh, which races peers via NetworkBus 'cd:request'
↓
[middleware: chunks plugin (verify)] verify Ed25519 sig, decrypt AEAD, check BLAKE3
↓
[middleware: chunks plugin (reassemble)] concat into the original bytes
↓
return Uint8Array
```
## Why bitpacker, not JSON
The two indexes are read on every `list`, `stat`, and `read` — they belong in memory. Bitpacker buys us:
- **Compactness** — typical directory entry is ~20 bytes vs ~120 for JSON.
- **Schema evolution** — versioned schemas with forward/backward compatibility live in `@suaveplan/schema-registry` and `@suaveplan/bitpacker`.
- **Delta gossip** — the mesh advertises *changes* to the index, not the whole index. A 1 MiB index with 100 changes/s gossips ~2 KiB/s.
JSON is fine for the user-facing snapshot/export format. It's not fine for the hot-path index.
## Path-to-key mapping summary
```typescript
"/photos/2026/wedding.jpg"
↓ (DirIndex walk)
DirEntry { name: "wedding.jpg", type: "file", fileLocId: "f-1729...." }
↓ (FileLocation lookup)
FileLocation { fileId: "f-1729...", version: 3, storageHints: [
{ kind: "sparse-file", storageId: "local", key: "files/f-1729...", chunkSize: 65536 },
{ kind: "mesh", manifestKey: "manifests/f-1729...-v3" },
] }
↓ (chunks plugin)
manifest = bitpacker.decode(storage.get("manifests/f-1729...-v3"))
→ 14 chunks, each at offset i * 65536, signed by drive-id, AEAD-encrypted with file key
↓ (per-chunk read + verify + decrypt)
Uint8Array (the photo)
```
This is the whole "metadata object of nested directory indexes + a file with an index to its location" you asked for, expressed as a precise contract.
## How a 100-file drive maps onto the storage backend
A common question: if I save 100 files through the drive, do I get 100 files on disk, or one big partition file? The answer depends on which backend you configure. The drive layer's two indexes plus the chunks plugin's layout selection produce different on-disk shapes:
| Backend | Files-on-disk for 100 logical files (~50 MiB each) | Layout | Why |
|---|---|---|---|
| `MemoryStorage` | 0 (in-process map) | sparse-file | `partialWrites=true` |
| `FileStorage` (Node fs) | **~202** (100 chunk-files + 100 manifests + 2 indexes) | sparse-file | `partialWrites=true` |
| `OpfsStorage` (browser) | ~202 OPFS handles | sparse-file | `partialWrites=true` |
| `S3` | **~80,202 objects** (one per chunk + manifests + indexes) | chunk-keys | `partialWrites=false`, RMW too expensive |
| `IndexedDbStorage` | ~80,202 IDB rows | chunk-keys | `partialWrites=false` |
| `SqliteStorage` | **1** `.sqlite` file with ~80k blob rows | sparse-file via `incremental_blob_open` | One file ≈ one partition |
| `FatStorage` | **1** FAT disk image (mountable by the OS) | sparse-file inside the FS-in-a-file | True "partition file" |
| `ContentAddressedStorage` wrapping any backend | ~80k unique chunk-blobs (deduped across files) | chunk-keys | Hashes coalesce identical content |
So `FileStorage` gives you ~2 files per logical file (the sparse chunk-file plus its manifest). `S3` gives you ~800 objects per logical file (one per chunk plus manifest). `SqliteStorage` and `FatStorage` give you genuine one-file-equals-one-partition semantics — pick these when you need atomic backup, mountable export, or fixed-size budgets.
Tiny files (<1 KiB) skip the chunks pipeline entirely via the `kind: 'inline'` storage hint — bytes live inside the FileLocationIndex itself. So a 100-file drive where 30 files are <1 KiB is closer to **172 files on disk** with `FileStorage`.
The drive layer never produces "100 files mirroring user paths in plaintext on disk" — that is by design, because path mirroring leaks structural metadata to whichever backend holds the bytes (S3 admins, OPFS quotas, FAT disk inspectors). If plaintext path-mirror semantics are genuinely required, skip the drive layer and write to `FileStorage` directly; the drive's whole job is to abstract over storage.
## Reading further
- [`plugins.md`](./plugins.md) — what each middleware in the chain actually does.
- [`encryption.md`](./encryption.md) — the chunks plugin in cryptographic detail.
- [`mesh-and-sharing.md`](./mesh-and-sharing.md) — what `kind: 'mesh'` means.
- [`mesh-protocol.md`](./mesh-protocol.md) — the chunk-request envelope forms (whole-file, specific, bitset).
- [`dehydrated-drives.md`](./dehydrated-drives.md) — server-signed templates that pre-configure folder structure with bytes arriving later.

View File

@@ -0,0 +1,144 @@
# Drive Mesh and Sharing
This document covers two of the most-asked-about pieces of the drive layer:
1. How **n drives** find each other and replicate chunks (`drive-mesh`).
2. How files become **shareable** between specific drives (`drive-sharing`).
Both are plugins that compose with the chunks plugin and the network layer.
## `drive-mesh` — per-file scheduling
`drive-mesh` makes a drive participate in a chunk-sharing mesh. It does **not** own peer identity or per-peer health — those live in `network-plugin-registry`. Mesh's job is, for one specific file:
1. **Advertise** which chunks of which fileIds this drive holds (gzip-compressed bitset).
2. **Discover** which peers hold the chunks this drive needs (read advertisements off the bus).
3. **Schedule** a fetch plan: which chunks to pull from which peers, with parallel races and per-chunk timeouts.
4. **Score** peers per file: a peer that gave you a bad signature gets `-Infinity`; a peer that consistently delivers fast bumps up.
Spec excerpts:
- `MESH-DEPS-01`: mesh consumes `ChunkSource` from the chunks plugin; it does not import drive core.
- `MESH-SCHED-01..03`: parallel-pull window, per-chunk timeout, server fallback.
- `MESH-SCORE-01`: bad sig → score `-Infinity` permanently.
- `MESH-GOSSIP-01`: bitset advertisements over the network bus.
- `MESH-FETCH-FILE-01`: end-to-end happy path — mesh resolves, requests, verifies, hands plaintext up the chain.
### The mesh fetch flow (one file)
```text
drive-mesh.fetchFile(fileId)
│
├── 1. fetch manifest (peer-first, server-fallback)
├── 2. local check: which chunks do we already have?
├── 3. for each missing chunk:
│ ├── pick top-N candidates from per-peer score
│ ├── race them; first verifying response wins
│ ├── on bad sig: blacklist that peer, retry next candidate
│ ├── on timeout: try the next candidate
│ └── on full miss: fall through to drive-transport-https (server)
├── 4. verify each chunk's Ed25519 sig + AEAD tag
├── 5. reassemble; verify whole-file BLAKE3
└── 6. emit `cd:advertise` so other peers know we now have these chunks
```
### `drive-mesh` does NOT do
- **Peer discovery.** That's the network discovery plugins (`network-plugin-registry`, `network-discovery-*`).
- **Mesh-wide redundancy** (ensuring N copies of every chunk exist). That's `network-plugin-content-delivery`.
- **Cross-mesh relay.** That's `network-plugin-relay`.
- **The actual transport.** That's `drive-transport-webrtc` / `drive-transport-https`.
Mesh sits in the middle: scheduler for one specific file's chunks, leaving identity, redundancy, and bytes-on-wire to the layers around it.
## `drive-sharing` — partitions and access control
`drive-sharing` is the access-control layer. The unit of access is a **partition**.
### Mental model
Think of a drive as a tree of partitions. The root partition belongs to the drive's owner. Sub-partitions can be created and granted to other drives. Each partition has its own key-encryption-key (KEK); files inside a partition are encrypted with per-file `fileKey`s, each of which is wrapped with the partition's KEK.
```text
drive (alice@example.com)
└── root partition (KEK_root)
├── /personal/... (file keys wrapped by KEK_root)
└── shared partition "wedding" (KEK_wedding)
├── /wedding/photo1.jpg (file key wrapped by KEK_wedding)
└── /wedding/photo2.jpg
```
Granting `bob@example.com` access to "wedding" means: rewrap `KEK_wedding` with bob's public key (X25519 sealed-box) and hand bob the wrapped blob. Bob's drive unwraps with its private key, caches `KEK_wedding`, and can now unwrap any `fileKey` in that partition.
### Public API (from `@suaveplan/drive-sharing` proposal)
| Component | Role |
| --- | --- |
| `PartitionManager` | Create/delete/list partitions. Each has an id and a KEK. |
| `KeyRing` | Holds the unwrapped KEKs the current drive has access to. |
| `WebCryptoProvider` | KEK wrap/unwrap. X25519 sealed-box plus AES-KW for symmetric rewrap. |
| `GrantManager` | `grant(partition, peerPubKey)` / `revoke(partition, peerPubKey)`. |
| `TokenManager` | Time-limited share tokens (URL-safe) — give someone read access for 24h without exchanging keys. |
| `TransferManager` | Move a file between partitions: rewrap its `fileKey` from the source KEK to the destination KEK. |
### What revocation actually does
Naive revocation is impossible after the fact: a peer who has already decrypted a chunk will always have the plaintext. Revocation in this design is *forward*:
1. `revoke(partition, bob)` removes bob from the partition's authorized list.
2. The next `write()` to that partition rotates `KEK_wedding` to `KEK_wedding'`.
3. New `fileKey`s are wrapped with `KEK_wedding'`. Old `fileKey`s remain wrapped with `KEK_wedding`.
4. Files written before revocation are *re-keyed* lazily: on first read after revocation, the drive rewraps the `fileKey` with the new KEK and re-uploads the wrap blob (not the data — just the small wrap).
Bob can read what he already cached. He cannot read anything new, including content uploaded after revocation. This is the standard envelope-encryption revocation pattern.
### Tokens vs grants
- **Grants** are per-peer-identity (X25519 public key). Revocable. Use for stable relationships.
- **Tokens** are bearer (anyone with the token can use it for the validity window). Self-expiring. Use for one-off shares ("here's a link to the wedding photos for 24 hours").
`TokenManager` builds tokens server-side (or client-side with a server signature, depending on the deployment). The token contains the partition id, an expiry, and a wrapped KEK. The receiver's drive unwraps and uses it like any other KEK until expiry.
### React UI
`@suaveplan/drive-react-sharing` ships:
- `<ShareDialog drive={drive} path={...} />` — full UI for granting/revoking/tokenizing.
- `<PartitionBadge>` — show which partition a file belongs to.
- `<FileAccessList>` — render the current grant list.
- `useSharing()` — hook that exposes the imperative API.
It is a thin UI layer over `@suaveplan/drive-sharing` — no business logic of its own.
## Composition — mesh + sharing + chunks
The four plugins (`chunks`, `sharing`, `mesh`, `transport-*`) compose cleanly because they each touch a different concern:
| Plugin | Concern | Where it intercepts |
| --- | --- | --- |
| `chunks` | Confidentiality + integrity of bytes on disk and on wire | `drive:read` (verify+decrypt), `drive:write` (encrypt+sign) |
| `sharing` | Authorisation: which drives can read which partition | `drive:read` (unwrap fileKey), `drive:write` (wrap fileKey) |
| `mesh` | Where to fetch missing chunks | `drive:read` (when local store misses) |
| `transport-*` | Bytes on the wire to/from peers | Below mesh; serves chunks-by-id |
Chunks alone gives you encryption-at-rest. Chunks + sharing gives you encryption-at-rest with multi-drive access control. Chunks + sharing + mesh + transport gives you n drives that share files end-to-end-encrypted across a tiered network.
## Cross-references with the network layer
The `drive-network-integration` change folder ([proposal](../../openspec/changes/drive-network-integration/proposal.md)) is the canonical contract. Summary:
| Direction | Event | Producer | Consumer | Meaning |
| --- | --- | --- | --- | --- |
| Out | `cd:advertise` | `drive-mesh` | `network-plugin-content-delivery` | "I have these chunks of these fileIds" |
| Out | `cd:request` | `drive-mesh` | `network-plugin-content-delivery` | "Anyone got chunk X of file Y?" |
| In | `cd:response` | `network-plugin-content-delivery` | `drive-mesh` | "Here's the chunk" (verifies via chunks plugin) |
| In | `peer:joined`/`peer:left`/`peer:updated` | `network-plugin-registry` | `drive-mesh` | Peer identity lifecycle |
`drive-mesh` knows nothing about *which* transport delivers a chunk. `network-plugin-content-delivery` knows nothing about *which* file a chunk belongs to. The bus carries content-addressed chunk requests; everything else is layer-local.
## Reading further
- [`plugins.md`](./plugins.md) — the full plugin chain.
- [`encryption.md`](./encryption.md) — the chunks plugin in detail.
- [`../network/plugins-catalog.md`](../network/plugins-catalog.md) — `network-plugin-content-delivery` and `network-plugin-relay`.
- [`../network/topology-and-failover.md`](../network/topology-and-failover.md) — how the mesh tolerates partitions and server outages.

301
docs/drive/mesh-protocol.md Normal file
View File

@@ -0,0 +1,301 @@
# Mesh Protocol — Chunk Requests, Advertisements, Responses
This is the wire-protocol spec for how drives request and exchange chunks across the mesh. It encodes the three-form chunk request, the bitpacker-compressed presence bitset, the response piggyback, and the trust model.
This document is the resolution of [`system-review.md`](../system-review.md) §1, §2, §10, §11. The spec belongs in `openspec/specs/drive-mesh/spec.md` once the proposal lands; for now this is the architectural reference.
---
## 1. Why bitsets
A 50 MiB file at 65,536-byte chunks = 800 chunks. Two clients meeting on the mesh must:
1. Tell each other what they have (advertise).
2. Request what they need.
A naïve protocol uses one envelope per chunk. With 800-chunk files and a 50-peer mesh, every advertisement round = 40,000 envelopes. A bitset is two orders of magnitude smaller. With `@suaveplan/bitpacker-compressed` (RLE + DEFLATE), a typical 800-bit presence map encodes to <100 bytes when most chunks are present or absent.
The mesh protocol is therefore **bitset-first**: every advertisement and every dense request uses a bitset. Sparse selections (≤64 chunks) use an array; the cutover threshold matches the bytes-on-wire break-even.
---
## 2. The three request forms
```ts
type ChunkRequest =
| { kind: 'whole' }
| { kind: 'specific'; chunkIds: number[] }
| { kind: 'bitset'; bytes: Uint8Array };
```
| Form | When to use | Wire size (800-chunk file) |
|---|---|---|
| `whole` | Initial fetch; receiver returns everything it has | 1 byte (the discriminator) |
| `specific` | Sparse selection (≤64 chunks needed) | 4 bytes per chunkId × N |
| `bitset` | Dense selection (>64 chunks) | typically <100 bytes after `bitpacker-compressed` |
The drive-mesh layer chooses the form per-request based on the chunk-presence delta. Switching is automatic; the receiver supports all three.
### `ChunkRequestEnvelope`
```ts
interface ChunkRequestEnvelope extends MessageEnvelope {
type: 'cd:request';
payload: Uint8Array; // bitpacker-encoded ChunkRequestPayload
}
interface ChunkRequestPayload {
fileId: string; // 32-byte hex
request: ChunkRequest;
priority: 'critical' | 'high' | 'normal' | 'low' | 'bulk';
maxChunks?: number; // cap on response payload (default 64)
signal?: AbortSignal; // not on wire — local cancellation
}
```
Encoded via `@suaveplan/bitpacker-network` for the envelope frame; the `bytes` of the bitset use `@suaveplan/bitpacker-compressed`.
### Empty-array semantics
The user's brief mentioned "supplying the file id and an empty chunk ids array" to mean "everything." The current spec uses `{ kind: 'whole' }` instead of `{ kind: 'specific'; chunkIds: [] }` to disambiguate — empty array could mean "nothing" or "everything" depending on reader bias. `kind: 'whole'` is unambiguous; `kind: 'specific'` with `chunkIds: []` is rejected at validation as a no-op request.
---
## 3. The advertisement envelope
```ts
interface ChunkAdvertiseEnvelope extends MessageEnvelope {
type: 'cd:advertise';
payload: Uint8Array; // bitpacker-encoded ChunkAdvertisePayload
}
interface ChunkAdvertisePayload {
fileId: string;
presence: Uint8Array; // bitset, bit i = 1 iff peer has chunk i
totalChunks: number;
// Generation counter — increments on every change to this peer's presence
// map for this file. Lets receivers ignore out-of-order advertisements.
generation: number;
timestamp: number; // injectable Clock
}
```
### Encoding
- `presence` uses `@suaveplan/bitpacker-compressed`'s RLE encoder. Run-length is ideal for chunk presence because chunks tend to arrive in bursts (sequential downloads) — most bitsets have long 1-runs and long 0-runs.
- `presence.length` (in bits) MUST equal `totalChunks`. Padding bits in the final byte MUST be zero.
- A bit set to 1 means: "I have a verified chunk for this position." A bit set to 0 means either "I don't have it" or "I have it but haven't verified the signature yet."
### When to advertise
Drives gossip advertisements:
- On startup, for every file in the FileLocationIndex.
- On `cd:advertise` from another peer (transitive — but only the delta since our last advert; throttled to one outgoing per file per second).
- On every `cd:response` we receive (we now have new chunks; tell our neighbors).
### Generation counter
`generation` lets receivers handle out-of-order advertisements:
```text
if (advert.generation < lastSeen[peerId][fileId]) drop;
else lastSeen[peerId][fileId] = advert.generation;
```
Wraps modulo 2^32 — wrap is rare enough that we tolerate a one-cycle anomaly.
---
## 4. The response envelope
```ts
interface ChunkResponseEnvelope extends MessageEnvelope {
type: 'cd:response';
payload: Uint8Array; // bitpacker-encoded ChunkResponsePayload
}
interface ChunkResponsePayload {
fileId: string;
chunks: ChunkBytes[]; // ordered by chunkId asc
presence: Uint8Array; // piggyback: my full bitset for this file (free advert)
generation: number;
}
interface ChunkBytes {
chunkId: number;
bytes: Uint8Array; // 164-byte chunk header + ciphertext (signed by writer)
}
```
### Why piggyback presence
A response is the perfect time to update peer-index for `(peerId, fileId)`. The receiver got the chunks they wanted *and* learned the holder's full inventory in one round-trip. This eliminates a separate advertisement cycle for active partners. Cost: ~100 bytes per response. Benefit: skip ~200 bytes + RTT for a follow-up advertisement.
### Per-chunk verification
Each `ChunkBytes.bytes` is the locked v0x0001 chunk format from [`encryption.md`](./encryption.md):
```text
metadata(100) ‖ sig(64) ‖ ciphertext(variable)
```
The receiver verifies the Ed25519 sig against the writer's pubkey (from the registry), checks the AEAD tag, and only then commits the bytes. A bad sig drops the response and demotes the responder's score (see §6).
---
## 5. Response sizing and chunking-of-the-response
A 64-chunk response at 65,536 bytes per chunk = 4 MiB. That is too large for a single WebRTC datachannel message (WebRTC SCTP fragmentation breaks down past 16 KiB cleanly). The drive-transport layer fragments the envelope; the protocol layer caps:
- `maxChunks` defaults to 64; receivers MUST honor it.
- Responses larger than 1 MiB are sent as multiple `cd:response` envelopes with the same correlation id.
- WebRTC backpressure (`bufferedAmount > 16 MiB`) blocks further sends — see `TRANSPORT-WRTC-04`.
For very large transfers, the responder may choose to return a partial response and emit `cd:advertise` for the rest, letting the requester re-pull on its own pace.
---
## 6. Trust and verification
### Probabilistic trust model
A peer that consistently advertises chunks it doesn't actually have wastes the requester's time even if it never sends a bad signature. The trust model handles this:
```text
For each (peerId, fileId):
advertised: bitset of "has it according to advertisements"
delivered: bitset of "actually delivered when asked"
missing: bitset of advertised-but-failed-on-request
trustScore = 1 - (popcount(missing) / popcount(advertised))
P(skip request) = max(0, 0.05 - trustScore) * 20 // demotes harshly past 5% miss
```
Bad signature: immediate `trustScore = -Infinity` (existing `MESH-SCORE-01`); we never request from this peer for this file again.
Per-file scope: a peer good for one file doesn't earn blanket trust for others. Avoids reputation laundering.
### Sample verification on advertisement
Optionally, the receiver can probe: pick one chunk from `presence`, request it, verify. If the bit is set but the chunk isn't deliverable, demote. Sampling rate is adaptive: 1-in-100 by default, raised to 1-in-10 if a peer enters "suspect" status.
---
## 7. Worked example — 800-chunk file, 50% present locally
Alice's drive needs file `f-1729...` (800 chunks). She has chunks 0–399 locally.
```text
1. Alice constructs a "needed" bitset: 0b0...01...1 (400 zeros, 400 ones).
bitpacker-compressed RLE encode: ~12 bytes.
2. Alice broadcasts cd:request:
{ fileId: 'f-1729...', request: { kind: 'bitset', bytes: <12 bytes> },
priority: 'normal', maxChunks: 64 }
Total envelope: ~50 bytes.
3. Three peers (Bob, Carol, Dave) reply:
- Bob: has 400-799. Sends 64 chunks (bytes ~4.2 MiB, fragmented), plus
piggyback presence (RLE: ~10 bytes).
- Carol: has 400-700. Sends 64 chunks of 400-463.
- Dave: has 500-700 only. Sends 64 chunks of 500-563.
4. Alice's mesh layer races them; first verifying response wins per chunk.
- Chunks 400-463: Carol wins (lower latency).
- Chunks 464-499: Bob wins (Carol doesn't have).
- Chunks 500-563: race between Bob/Carol/Dave.
- Chunks 564-700: race between Bob/Carol.
- Chunks 700-799: Bob wins (sole holder).
5. Each verified chunk is stored, presence bit flipped, gossiped via cd:advertise.
6. Alice's progress UI:
t=0: ███░░░░░░░ 50%
t=2s: █████░░░░░ 60% (Carol's batch verified)
t=4s: ███████░░░ 80% (Bob's batch verified)
t=6s: ██████████ 100%
```
Total round trips: 3 (one per responder). Total bandwidth (excluding chunk bytes): ~200 bytes of metadata. Without bitset compression, the same exchange would have generated 800 single-chunk requests.
---
## 8. Error envelopes
```ts
interface ChunkErrorEnvelope extends MessageEnvelope {
type: 'cd:error';
payload: {
fileId: string;
correlationId: string; // matches the cd:request id
code: ChunkErrorCode;
chunkId?: number; // present for chunk-specific errors
retryAfterMs?: number; // backpressure hint
};
}
type ChunkErrorCode =
| 'NOT_FOUND' // requested chunk genuinely absent
| 'ACCESS_DENIED' // policy prevents serving (e.g. unsigned trust token)
| 'BANDWIDTH_EXCEEDED' // QoS quota hit; retryAfterMs set
| 'GENERATION_STALE' // requester's presence info is older than ours
| 'INTERNAL';
```
`GENERATION_STALE` is informational — the responder included its current generation in the error so the requester can update without a separate advertisement.
---
## 9. Integration with `network-plugin-content-delivery`
`drive-mesh` produces and consumes these envelopes via `NetworkBus`. `network-plugin-content-delivery` is the *transport-level* plugin that routes them:
| Direction | Bus topic | Producer | Consumer |
|---|---|---|---|
| Outbound | `cd:request` | `drive-mesh` | `network-plugin-content-delivery` (forwards to peers) |
| Outbound | `cd:advertise` | `drive-mesh` | `network-plugin-content-delivery` (gossips to peers) |
| Outbound | `cd:response` | `drive-mesh` (when serving) | `network-plugin-content-delivery` (forwards to requester) |
| Inbound | `cd:request` | `network-plugin-content-delivery` | `drive-mesh` (decides what to serve) |
| Inbound | `cd:response` | `network-plugin-content-delivery` | `drive-mesh` (verifies + stores) |
| Inbound | `cd:advertise` | `network-plugin-content-delivery` | `drive-mesh` (updates peer-index) |
`network-plugin-content-delivery` does not parse the bitsets or verify signatures. It is a forwarder + redundancy enforcer. All chunk-level intelligence lives in `drive-mesh` + `drive-plugin-chunks`.
---
## 10. Bitpacker package usage map
| Concern | Package |
|---|---|
| Bitset encode/decode (presence, request) | `@suaveplan/bitpacker-compressed` |
| Envelope schema validation | `@suaveplan/bitpacker-schema` |
| On-wire framing | `@suaveplan/bitpacker-network` |
| Streaming for large responses | `@suaveplan/bitpacker-streaming` |
| Future: delta-encoded advertisements | `@suaveplan/bitpacker-delta` (when generation diffs become large) |
The codecs are interoperable — a bitset encoded by `bitpacker-compressed` is decoded the same way regardless of which sub-package framed the envelope.
---
## 11. Test contract
The `drive-mesh-protocol-v1` change folder will ship these conformance tests:
- `tests/three-form-roundtrip.test.ts` — every `ChunkRequest` form roundtrips through encode/decode.
- `tests/bitset-rle.test.ts` — fast-check property: encode(decode(x)) = x for random bitsets.
- `tests/generation-monotonic.test.ts` — out-of-order advertisements drop correctly.
- `tests/piggyback-presence.test.ts` — receiving a `cd:response` updates peer-index without a separate `cd:advertise`.
- `tests/trust-decay.test.ts` — repeatedly-advertising-but-not-delivering peer loses trust score predictably.
- `tests/cap-respected.test.ts` — `maxChunks` is honored by responders.
---
## Reading further
- [`mesh-and-sharing.md`](./mesh-and-sharing.md) — the `drive-mesh` plugin proper.
- [`encryption.md`](./encryption.md) — chunk format that the responses carry.
- [`dehydrated-drives.md`](./dehydrated-drives.md) — how mesh protocol is bypassed when CDN fallback is in play.
- [`../network/plugins-catalog.md`](../network/plugins-catalog.md) — `network-plugin-content-delivery` and `network-plugin-relay`.
- [`../system-review.md`](../system-review.md) — the review this doc resolves.

124
docs/drive/plugins.md Normal file
View File

@@ -0,0 +1,124 @@
# Drive Plugins
The drive core does five things: lifecycle, path resolution, the two indexes, the middleware chain, and capability fallbacks. Everything else is a plugin.
This document is the catalogue of plugins (shipped, planned, and conventional) and how they compose.
## The plugin contract
```ts
interface DrivePlugin {
readonly name: string;
readonly hooks: Record<string, PluginHook>;
readonly priority?: number; // higher first
}
type PluginHook = (context: unknown, next: NextFunction) => Promise<unknown>;
```
Plugins register hooks against operation names — `drive:read`, `drive:write`, `drive:delete`, `drive:stat`, `drive:list`, `drive:initialize`, `drive:dispose`. Hooks run in priority order (high → low) on the request path, then the same order in reverse on the response path. A hook can:
- transform the args before delegating (`await next()`),
- transform the result after delegating,
- short-circuit by *not* calling `next()` and returning a value directly,
- throw, propagating up the chain.
`useMiddleware(mw)` is a thin sugar for a plugin whose only hook is the universal `drive:*` interceptor.
## Plugin catalogue
| Plugin | Status | Responsibility |
| --- | --- | --- |
| `chunks` (`@suaveplan/drive-plugin-chunks`) | **shipped** | Splits files into signed, encrypted chunks. Owns the `manifest`. Owns layout selection (sparse-file vs chunk-keys). |
| `sharing` (`@suaveplan/drive-sharing`) | pending — `main` | Partition-based access control. Envelope encryption. Grant/revoke/rotate. |
| `react-sharing` (`@suaveplan/drive-react-sharing`) | pending — `main` | React UI for the sharing engine — `<ShareDialog>`, `<PartitionBadge>`, `useSharing()`. |
| `mesh` (`@suaveplan/drive-mesh`) | planned (genesis-original) | Per-file chunk scheduling: which peer to ask for which chunk. Bitset advertisement. Per-peer scoring. |
| `transport-https` (`@suaveplan/drive-transport-https`) | planned (genesis-original) | HTTPS chunk transport — server fallback when WebRTC isn't available. `GET /v1/chunks/<fileId>/<chunkId>` with Range. |
| `transport-webrtc` (`@suaveplan/drive-transport-webrtc`) | planned (genesis-original) | WebRTC datachannel chunk transport — browser↔browser. `[uint32 BE frameLen][uint8 frameType][payload]` framing. |
| `webrtc` (`@suaveplan/drive-webrtc`) | pending — `main` | A pre-packaged "browser drive" plugin: chunks + transport-webrtc + signalling, in one. |
| `cache` (`@suaveplan/cache`) | shipped | Read-through cache middleware. Drop in to cache decrypted plaintext. |
| `cow` (planned) | planned | Copy-on-write versioning. Multiple `version` numbers per `fileId`. Snapshot create/restore/diff. |
| `dedup` (planned) | planned | Content-addressed dedup. Hashes content, points multiple `fileLocId`s at the same chunks. |
| `metadata` (planned, see `streams-metadata`) | planned | Side-channel metadata extraction (EXIF/MIME/audio) on every write. |
## How the chain runs end-to-end
A typical browser drive:
```ts
const drive = new Drive(opfsStorage)
.usePlugin(chunksPlugin({ chunkSize: 65536, signing: { secretKey } }))
.usePlugin(sharingPlugin({ partitions, kms }))
.usePlugin(meshPlugin({ networkNode }))
.usePlugin(transportWebrtcPlugin({ signaller }))
.useMiddleware(loggingMiddleware);
```
Registration order matters:
```text
drive.read("/photo.jpg")
│
┌────────────▼────────────┐
│ logging (pre) │
│ chunks (pre) │ load manifest
│ sharing (pre) │ unwrap envelope key
│ mesh (pre) │ prepare peer scoring
│ transport (pre) │ open peer connections
└────────────┬────────────┘
▼
storage.get
│
┌────────────▼────────────┐
│ transport (post)│ fetch missing chunks via WebRTC
│ mesh (post) │ reassemble
│ sharing (post) │ decrypt envelope
│ chunks (post) │ verify+decrypt+concat
│ logging (post) │ log duration
└────────────┬────────────┘
▼
Uint8Array
```
Each layer is independent. Strip any one and the layers above keep working — `mesh` without `transport-webrtc` falls back to `transport-https`; `sharing` without `mesh` works on a single-node drive; `chunks` without `mesh` works with no network at all.
## Cross-layer ownership boundaries
The genesis design is meticulous about which layer owns what. The `drive-network-integration` change folder ([proposal](../../openspec/changes/drive-network-integration/proposal.md)) is the canonical resolution document; the summary is:
| Concern | Owner | Why |
| --- | --- | --- |
| Peer identity & lifecycle | `network-plugin-registry` | Peers exist independent of files; one registry per mesh. |
| Per-file chunk-availability bitsets | `drive-mesh` | Specific to a file's manifest; doesn't belong in the generic registry. |
| Per-file chunk scheduling (which peer to ask, in what order) | `drive-mesh` | Highly file-specific; uses peer health from registry but decides locally. |
| Mesh-wide chunk redundancy (N copies, prefetch, integrity) | `network-plugin-content-delivery` | Cuts across files; lives at the network layer because it's about peers, not files. |
| Cross-mesh forwarding for chunks | `network-plugin-relay` | Same as message relay; a chunk request is just another envelope. |
The interaction is event-driven via `NetworkBus`:
- `peer:joined` / `peer:left` / `peer:updated` from `network-plugin-registry`.
- `cd:advertise` from `drive-mesh` (this drive has these chunks).
- `cd:request` from `drive-mesh` (this drive needs this chunk).
- `cd:response` from `network-plugin-content-delivery` (here it is).
That keeps `drive-mesh` blissfully unaware of *which* transport is fetching the chunk; it just listens for `cd:response`.
## Conventions for writing a new plugin
1. **No drive imports.** Use `DrivePlugin`, `MiddlewareHandler`, and `MiddlewareContext` from `@suaveplan/types`. Never import `Drive` itself — that creates a circular dep.
2. **Idempotent install.** A plugin's install hook may run more than once if a drive is reinitialized. Track state on the plugin instance, not in module scope.
3. **Reverse dispose.** If your plugin allocates anything (sockets, workers, watchers), free it in `drive:dispose`. The drive guarantees this hook runs in reverse-registration order, so plugins added later get to clean up first.
4. **Surface errors as `DriveError`.** Wrap thrown errors as `DriveError(DRIVE.PLUGIN_FAILED, { cause })` so consumers see one error type at the boundary.
5. **Use `@suaveplan/types` for backend-agnostic capabilities.** If you need to know what the storage can do, read `storage.capabilities`. If you need to know what the network can do, read it through `NetworkBus` capability events. Don't hardcode.
## Why this is split, not a fat library
A drive that runs in a browser without WebRTC just doesn't install the WebRTC plugin. A server drive in a single-node deployment doesn't install `mesh`. A test drive doesn't install any plugin at all and uses `MemoryStorage` directly. None of those configurations cost extra bytes or extra surface area, because the core knows nothing about chunks, peers, or partitions — it just runs whatever middleware you registered.
This is the genesis bet: composability over completeness. The `enterprise-drive` monolith design tried the other way and ended up with a 2 MB package that imports half the platform on the import line. The split keeps every drive minimal.
## Reading further
- [`encryption.md`](./encryption.md) — the chunks plugin in detail.
- [`mesh-and-sharing.md`](./mesh-and-sharing.md) — the mesh and transport plugins.
- [`../network/plugins-catalog.md`](../network/plugins-catalog.md) — the network plugins these drive plugins talk to.