Files
suaveplan/openspec/archive/2026-05-04-drive-template-v1/specs/drive-template/spec.md
2026-05-27 23:29:53 +01:00

17 KiB

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:

<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.