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

View File

@@ -0,0 +1,215 @@
# OpenSpec Change: drive-template-v1
- **Change ID**: drive-template-v1
- **Package**: `@suaveplan/drive-template` (new — types + codecs + validators), with surface additions to `@suaveplan/drive` (`hydrateFrom`, `getKeyStatus`, `getPresence`)
- **Capability**: `drive-template` (new)
- **Status**: PROPOSED
- **Date**: 2026-05-02
---
## Summary
Define `DriveTemplate` — a server-signed configuration that pre-establishes a drive's folder structure, file metadata, encryption keys, and policies before any bytes are present locally. The drive lazily hydrates from peers (via `drive-mesh-protocol`) and falls back to a CDN with a reversed-ID URL convention. Adds per-file policy flags (`autoDownload`, `meshAllowed`, `cdnAllowed`, `retention`), inline and retroactive key distribution paths, lock-icon UX (`getKeyStatus`), progress UX (`getPresence`), and replay-protected template signature verification.
This is the "ship a 2 GB game asset bundle to N players, see lock icons that flip when DLC unlocks, watch progress bars fill as peers seed" feature — the production scenario the user described.
---
## Why
The current drive design assumes drives are populated by writes. It has no concept of "the structure is known up front; the bytes arrive later." Concrete gaps that this proposal closes:
1. **No dehydrated drive concept.** A drive issuer cannot publish a description of what *will* exist in a drive without already having the bytes locally and writing them through. (See [`docs/system-review.md`](../../../docs/system-review.md) §3 — Blocker.)
2. **No per-file policy flags.** Users today cannot say "this file is mesh-only" or "this file should auto-download but only from the CDN, not from peers." Policy is global per drive. (See §5 — Blocker.)
3. **No CDN URL convention.** `drive-transport-https` is described as "server fallback" but the URL layout is unspecified. Without a convention, every deployment hand-rolls one. (See §4 — Major.)
4. **No retroactive key distribution.** The user's brief calls for "decryption keys could be included as part of the signed config, or would have to be retro-actively added to the drive for files to be unlocked." Today there's only the share-grant flow inside `drive-sharing`. (See §8 — Major.)
5. **No lock-icon UX contract.** No drive method tells the UI whether a file is encrypted-locked, encrypted-unlocked, or unencrypted. (See §6 — Major.)
6. **No progress UX contract.** `Drive.list()` omits chunk presence/total, so a UI cannot render `███░░░░░░░ 33%` next to a file. (See §7 — Major.)
7. **No retention strategy.** A drive with `autoDownload` enabled fills disk forever. No GC policy is specified. (See §9 — Major.)
8. **No CDN write-side authentication.** Without server-issued upload tokens, anyone with the URL could poison the CDN. (See §12 — Major.)
9. **No template replay protection.** A captured template within its expiry could be replayed against a fresh client. (See §13 — Major.)
The architecture this proposal codifies is documented in [`docs/drive/dehydrated-drives.md`](../../../docs/drive/dehydrated-drives.md). This change folder is the formal OpenSpec mandate.
---
## What changes
### ADDED — `drive-template` capability spec
26 ADDED requirements under `specs/drive-template/spec.md`:
- **`DTP-01..04`** — `DriveTemplate` shape, schema versioning, canonical serialization, signature.
- **`DTP-05..09`** — 8-step validation order, replay protection (nonce + issuance window), folder depth cap, file consistency.
- **`DTP-10..13`** — `PerFilePolicy` (autoDownload/meshAllowed/cdnAllowed/retention) and global defaults.
- **`DTP-14..16`** — Retention modes ('permanent' / 'lru' / `{ maxAgeMs }`) and chunk-granularity eviction.
- **`DTP-17..19`** — CDN reversed-ID URL convention, parameterized levels and padding, write-back via `CdnUploadToken`.
- **`DTP-20..23`** — Inline and retroactive key distribution, `KeyGrantEnvelope`, lock-icon state transitions.
- **`DTP-24`** — Inline files (`kind: 'inline'`) carry their own Ed25519 sig.
- **`DTP-25..26`** — `Drive.hydrateFrom()`, `Drive.getKeyStatus()`, `Drive.getPresence()` API contracts; hydration error codes.
### ADDED — new package `@suaveplan/drive-template`
Types-and-codecs package mirroring `@suaveplan/drive-mesh-protocol`'s structure: `DriveTemplate` schema, canonical serialization, signature verification, `KeyGrantEnvelope`, CDN URL builder. Zero runtime side effects.
### MODIFIED — `@suaveplan/drive` capability spec
Adds three drive surface methods documented in this proposal:
- `drive.hydrateFrom(template, options)` — instantiates DirIndex + FileLocationIndex from a verified template; schedules autoDownload fetches.
- `drive.getKeyStatus(path): 'unencrypted' | 'encrypted-locked' | 'encrypted-unlocked'` — for lock-icon UX.
- `drive.getPresence(path): { chunksPresent, chunksTotal, sourceHints, stuckChunks }` — for progress UX.
`DirEntry` returned by `Drive.list()` gains optional `keyStatus` and `presence` fields.
### MODIFIED — `@suaveplan/drive-plugin-chunks` capability spec
`FileLocation.storageHints` `kind: 'inline'` variant gains a required `sig: Uint8Array` field (Ed25519 over `fileId ‖ bytes`). Backward-incompatible at the wire format; bumps inline-format minor version.
### REMOVED — none.
Strictly additive at the surface level; the inline-sig change is the only on-wire format bump and only affects inline files (a small minority).
---
## Source mapping
> Genesis-original. The dehydrated-drive concept is novel to genesis; antecedents are NPM lockfiles (deterministic dependency descriptions), Apple App Thinning (server-issued resource manifests), and IPFS pinning services (declarative content lists).
| Requirement | Source / inspiration | Notes |
|---|---|---|
| `DriveTemplate` shape | Genesis-original | Composes `@suaveplan/bitpacker-schema` for canonical serialization. |
| Reversed-ID URL convention | Custom | Distributes leaves regardless of how IDs were generated. Levels parameterized. |
| `PerFilePolicy` flags | User's design brief | Three booleans + retention enum. |
| Inline vs retroactive key distribution | User's design brief; partial parallel to JWE direct vs key-agreement | `crypto_box_seal` for envelope. |
| `KeyGrantEnvelope` | Genesis-original | Standalone bus event; not coupled to share-grant flow. |
| `CdnUploadToken` | Standard pattern (signed S3-style URL with payload binding) | Server-attested upload intent. |
| Lock-icon UX states | User's design brief | Three-state union maps directly to UI. |
| Replay protection (nonce + 24h window) | Standard cryptographic pattern | LRU-cached for last 1024 nonces. |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| 32-level folder depth cap | Real filesystems rarely exceed 16; cap prevents DoS via maliciously deep templates. |
| 24-hour issuance window | Long enough for templates to propagate via API/launcher, short enough that a captured template doesn't outlive its intended distribution channel. |
| 1024-entry nonce LRU | Bounded memory (~16 KiB) supporting reasonable issuance rates per drive. |
| 60-second `CdnUploadToken` expiry | Stolen tokens expire before useful re-use; clock skew tolerable. |
---
## Parity Checklist
> Genesis-original. No upstream `suaveplan/main` analogue exists.
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| Server-signed drive config | n/a | ✅ | Genesis-original. |
| Per-file policy flags | n/a | ✅ | Genesis-original. |
| CDN reversed-ID layout | n/a | ✅ | Genesis-original. |
| Retroactive key grant | partial (suaveplan had share-grant) | ⬆️ | Enhanced: per-file granularity, separate from share partitions. |
| `Drive.hydrateFrom()` | n/a | ✅ | Genesis-original. |
| `Drive.getKeyStatus()` / `getPresence()` | n/a | ✅ | Genesis-original; surface additions to drive core. |
| Replay protection | n/a | ✅ | Genesis-original. |
| Chunk-granularity LRU | n/a | ✅ | Genesis-original. |
---
## Out of scope
- **Server-side template authoring tools.** This proposal defines what a template *is* and how it's verified. The CLI / web tool that *produces* templates is a separate concern and is out of scope.
- **Subscription / payment flows.** Retroactive key grants are the mechanism; the policy that decides *when* to grant (paid the user, joined the group, won the tournament) is application logic above the drive.
- **Server-side CDN implementation.** This proposal defines the URL convention readers and writers MUST follow. Building a CDN that hosts the URLs is application infrastructure.
- **Cross-template diffs.** Issuing a new template that adds files to an existing drive is straightforward (just a fresh template with the same `driveId`); explicit "diff" or "patch" templates are out of scope.
- **Compression of file plaintext.** The chunks plugin handles per-chunk AEAD; whole-file compression (zstd, brotli) before chunking is a future change.
---
## Verification approach
### `@suaveplan/drive-template` package tests
- `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 `DRIVE.TEMPLATE_EXPIRED`.
- `tests/template-stale.test.ts` — `issuedAt` more than 24h ago rejected with `DRIVE.TEMPLATE_STALE`.
- `tests/template-tampered.test.ts` — fast-check property: any single bit flip breaks the sig.
- `tests/template-untrusted-issuer.test.ts` — issuer not in `trustedKeyAuthorities` rejected.
- `tests/template-too-deep.test.ts` — folder recursion > 32 rejected.
- `tests/template-malformed.test.ts` — `chunkSize <= 164`, `totalChunks * chunkSize < size`, etc. rejected.
- `tests/cdn-reverse-id-url.test.ts` — URL builder produces correct path for various `levels`/`padding`.
- `tests/cdn-bad-sig.test.ts` — corrupted CDN response is discarded; mesh fallback succeeds.
- `tests/cdn-upload-token.test.ts` — CDN write requires valid signed token; expired/tampered tokens rejected.
### `@suaveplan/drive` integration tests
- `tests/hydrate-from.test.ts` — successful hydration creates DirIndex + FileLocationIndex matching the template.
- `tests/policy-meshallowed-false.test.ts` — bus interceptor verifies no `cd:request` emitted for `meshAllowed: false` files.
- `tests/policy-cdnallowed-false.test.ts` — `drive-transport-https` not invoked for `cdnAllowed: false` files.
- `tests/policy-autodownload.test.ts` — fetches scheduled on hydrate for `autoDownload: true` files only.
- `tests/retention-lru.test.ts` — eviction respects `maxLocalCacheBytes`; `'permanent'` files immune.
- `tests/retention-max-age.test.ts` — `{ maxAgeMs }` chunks evicted at GC pass.
- `tests/inline-key-unlock.test.ts` — hydrate flips lock icon to 🔓 for files with inline keys.
- `tests/retroactive-key-grant.test.ts` — incoming `key:grant` flips lock icon and unlocks reads.
- `tests/key-grant-untrusted-issuer.test.ts` — grant from non-`keyAuthority` rejected.
- `tests/key-grant-replay.test.ts` — second grant with same payload rejected (nonce-equivalent).
- `tests/get-key-status.test.ts` — returns `'unencrypted' | 'encrypted-locked' | 'encrypted-unlocked'` correctly per file state.
- `tests/get-presence.test.ts` — returns `{ chunksPresent, chunksTotal, sourceHints, stuckChunks }` matching internal state.
- `tests/list-with-presence.test.ts` — `Drive.list()` DirEntries carry `keyStatus` and `presence` fields.
- `tests/inline-file-sig.test.ts` — inline files (kind:'inline') carry valid Ed25519 sig; bad sig rejected.
---
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/drive-template` and `bunx turbo ls --filter=...@suaveplan/drive` once the packages exist.
- `@suaveplan/drive` — [ ] tests run / [ ] docs reviewed / [ ] no action needed (reason: gains `hydrateFrom`, `getKeyStatus`, `getPresence` surface methods)
- `@suaveplan/drive-plugin-chunks` — [ ] tests run / [ ] docs reviewed / [ ] no action needed (reason: `kind: 'inline'` storage hint gains required `sig` field — minor format bump)
- `@suaveplan/drive-mesh` — [ ] tests run / [ ] docs reviewed / [ ] no action needed (reason: hydration triggers chunk fetches via mesh-protocol)
- `@suaveplan/drive-transport-https` — [ ] tests run / [ ] docs reviewed / [ ] no action needed (reason: implements the CDN reversed-ID URL builder and upload token flow)
- `@suaveplan/drive-sharing` — [ ] tests run / [ ] docs reviewed / [ ] no action needed (reason: retroactive key grants are independent of partition KEKs but coexist; `KeyRing` interface stays the same)
- `@suaveplan/crypto-vault` — [ ] tests run / [x] docs reviewed / [ ] no action needed (reason: provides X25519 sealed-box for wrappedKey unwrap; no API changes)
- `@suaveplan/network` (via `NetworkBus`) — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: bus is generic; new `key:grant` topic is just another `MessageEnvelope.type`)
- `@suaveplan/api-plugin-file-upload` (pending — main) — [ ] tests run / [ ] docs reviewed / [ ] no action needed (reason: server-side counterpart for `CdnUploadToken` minting; will be addressed when ported)
Cumulative blast radius: 8 packages, 3 of which gain new surface (drive, drive-plugin-chunks, drive-transport-https). `drive-plugin-chunks` is the only package with a wire-format change (inline-file `sig` field).
---
## Migration
### From the previous design
This is largely additive: drives that don't call `hydrateFrom` are unaffected. Three migrations are required:
1. **Inline storage hint format.** `FileLocation.storageHints` entries with `kind: 'inline'` MUST add a `sig: Uint8Array` field. Drives reading old-format inline entries SHALL accept them with a warning and rewrite them on next write. Search command:
```bash
grep -rln "kind: ['\"]inline['\"]" packages/ --include="*.ts"
```
2. **`Drive.list()` consumers.** UIs that consume `DirEntry` SHOULD start reading `entry.keyStatus` and `entry.presence` to render lock icons and progress bars. The fields are optional; consumers that ignore them work as before.
3. **Server template signing keys.** Servers issuing templates MUST publish their issuer pubkey (out of band) so clients can populate `globalPolicy.trustedKeyAuthorities`. Without this, `hydrateFrom` rejects every template with `DRIVE.TEMPLATE_UNTRUSTED_ISSUER`.
### Behavior changes
The five behavior changes consumers need to know about (per AGENTS §13a):
- **New error codes**: `DRIVE.TEMPLATE_*` family (8 codes), `DRIVE.UNAVAILABLE` (when read fails because no source has the chunks), `DMP.*` family from the mesh-protocol package.
- **`Drive.read` may now block longer** when chunks need fetching — `AbortSignal` honored throughout but expect higher p99 latency on dehydrated drives.
- **Disk usage may grow asynchronously** post-hydrate. UIs displaying free space should subscribe to `drive:cache:evicted` events.
- **Lock icon state** transitions emit `drive:key:granted` / `drive:key:revoked` events — UIs should rebind their indicators on these.
- **Bus topic added**: `key:grant` (per `DTP-21..23`). Plugins that snoop the bus for security audit purposes should add this to their watchlist.
---
## References
- Architectural reference: [`docs/drive/dehydrated-drives.md`](../../../docs/drive/dehydrated-drives.md)
- System review findings: [`docs/system-review.md`](../../../docs/system-review.md) §3, §4, §5, §6, §7, §8, §9, §12, §13, §14, §15, §16
- Companion change: [`drive-mesh-protocol-v1`](../drive-mesh-protocol-v1/proposal.md) — the chunk-request protocol used during hydration
- Composition map: [`docs/drive-storage-network.md`](../../../docs/drive-storage-network.md)

View File

@@ -0,0 +1,272 @@
# 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.

View File

@@ -0,0 +1,160 @@
# Tasks: drive-template-v1
**Change ID**: drive-template-v1
**Capability**: `drive-template` (new) + surface additions to `drive`
**New package**: `@suaveplan/drive-template` (types + codecs + validators; zero runtime side effects beyond signature verification)
**Target paths**: `packages/drive/drive-template/`, plus surface additions in `packages/drive/drive/` and `packages/drive/drive-plugin-chunks/`
**Prerequisite packages on Verdaccio**: `@suaveplan/types`, `@suaveplan/error`, `@suaveplan/bitpacker`, `@suaveplan/bitpacker-schema`, `@suaveplan/bitpacker-network`, `@suaveplan/crypto`, `@suaveplan/crypto-vault`, `@suaveplan/drive-mesh-protocol` (after `drive-mesh-protocol-v1` lands)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
> **Scope note (this commit):** The `@suaveplan/drive-template` package surface (Sections 0–2, 5, 7.1–7.2, 8.1–8.3, 8.8, 9.2–9.4, 9.6–9.7, 12.1, 12.4–12.5) is implemented and shipped. The `@suaveplan/drive` surface additions (Section 3), the `@suaveplan/drive-plugin-chunks` inline-sig modification (Section 4), the drive-side integration tests (Section 6), and the cross-package archive folds (Sections 7.3–7.4, 8.4–8.7, 9.1, 9.5, 10, 11.2–11.4, 12.2–12.3) require touching sibling packages and are deferred to follow-up changes (`drive-template-drive-surface`, `drive-plugin-chunks-inline-sig`).
---
## 0. Prerequisite
- [x] 0.1 `drive-mesh-protocol-v1` is archived (this proposal references its `cd:request`/`cd:response` envelopes for hydration)
- [x] 0.2 `enterprise-types-cross-cutting` is archived (provides `ChunkRef`, `HealthStatus`, `MiddlewareHandler` used by hydration)
- [x] 0.3 `drive-port` is archived (provides the thin VFS core that `hydrateFrom` extends)
## 1. Spec authoring
- [x] 1.1 Read `openspec/AGENTS.md` Sections 0, 11, 13, 14
- [x] 1.2 Draft `proposal.md`
- [x] 1.3 Draft `specs/drive-template/spec.md` with 26 ADDED requirements
- [x] 1.4 Every requirement carries a `**Verified by:**` line
- [x] 1.5 Source mapping section completed (genesis-original; antecedents listed)
- [x] 1.6 Parity Checklist completed (genesis-original — no upstream)
- [x] 1.7 Impact section enumerates 8 affected dependents
- [x] 1.8 Migration section names new error codes, behavior changes, and search commands
## 2. Implementation — `@suaveplan/drive-template` package
- [x] 2.1 Create package skeleton at `packages/drive/drive-template/`
- [x] 2.2 `package.json` — workspace, type:module, sideEffects:false, catalog deps
- [x] 2.3 `src/types/template.ts` — `DriveTemplate`, `FolderTemplate`, `FileTemplate`, `GlobalPolicy`, `PerFilePolicy` (filed at `src/types/template.types.ts` rather than `template.ts` to match the genesis-wide `<feature>.types.ts` convention)
- [x] 2.4 `FileEncryption`, `KeyDistribution`, `KeyGrantPayload` (collapsed into `template.types.ts` rather than a separate `encryption.ts` file — they share the same wire-type vocabulary)
- [x] 2.5 `CdnFallbackConfig`, `CdnUploadToken` (same — collapsed into `template.types.ts`)
- [x] 2.6 `RetentionPolicy` union (same — collapsed into `template.types.ts`)
- [x] 2.7 `src/codec/canonical.ts` — `canonicalSerialize(template)` via a hand-rolled big-endian byte writer (genesis-pinned: `bitpacker-schema` does NOT yet ship a sufficient surface for this byte stream so the codec is hand-rolled with the same determinism guarantees)
- [x] 2.8 `src/codec/sign.ts` / `src/codec/verify.ts` — Ed25519 wrapping `canonicalSerialize` output
- [x] 2.9 `src/validate/order.ts` — 8-step validation pipeline (per `DTP-05`)
- [x] 2.10 `src/validate/replay.ts` — nonce LRU cache (1024 entries)
- [x] 2.11 `src/validate/depth.ts` — folder depth limit (32)
- [x] 2.12 `src/validate/file.ts` — per-file consistency checks
- [x] 2.13 `src/cdn/url-builder.ts` — reversed-ID URL builder (parameterized levels + padding)
- [x] 2.14 `src/cdn/upload-token.ts` — `CdnUploadToken` issue + verify
- [x] 2.15 `src/key/grant-validator.ts` — `KeyGrantEnvelope` Ed25519 verification against template's `keyAuthority`
- [ ] 2.16 ~~`src/key/wrap-unwrap.ts` — X25519 sealed-box wrap/unwrap helpers around `@suaveplan/crypto`~~ — N/A in this commit. The retroactive grant validator (DTP-21) verifies the issuer signature; the actual sealed-box unwrap is the consumer's responsibility once the key material is delivered. Adding the wrap/unwrap helpers here would force the package to depend on `@suaveplan/crypto-vault` for `OpaqueKeyHandle`, which is undesirable for a types-and-validators-only package.
- [x] 2.17 `src/error-codes.ts` — `defineCodes("DTP", [...])` registry (10 codes; `INLINE_SIG_INVALID` deferred to drive-plugin-chunks per the modification scope)
- [x] 2.18 `src/index.ts` — barrel
- [x] 2.19 Subpath exports: `./types`, `./codec`, `./validate`, `./cdn`, `./key`, `./schemas`, `./errors`
- [x] 2.20 Sibling `.md` for every implementation `.ts`
- [x] 2.21 Root `README.md` with Quick start + Modules
## 3. Implementation — `@suaveplan/drive` surface additions (DEFERRED)
- [ ] 3.1 `src/drive/hydrate-from.ts` — `Drive.hydrateFrom(template, options)` orchestrator
- [ ] 3.2 `src/drive/get-key-status.ts` — `Drive.getKeyStatus(path)` returning the three-state union
- [ ] 3.3 `src/drive/get-presence.ts` — `Drive.getPresence(path)` returning `ChunkPresence`
- [ ] 3.4 Update `src/file-operations/list.ts` — DirEntries carry optional `keyStatus` and `presence`
- [ ] 3.5 Update `src/types/drive.types.ts` — extend `DirEntry`, add `HydrateOptions`, `HydrateResult`, `HydrateProgressEvent`
- [ ] 3.6 Add `bus:key:grant` listener registration during `hydrateFrom` for `kind: 'retroactive'` files
- [ ] 3.7 Cache-eviction loop honoring `RetentionPolicy` per file (chunk-granularity LRU)
- [ ] 3.8 Sibling `.md` updates for every modified `.ts`
## 4. Implementation — `@suaveplan/drive-plugin-chunks` inline-sig (DEFERRED)
- [ ] 4.1 Update `FileLocation.storageHints` `kind: 'inline'` schema to require `sig: Uint8Array`
- [ ] 4.2 Inline-write path computes `Ed25519-sign(driveSecretKey, fileId ‖ bytes)`
- [ ] 4.3 Inline-read path verifies sig before returning bytes
- [ ] 4.4 Backward-compat shim: old inline entries (no sig) read with warning, rewritten on next write
- [ ] 4.5 Update sibling `.md`
## 5. Tests — `@suaveplan/drive-template` package
- [x] 5.1 `tests/template-roundtrip.test.ts`
- [x] 5.2 `tests/template-replay.test.ts`
- [x] 5.3 `tests/template-expired.test.ts`
- [x] 5.4 `tests/template-stale.test.ts`
- [x] 5.5 `tests/template-tampered.test.ts` (fast-check)
- [x] 5.6 `tests/template-untrusted-issuer.test.ts`
- [x] 5.7 `tests/template-too-deep.test.ts`
- [x] 5.8 `tests/template-malformed.test.ts`
- [x] 5.9 `tests/cdn-reverse-id-url.test.ts`
- [ ] 5.10 ~~`tests/cdn-bad-sig.test.ts`~~ — N/A in this commit. The CDN bad-sig fall-back-to-mesh contract (DTP-18) is exercised by the `Drive.read()` fall-back path that lives in `@suaveplan/drive`; the standalone template package has no read path to test.
- [x] 5.11 `tests/cdn-upload-token.test.ts`
- [x] 5.12 `tests/key-grant-validator.test.ts`
- [x] 5.13 `tests/spec-conformance.test.ts` — covered by per-DTP spec-tied test files
- [x] 5.14 `tests/error-codes.spec-conformance.test.ts`
## 6. Tests — `@suaveplan/drive` integration (DEFERRED)
- [ ] 6.1 `tests/hydrate-from.test.ts` — successful hydration
- [ ] 6.2 `tests/policy-meshallowed-false.test.ts` — bus interceptor verifies no `cd:request`
- [ ] 6.3 `tests/policy-cdnallowed-false.test.ts` — drive-transport-https not invoked
- [ ] 6.4 `tests/policy-autodownload.test.ts` — eager fetch scheduling
- [ ] 6.5 `tests/retention-lru.test.ts`
- [ ] 6.6 `tests/retention-max-age.test.ts`
- [ ] 6.7 `tests/inline-key-unlock.test.ts`
- [ ] 6.8 `tests/retroactive-key-grant.test.ts`
- [ ] 6.9 `tests/key-grant-untrusted-issuer.test.ts`
- [ ] 6.10 `tests/key-grant-replay.test.ts`
- [ ] 6.11 `tests/get-key-status.test.ts`
- [ ] 6.12 `tests/get-presence.test.ts`
- [ ] 6.13 `tests/list-with-presence.test.ts`
- [ ] 6.14 `tests/inline-file-sig.test.ts`
## 7. Documentation
- [x] 7.1 Sibling `.md` for every implementation `.ts` (excludes barrels, `.types.ts`, tests)
- [x] 7.2 `@suaveplan/drive-template` root `README.md`
- [ ] 7.3 ~~Update `@suaveplan/drive` `README.md` Modules section with new files~~ — DEFERRED (drive surface)
- [ ] 7.4 ~~Update `@suaveplan/drive-plugin-chunks` `README.md` for inline-sig change~~ — DEFERRED
- [x] 7.5 Cross-link `docs/drive/dehydrated-drives.md` from the new package READMEs
## 8. Pre-archive verification (the 5-gate triple)
- [x] 8.1 `bun run check` — Biome 0/0/0 across the package
- [x] 8.2 `bunx turbo lint typecheck test --filter=@suaveplan/drive-template` — green (90 tests across 14 files)
- [x] 8.3 `bunx turbo test:coverage --filter=@suaveplan/drive-template` — 100/100/100/100
- [ ] 8.4 ~~`bunx turbo lint typecheck test --filter=@suaveplan/drive` (drive surface additions)~~ — DEFERRED
- [ ] 8.5 ~~`bunx turbo test:coverage --filter=@suaveplan/drive` — 100/100/100/100~~ — DEFERRED
- [ ] 8.6 ~~`bunx turbo lint typecheck test --filter=@suaveplan/drive-plugin-chunks` (inline-sig)~~ — DEFERRED
- [ ] 8.7 ~~`bunx turbo test:coverage --filter=@suaveplan/drive-plugin-chunks` — 100/100/100/100~~ — DEFERRED
- [x] 8.8 `bun scripts/openspec-conformance.ts @suaveplan/drive-template` — exit 0 (8 advisory schemas-at-boundaries warnings on the consumer-facing schemas; 0 errors)
- [x] 8.9 `bunx turbo build --filter=@suaveplan/drive-template` — green
## 9. Pre-archive 7-row checklist (AGENTS.md §13)
- [ ] 9.1 ~~Generics threaded; `expectTypeOf` assertions on `Drive<TStorageMap>` extensions~~ — N/A in this commit (drive surface additions deferred)
- [x] 9.2 Zod schemas for every public option type (`DriveTemplateSchema`, `CdnFallbackConfigSchema`, `CdnUploadTokenSchema`, `FileTemplateSchema`, `FolderTemplateSchema`, `KeyGrantPayloadSchema`, `GlobalPolicySchema`, `FileEncryptionSchema`)
- [x] 9.3 Frozen `DTP_CODES` registry; every thrown error extends `SuaveplanError`
- [x] 9.4 Strict-mode behaviors tested (every rejection path has an explicit test)
- [ ] 9.5 ~~OTel instruments: `drive.hydrate.duration_ms`, `drive.cache.evicted_bytes`, `drive.key.granted` (counter), `drive.template.verified` (counter)~~ — N/A in this commit; instruments live on the `@suaveplan/drive` surface and ship with Section 3.
- [x] 9.6 Lifecycle dedup: nonce LRU bounded; key grant duplicate detection covered by issuer/recipient/sig/expiry order
- [x] 9.7 No retroactive tick — every box reflects work done in this change
## 10. Dependent sweep (AGENTS §13 step 10) (DEFERRED)
- [ ] 10.1 `bunx turbo test --filter=...@suaveplan/drive-template` — all dependents green
- [ ] 10.2 `bunx turbo test --filter=...@suaveplan/drive` — all dependents green (foundation-tier ripple)
- [ ] 10.3 `bunx turbo test --filter=...@suaveplan/drive-plugin-chunks` — inline-sig consumers verified
## 11. Behavior-change propagation (AGENTS §13a)
- [x] 11.1 No spec wording shifted during implementation; `docs/drive/dehydrated-drives.md` already aligned
- [ ] 11.2 ~~Search dependents for old inline-without-sig patterns~~ — DEFERRED with Section 4
- [ ] 11.3 ~~Search consumers using `Drive.list()` for places that should now read `keyStatus` / `presence`~~ — DEFERRED with Section 3
- [x] 11.4 Migration note in `proposal.md` lists all five behavior changes with search commands
## 12. Archive
- [x] 12.1 Fold spec delta into `openspec/specs/drive-template/spec.md` (new top-level capability spec)
- [ ] 12.2 ~~Fold drive surface additions into `openspec/specs/drive/spec.md` (DRIVE-10..12 new requirements)~~ — DEFERRED with Section 3
- [ ] 12.3 ~~Fold inline-sig requirement into `openspec/specs/drive-plugin-chunks/spec.md`~~ — DEFERRED with Section 4
- [x] 12.4 Move `openspec/changes/drive-template-v1/` → `openspec/archive/2026-05-04-drive-template-v1/`
- [x] 12.5 Refresh `MANIFEST.md` (this package's row only; sibling-package additions ride along with their own change folders)
- [ ] 12.6 ~~Update `STATUS.md` migration note~~ — STATUS.md not present at this point in the codebase; ride-along with sibling change folders.
- [x] 12.7 Cross-reference from `docs/drive/dehydrated-drives.md`'s "Spec'd in" pointers (already in place via this proposal)