Files
2026-05-27 23:29:53 +01:00

273 lines
17 KiB
Markdown

# Capability Spec Delta: drive-template (drive-template-v1)
**Capability**: `drive-template`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` §13.)
---
## ADDED Requirements
### Requirement: DriveTemplate shape
The package SHALL export a `DriveTemplate` interface with required fields: `schemaVersion: 1`, `driveId: string`, `rootFolder: FolderTemplate`, `policy: GlobalPolicy`, `issuer: string` (Ed25519 pubkey hex), `issuedAt: number`, `expiresAt: number`, `nonce: Uint8Array(16)`, `serverSignature: Uint8Array`. Optional fields: `cdnFallback?: CdnFallbackConfig`. `FolderTemplate` SHALL be `{ name: string; children: (FolderTemplate | FileTemplate)[] }`. `FileTemplate` SHALL include `name`, `fileLocId`, `fileId`, `size`, `totalChunks`, `chunkSize`, `checksum`, `flags: PerFilePolicy`, `manifestKey`, `manifestSig`, and optional `encryption: FileEncryption`.
**Verified by:** `tests/template-roundtrip.test.ts`::"DriveTemplate with all required fields encodes/decodes"
#### Scenario: Minimal template
- **WHEN** a template with empty rootFolder, no cdnFallback, and minimal GlobalPolicy is signed and verified
- **THEN** validation passes and hydration produces an empty drive
---
### Requirement: Schema version pinned
`schemaVersion` SHALL equal `1` in this revision. Templates with `schemaVersion !== 1` SHALL be rejected with `DRIVE.TEMPLATE_VERSION_UNSUPPORTED`. Future schema versions SHALL ship as new packages (`@suaveplan/drive-template-v2`) — never as in-place modifications.
**Verified by:** `tests/template-malformed.test.ts`::"schemaVersion ≠ 1 rejected with TEMPLATE_VERSION_UNSUPPORTED"
---
### Requirement: Canonical serialization is deterministic
The package SHALL provide `canonicalSerialize(template: Omit<DriveTemplate, 'serverSignature'>): Uint8Array` that produces a deterministic byte representation. The serializer SHALL use `@suaveplan/bitpacker-schema` with fixed field order. JSON SHALL NOT be used because object-key ordering is not portable. Two implementations of `canonicalSerialize` MUST produce byte-identical output for identical input.
**Verified by:** `tests/template-roundtrip.test.ts`::"canonicalSerialize is deterministic across calls"
#### Scenario: Field-reorder produces identical bytes
- **WHEN** the same template is serialized twice with different in-memory key ordering
- **THEN** the output byte sequences are identical
---
### Requirement: Server signature covers canonical bytes
`serverSignature` SHALL be `Ed25519-sign(serverSecretKey, canonicalSerialize(template))` where `template` excludes the `serverSignature` field itself. Verification SHALL `Ed25519-verify(issuerPubkey, serverSignature, canonicalSerialize(template - sig))`. Tampering with any field — including `nonce`, `issuedAt`, or any nested file — MUST cause verification to fail.
**Verified by:** `tests/template-tampered.test.ts`::"single bit flip in any field causes signature failure" (fast-check property)
---
### Requirement: 8-step validation order
`Drive.hydrateFrom(template)` SHALL run these checks in this exact order; failure at any step aborts before the next step:
1. `template.schemaVersion === 1` — else `DRIVE.TEMPLATE_VERSION_UNSUPPORTED`.
2. `Date.now() < template.expiresAt` — else `DRIVE.TEMPLATE_EXPIRED`.
3. `Date.now() - template.issuedAt < 24*60*60*1000` — else `DRIVE.TEMPLATE_STALE`.
4. `template.nonce` not in the LRU of last 1024 nonces — else `DRIVE.TEMPLATE_REPLAY`.
5. `template.issuer ∈ globalPolicy.trustedKeyAuthorities` — else `DRIVE.TEMPLATE_UNTRUSTED_ISSUER`.
6. Ed25519 verify `serverSignature` — else `DRIVE.TEMPLATE_SIG_INVALID`.
7. Recursive folder depth ≤ 32 — else `DRIVE.TEMPLATE_TOO_DEEP`.
8. Per-file consistency: `chunkSize > 164`, `totalChunks * chunkSize >= size`, `manifestKey` non-empty — else `DRIVE.TEMPLATE_MALFORMED`.
**Verified by:** `tests/template-replay.test.ts`, `tests/template-expired.test.ts`, `tests/template-stale.test.ts`, `tests/template-untrusted-issuer.test.ts`, `tests/template-too-deep.test.ts`, `tests/template-malformed.test.ts`
---
### Requirement: Replay protection via nonce LRU
The package SHALL maintain an LRU cache of the last 1024 `(driveId, nonce)` tuples seen by this client. A `hydrateFrom` call with a `(driveId, nonce)` already in the LRU SHALL be rejected with `DRIVE.TEMPLATE_REPLAY` BEFORE signature verification. The LRU is per-client; it persists across `Drive.dispose()` and rebuilds from the FileLocationIndex on `Drive.initialize()`.
**Verified by:** `tests/template-replay.test.ts`::"hydrate with same (driveId, nonce) twice rejects on second call"
---
### Requirement: Issuance window
A template with `issuedAt` more than 24 hours in the past SHALL be rejected with `DRIVE.TEMPLATE_STALE`. Servers issuing templates MUST refresh `issuedAt` and `nonce` for each distribution. The 24-hour window is fixed by this revision.
**Verified by:** `tests/template-stale.test.ts`::"template issued 24h+1s ago rejected as STALE"
---
### Requirement: Folder depth cap
The recursive depth of `FolderTemplate.children` chains SHALL NOT exceed 32. Validation SHALL count from the root (depth 0) and reject the first folder reaching depth 33 with `DRIVE.TEMPLATE_TOO_DEEP`.
**Verified by:** `tests/template-too-deep.test.ts`::"depth-33 nested folder rejected"
---
### Requirement: Per-file consistency invariants
For every `FileTemplate`: (a) `chunkSize > 164` (chunk header overhead requires positive payload), (b) `totalChunks * chunkSize >= size` (chunks must cover the file), (c) `manifestKey` non-empty, (d) `checksum` matches the format `'blake3:<hex>'` or `'sha256:<hex>'`. Failure SHALL produce `DRIVE.TEMPLATE_MALFORMED` with a context object naming the offending file.
**Verified by:** `tests/template-malformed.test.ts`::"chunkSize 164 rejected" AND `tests/template-malformed.test.ts`::"totalChunks insufficient for size rejected"
---
### Requirement: PerFilePolicy four-flag shape
`PerFilePolicy` SHALL be `{ autoDownload: boolean; meshAllowed: boolean; cdnAllowed: boolean; retention?: RetentionPolicy }`. `RetentionPolicy` SHALL be the union `'permanent' | 'lru' | { maxAgeMs: number }`. Missing fields in a `FileTemplate.flags` SHALL fall through to `globalPolicy.defaultPolicy`.
**Verified by:** `tests/policy-autodownload.test.ts`::"autoDownload=true schedules fetch on hydrate" AND `tests/policy-meshallowed-false.test.ts`::"meshAllowed=false skips bus emit"
---
### Requirement: meshAllowed=false skips bus emission
For files with `flags.meshAllowed === false`, the drive SHALL NOT emit `cd:request` or `cd:advertise` envelopes for the file's chunks on the `NetworkBus`. The bus interceptor in tests SHALL observe zero such emissions throughout a hydration + read cycle.
**Verified by:** `tests/policy-meshallowed-false.test.ts`::"NetworkBus interceptor sees no cd:request for meshAllowed=false file"
---
### Requirement: cdnAllowed=false skips HTTPS fallback
For files with `flags.cdnAllowed === false`, the drive SHALL NOT invoke `drive-transport-https` for the file's chunks. If both `meshAllowed: false` and `cdnAllowed: false` are set and the file is not present locally, `Drive.read()` SHALL throw `DRIVE.UNAVAILABLE` rather than attempt any external fetch.
**Verified by:** `tests/policy-cdnallowed-false.test.ts`::"drive-transport-https not invoked for cdnAllowed=false file"
---
### Requirement: Retention 'permanent' protected from eviction
Files with `retention: 'permanent'` SHALL NOT have their chunks evicted by the global LRU loop, regardless of `maxLocalCacheBytes` pressure. If `maxLocalCacheBytes` is exceeded by 'permanent' files alone, the drive SHALL emit a `drive:cache:overflow` event and continue serving; eviction SHALL operate only on 'lru' and `{ maxAgeMs }` files.
**Verified by:** `tests/retention-lru.test.ts`::"permanent files survive cache eviction even past budget"
---
### Requirement: Retention 'lru' chunk-granularity eviction
Files with `retention: 'lru'` (or unspecified, falling through to `defaultPolicy.retention === 'lru'`) SHALL have chunks evicted in least-recently-touched order when total local cache exceeds `globalPolicy.maxLocalCacheBytes`. Eviction SHALL be at chunk granularity — the manifest, FileLocation, and DirEntry are NEVER evicted (only the chunk bytes). The file remains visible with reduced `chunksPresent`; subsequent reads re-fetch.
**Verified by:** `tests/retention-lru.test.ts`::"chunks evicted oldest-first; manifest preserved; re-read triggers refetch"
---
### Requirement: Retention `{ maxAgeMs }` aging eviction
Files with `retention: { maxAgeMs: N }` SHALL have chunks older than N milliseconds eligible for eviction at the next GC pass. The GC pass SHALL run at most once per minute and SHALL prioritize `{ maxAgeMs }` files over 'lru' files. A chunk's "age" is measured from its last successful read or write.
**Verified by:** `tests/retention-max-age.test.ts`::"chunks past maxAgeMs evicted at next GC"
---
### Requirement: CDN reversed-ID URL convention
When `cdnFallback.pathScheme === 'reverse-id'`, the URL for chunk `(fileId, chunkId)` SHALL be:
```text
<cdnFallback.baseUrl>/<reversed[0]>/<reversed[1]>/.../<reversed[levels-1]>/<fileId>_<chunkIdPadded>.chunk
```
where `reversed = fileId.split('').reverse().join('')` and `chunkIdPadded` is `chunkId.toString().padStart(cdnFallback.padding, '0')`. The default `levels` is 3; the default `padding` is 9. The URL builder SHALL be deterministic — identical inputs produce byte-identical URLs.
**Verified by:** `tests/cdn-reverse-id-url.test.ts`::"URL builder produces correct path for various levels and padding values"
#### Scenario: Worked example
- **WHEN** `fileId = "abcdef1234567890"`, `chunkId = 47`, `levels = 3`, `padding = 9`
- **THEN** URL = `<base>/0/9/8/abcdef1234567890_000000047.chunk`
---
### Requirement: CDN bad-signature falls back to mesh
When a chunk fetched from the CDN fails Ed25519 sig verification or AEAD tag verification, the drive SHALL discard the bytes and SHALL fall back to the mesh path (subject to `meshAllowed`). The drive SHOULD report the corruption via OTel counter `drive.cdn.corrupted_bytes`.
**Verified by:** `tests/cdn-bad-sig.test.ts`::"corrupted CDN response discarded; mesh fallback succeeds"
---
### Requirement: CDN write-back via signed upload token
CDN write SHALL require a `CdnUploadToken` with fields `{ fileId, chunkId, chunkHash, uploaderPubkey, expiresAt, serverSignature }`. The CDN SHALL verify (1) the server signature, (2) `BLAKE3(body) === chunkHash`, (3) `Date.now() < expiresAt`, and (4) `uploaderPubkey` matches the bearer's identity. Token expiry SHALL default to 60 seconds. Tokens MUST NOT be re-used (CDN tracks `(fileId, chunkId, nonce)` in a short LRU).
**Verified by:** `tests/cdn-upload-token.test.ts`::"valid token accepted; expired/tampered/replayed tokens rejected"
---
### Requirement: Inline key distribution unwraps via X25519 sealed-box
For files with `encryption.keyDistribution.kind === 'inline'`, the `wrappedKey` SHALL be a `crypto_box_seal` ciphertext targeting the recipient's X25519 public key. On `hydrateFrom`, the drive SHALL unwrap using the recipient's secret key (held in `@suaveplan/crypto-vault`) and store the unwrapped `fileKey` in the drive's KeyRing under `fileId`. Successful unwrap SHALL emit `drive:key:granted { fileId }` and SHALL flip `Drive.getKeyStatus(path)` from `'encrypted-locked'` to `'encrypted-unlocked'`.
**Verified by:** `tests/inline-key-unlock.test.ts`::"hydrate with inline-wrapped key flips lock icon to unlocked AND read returns plaintext"
---
### Requirement: Retroactive key distribution via KeyGrantEnvelope
For files with `encryption.keyDistribution.kind === 'retroactive'`, the drive SHALL subscribe to bus topic `key:grant` filtered by `fileId`. The package SHALL export a `KeyGrantEnvelope` with payload `{ fileId, recipient, wrappedKey, issuer, issuerSignature, expiresAt? }`. On receipt, the drive SHALL validate (in order): `issuer === template.encryption.keyAuthority`, `recipient === ourPubkey`, `Ed25519-verify(issuer, issuerSignature, canonicalGrant)`, `expiresAt > Date.now()` (if present). On success, the wrapped key SHALL be unwrapped, stored in the KeyRing, and `drive:key:granted { fileId }` emitted.
**Verified by:** `tests/retroactive-key-grant.test.ts`::"valid key:grant flips lock icon AND read returns plaintext" AND `tests/key-grant-untrusted-issuer.test.ts`::"grant from non-keyAuthority rejected"
---
### Requirement: Drive.getKeyStatus three-state contract
`Drive.getKeyStatus(path: string): 'unencrypted' | 'encrypted-locked' | 'encrypted-unlocked'` SHALL be exposed as a synchronous method. Returns:
- `'unencrypted'` — the file's `FileTemplate.encryption` is undefined or matches an unencrypted format.
- `'encrypted-locked'` — encryption is configured but the drive does not currently hold the unwrapped `fileKey`.
- `'encrypted-unlocked'` — encryption is configured AND the drive holds the unwrapped `fileKey` in its KeyRing.
A path that does not exist SHALL throw `DRIVE.NOT_FOUND` (NOT a status return).
**Verified by:** `tests/get-key-status.test.ts`::"returns correct three-state value for unencrypted, locked, and unlocked files"
---
### Requirement: Drive.getPresence chunk-progress contract
`Drive.getPresence(path: string): ChunkPresence` SHALL be exposed. `ChunkPresence` SHALL be `{ chunksPresent: number; chunksTotal: number; sourceHints: ('local' | 'mesh' | 'cdn')[]; stuckChunks: number }`. `stuckChunks` is the count of chunks that failed retrieval from all configured sources within the last 60 seconds (resets on retry-success or peer-population). The method MAY return cached/throttled values; freshness SHALL be ≤ 1 second.
**Verified by:** `tests/get-presence.test.ts`::"returns correct chunksPresent/chunksTotal AND stuckChunks updates on failure"
---
### Requirement: DirEntry carries optional keyStatus and presence
The `DirEntry` returned by `Drive.list()` SHALL include optional fields `keyStatus?: KeyStatus` and `presence?: ChunkPresence` for entries with `type === 'file'`. UIs MAY ignore these fields without functional regression. `keyStatus` and `presence` SHALL match the values that would be returned by individual `getKeyStatus` / `getPresence` calls at the same instant.
**Verified by:** `tests/list-with-presence.test.ts`::"DirEntry.keyStatus and DirEntry.presence match dedicated getter results"
---
### Requirement: Inline files carry Ed25519 signature
`FileLocation.storageHints` entries with `kind: 'inline'` SHALL be `{ kind: 'inline'; bytes: Uint8Array; sig: Uint8Array }` where `sig = Ed25519-sign(driveSecretKey, fileId ‖ bytes)`. Read-side SHALL verify the signature before returning bytes; failure SHALL throw `DRIVE.INLINE_SIG_INVALID`. Backward-compat: drives reading old-format inline entries (no `sig` field) SHALL accept them with a `drive:inline:legacy` warning event and SHALL rewrite them with a sig on next write.
**Verified by:** `tests/inline-file-sig.test.ts`::"inline file with valid sig reads successfully" AND `tests/inline-file-sig.test.ts`::"inline file with bad sig throws INLINE_SIG_INVALID"
---
### Requirement: Drive.hydrateFrom API contract
`Drive.hydrateFrom(template: DriveTemplate, options?: HydrateOptions): Promise<HydrateResult>` SHALL be exposed. `HydrateOptions` SHALL be `{ signal?: AbortSignal; onProgress?: (e: HydrateProgressEvent) => void; preFetch?: 'none' | 'metadata-only' | 'auto-download-files' }`. `HydrateResult` SHALL be `{ filesAdded: number; bytesScheduled: number; unlocked: number; locked: number }`. Cancellation via `signal` SHALL abort cleanly without leaving partial state in the FileLocationIndex.
**Verified by:** `tests/hydrate-from.test.ts`::"successful hydration creates indexes and returns correct HydrateResult" AND `tests/hydrate-from.test.ts`::"AbortSignal cancellation leaves no partial state"
---
### Requirement: Hydration error code surface
`Drive.hydrateFrom` SHALL throw `DriveError` with one of the following codes in the `DRIVE.TEMPLATE_*` family: `TEMPLATE_VERSION_UNSUPPORTED`, `TEMPLATE_EXPIRED`, `TEMPLATE_STALE`, `TEMPLATE_REPLAY`, `TEMPLATE_UNTRUSTED_ISSUER`, `TEMPLATE_SIG_INVALID`, `TEMPLATE_TOO_DEEP`, `TEMPLATE_MALFORMED`. The codes SHALL be members of the frozen `DRIVE_CODES` registry. Each error SHALL carry a `context` object identifying the offending field (e.g. `{ file: '/photos/wedding.jpg', issue: 'chunkSize <= 164' }`).
**Verified by:** `tests/template-malformed.test.ts`::"every error path produces the documented code AND context object"
---
## MODIFIED Requirements
### Requirement: Inline storage hint format (in `drive-plugin-chunks` capability)
> Modifies the existing `FileLocation.storageHints` `kind: 'inline'` shape: previously `{ kind: 'inline'; bytes: Uint8Array }`, now `{ kind: 'inline'; bytes: Uint8Array; sig: Uint8Array }`. The new `sig` field is required on writes; reads accept both old and new format with a legacy warning on the old form. This is a minor wire-format bump; backward-compat shim ships in this change.
**Verified by:** `tests/inline-file-sig.test.ts`::"old-format inline reads with warning AND rewrites with sig on next write"
### Requirement: Drive.list returns DirEntries with optional new fields (in `drive` capability)
> Modifies the existing DRIVE-08 area: `DirEntry` gains optional `keyStatus` and `presence` fields. Strictly additive at the consumer surface; existing UIs that ignore the fields work unchanged.
**Verified by:** `tests/list-with-presence.test.ts`::"DirEntry surface is backward-compatible AND new fields populated correctly"
## REMOVED Requirements
> None.