# Capability Spec Delta: drive-sharing (enterprise-drive-sharing) **Package**: `@suaveplan/drive-sharing` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.) --- ## ADDED Requirements ### Requirement: Structured DriveShareError hierarchy The package SHALL export a `DriveShareError` class extending `SuaveplanError` from `@suaveplan/error`. Every throw site SHALL use a named factory helper. `DriveShareError` SHALL carry a `DriveShareErrorCode` discriminant. `error-codes.ts` SHALL register all twelve `SHARE.*` codes idempotently at module load. #### Scenario: Error code coverage - **WHEN** any `DriveShareErrorCode` is defined in `error-codes.ts` - **THEN** at least one production code path throws that exact code #### Scenario: Factory helpers are used - **WHEN** a consumer inspects a caught `DriveShareError` - **THEN** `error.code` is a stable string from `DriveShareErrorCode` and `error instanceof DriveShareError` is `true` **Verified by:** `src/error-codes.ts` (code registration) AND `src/partitions/partition-manager/partition-manager.spec-conformance.test.ts`::"throws DriveShareError with SHARE_PARTITION_NOT_FOUND for missing partition" AND `src/transfer/transfer/transfer.spec-conformance.test.ts`::"throws DriveShareError with SHARE_TRANSFER_FAILED on abort" --- ### Requirement: Zod schemas for all public option types Every public options interface (`PartitionManagerConfig`, `CreatePartitionOptions`, `AddMemberOptions`, `CreateGrantOptions`, `CreateTokenOptions`, `TokenManagerConfig`, `TransferOptions`, `PartitionPolicyConfig`, `KeyRingConfig`) SHALL have a corresponding Zod schema exported from `src/types/schemas.ts`. Schemas SHALL be wired at every declared boundary. #### Scenario: Invalid CreatePartitionOptions rejected - **WHEN** `partitionManager.create` is called with `name` missing - **THEN** it throws a validation error before any store mutation #### Scenario: PartitionPolicyConfig rejects unknown action - **WHEN** `PartitionPolicyConfigSchema` is parsed with `allowedActions: ["read", "invalid"]` - **THEN** `ZodError` is thrown citing the invalid action **Verified by:** `src/types/schemas.spec-conformance.test.ts`::"CreatePartitionOptionsSchema rejects missing name" AND `src/types/schemas.spec-conformance.test.ts`::"PartitionPolicyConfigSchema rejects unknown action in allowedActions" --- ### Requirement: Injectable clock and RNG No `Date.now()` or `Math.random()` SHALL appear at module scope in `src/`. `TokenManager` and `PartitionManager` SHALL accept optional `clock?: Clock`. All timestamp generation SHALL use the injected `clock`. `generateId` SHALL accept an optional `rng?: () => string` defaulting to `crypto.randomUUID`. #### Scenario: Deterministic token expiry - **WHEN** `tokenManager.create` is called with a stub `clock` returning `0` and `defaultTTL` of `1000` - **THEN** `token.expiresAt` equals `1000` #### Scenario: Injectable RNG produces deterministic IDs - **WHEN** `generateId(() => "fixed-id")` is called - **THEN** it returns `"fixed-id"` **Verified by:** `src/sharing/tokens/tokens.spec-conformance.test.ts`::"create uses injected clock for expiresAt" AND `src/types/common.spec-conformance.test.ts`::"generateId with stub rng returns fixed string" --- ### Requirement: OpenTelemetry instrumentation on all externally-visible operations Every externally-visible async operation SHALL emit an OTel span and/or counter/histogram as specified in `proposal.md`. Span names SHALL follow the convention `share..`. #### Scenario: partitionManager.create emits span - **WHEN** `partitionManager.create(options)` succeeds - **THEN** a span named `share.partition.create` is started and ended #### Scenario: tokenManager.validate increments counter - **WHEN** `tokenManager.validate(tokenId)` is called - **THEN** counter `share.token.validate_total` is incremented by `1` **Verified by:** `src/partitions/partition-manager/partition-manager.spec-conformance.test.ts`::"create emits span share.partition.create" AND `src/sharing/tokens/tokens.spec-conformance.test.ts`::"validate increments share.token.validate_total counter" --- ### Requirement: AbortSignal on cross-partition transfer `TransferOptions` SHALL accept an optional `signal?: AbortSignal`. When the signal aborts mid-transfer, `TransferManager.transfer` SHALL reject with `DriveShareError(SHARE_TRANSFER_FAILED)`. #### Scenario: Mid-transfer abort rejects - **WHEN** `transferManager.transfer(options)` is in progress and `options.signal` aborts - **THEN** the promise rejects with `DriveShareError` code `SHARE_TRANSFER_FAILED` #### Scenario: Pre-aborted signal rejects immediately - **WHEN** `transferManager.transfer` is called with an already-aborted signal - **THEN** it rejects immediately with `DriveShareError` code `SHARE_TRANSFER_FAILED` **Verified by:** `src/transfer/transfer/transfer.test.ts`::"mid-transfer abort rejects with SHARE_TRANSFER_FAILED" AND `src/transfer/transfer/transfer.test.ts`::"pre-aborted signal rejects immediately" --- ### Requirement: CryptoProvider interface and WebCryptoProvider The package SHALL export a `CryptoProvider` interface and a `WebCryptoProvider` implementation using `globalThis.crypto.subtle`. `WebCryptoProvider` SHALL implement `encrypt`, `decrypt`, `wrapKey`, `unwrapKey`, `generateKey`, and `computeIntegrity`. #### Scenario: WebCryptoProvider encrypt-decrypt round-trip - **WHEN** `webCryptoProvider.encrypt(key, data)` produces a bundle and `decrypt(key, bundle)` is called - **THEN** the decrypted bytes equal the original `data` #### Scenario: WebCryptoProvider wrap-unwrap round-trip - **WHEN** `webCryptoProvider.wrapKey(wrappingKey, keyToWrap)` produces a wrapped key and `unwrapKey(wrappingKey, wrappedKey)` is called - **THEN** the unwrapped bytes equal the original `keyToWrap` **Verified by:** `src/encryption/envelope/envelope.test.ts`::"WebCryptoProvider encrypt-decrypt round-trip" AND `src/encryption/envelope/envelope.test.ts`::"WebCryptoProvider wrapKey-unwrapKey round-trip" --- ### Requirement: Envelope encryption and decryption `envelopeEncrypt(key, data, options?)` SHALL encrypt `data` with `key` using the default `CryptoProvider`. `envelopeDecrypt(key, bundle)` SHALL decrypt. Both SHALL throw `DriveShareError(SHARE_ENCRYPTION_FAILED)` on any crypto failure. #### Scenario: Envelope encrypt-decrypt round-trip - **WHEN** `envelopeEncrypt(key, data)` produces a bundle and `envelopeDecrypt(key, bundle)` is called - **THEN** the decrypted bytes equal the original `data` #### Scenario: Wrong key decryption fails - **WHEN** `envelopeDecrypt(wrongKey, bundle)` is called - **THEN** it throws `DriveShareError(SHARE_ENCRYPTION_FAILED)` **Verified by:** `src/encryption/envelope/envelope.test.ts`::"envelopeEncrypt then envelopeDecrypt returns original data" AND `src/encryption/envelope/envelope.test.ts`::"envelopeDecrypt with wrong key throws SHARE_ENCRYPTION_FAILED" --- ### Requirement: Key wrap, unwrap, and rewrap `wrapKey(wrappingKey, keyToWrap, options?)` SHALL produce a `WrappedKey`. `unwrapKey(wrappingKey, wrappedKey)` SHALL return the original key bytes. `rewrapKey(oldWrappingKey, newWrappingKey, wrappedKey)` SHALL unwrap with the old key and rewrap with the new key without exposing plaintext to callers. #### Scenario: Rewrap round-trip - **WHEN** `rewrapKey(oldKey, newKey, wrappedKey)` produces a rewrapped key and `unwrapKey(newKey, rewrappedKey)` is called - **THEN** the unwrapped bytes equal the original `keyToWrap` #### Scenario: Integrity verification detects corruption - **WHEN** `computeIntegrity(data)` produces a digest and one byte of `data` is flipped before `verifyIntegrity` - **THEN** `verifyIntegrity` returns `false` **Verified by:** `src/encryption/envelope/envelope.test.ts`::"rewrapKey round-trip unwraps with new wrapping key" AND `src/encryption/envelope/envelope.test.ts`::"verifyIntegrity detects single-bit corruption" --- ### Requirement: Key ring management `KeyRing` SHALL store per-user keys wrapped with a master key. `addKey(userId, key, options?)` SHALL wrap and store the key, emitting `key:added`. `getKey(userId)` SHALL unwrap and return the key. `removeKey(userId)` SHALL delete the entry, emitting `key:removed`. `rotateKey(userId, newKey)` SHALL rewrap the key, emitting `key:rotated`. `export()` and `import(keys)` SHALL serialize/deserialize the full ring. #### Scenario: Add-then-get round-trip - **WHEN** `keyRing.addKey(userId, key)` then `keyRing.getKey(userId)` are called - **THEN** the returned bytes equal the original `key` #### Scenario: Export-import round-trip - **WHEN** `keyRing.export()` produces an array and `keyRing.import(array)` is called in a fresh instance - **THEN** `getKey(userId)` returns the original key for every user **Verified by:** `src/encryption/key-ring/key-ring.test.ts`::"addKey then getKey returns original bytes" AND `src/encryption/key-ring/key-ring.test.ts`::"export-import round-trip preserves all keys" --- ### Requirement: Partition policy creation and validation `createPolicy(config)` SHALL produce a valid `PartitionPolicyConfig`. `getDefaultPolicy()` SHALL return the default. `mergePolicies(base, override)` SHALL shallow-merge non-conflicting fields. `validateAction(policy, ctx)` SHALL return `{ allowed: false, reason?: string }` for denied actions and `{ allowed: true }` for permitted actions. `requiresEncryption` and `requiresReauth` SHALL be pure predicates. `DEFAULT_PRESETS` SHALL contain `public`, `private`, `shared`, and `encrypted` presets. #### Scenario: Preset validation - **WHEN** `validateAction(createPolicy(DEFAULT_PRESETS.encrypted), { action: 'read', userId, partition })` is called - **THEN** `allowed` is `true` and `requiresEncryption(policy)` is `true` #### Scenario: Merge preserves base when override is empty - **WHEN** `mergePolicies(base, {})` is called - **THEN** the result equals `base` #### Scenario: MaxMembers violation returns denied - **WHEN** `validateAction` is called on a partition whose member count exceeds `policy.maxMembers` - **THEN** `allowed` is `false` and `reason` mentions `maxMembers` **Verified by:** `src/partitions/partition-policy/partition-policy.test.ts`::"encrypted preset allows read and requiresEncryption is true" AND `src/partitions/partition-policy/partition-policy.test.ts`::"mergePolicies with empty override returns base unchanged" AND `src/partitions/partition-policy/partition-policy.test.ts`::"validateAction denies when maxMembers exceeded" --- ### Requirement: Partition store interface `PartitionStore` SHALL declare `get`, `set`, `delete`, and `list`. `InMemoryPartitionStore` SHALL implement the interface with a `Map` backing. #### Scenario: In-memory store read-after-write - **WHEN** `store.set(partition)` then `store.get(partition.id)` are called - **THEN** the returned partition equals the stored partition #### Scenario: In-memory store list returns all partitions - **WHEN** two partitions are stored and `store.list()` is called - **THEN** the result contains both partitions **Verified by:** `src/partitions/partition-store/partition-store.test.ts`::"set then get returns identical partition" AND `src/partitions/partition-store/partition-store.test.ts`::"list returns all stored partitions" --- ### Requirement: Partition manager CRUD and membership `PartitionManager` SHALL create, get, list, delete, update, add-member, and remove-member. `create` SHALL generate a `PartitionId`, apply the default or provided policy, store via `config.store`, and emit `partition:created`. `addMember` SHALL enforce `maxMembers` policy and emit `member:added`. `removeMember` SHALL emit `member:removed`. `get` on a missing id SHALL throw `DriveShareError(SHARE_PARTITION_NOT_FOUND)`. #### Scenario: Create emits event - **WHEN** `partitionManager.create(options)` succeeds - **THEN** event bus emits `partition:created` with the new partition #### Scenario: AddMember enforces maxMembers - **WHEN** `addMember` is called on a partition already at `maxMembers` - **THEN** it throws `DriveShareError(SHARE_POLICY_VIOLATION)` #### Scenario: RemoveMember restores count - **WHEN** `addMember` then `removeMember` are called for the same user - **THEN** `partition.members.length` equals its value before both calls **Verified by:** `src/partitions/partition-manager/partition-manager.test.ts`::"create emits partition:created event" AND `src/partitions/partition-manager/partition-manager.test.ts`::"addMember throws SHARE_POLICY_VIOLATION when maxMembers reached" AND `src/partitions/partition-manager/partition-manager.test.ts`::"addMember then removeMember restores original member count" --- ### Requirement: Grant manager create, check, and revoke `GrantManager` SHALL maintain grants in memory. `create(options)` SHALL generate a `GrantId`, validate that `expiresAt` is in the future (via injectable clock), and store the grant. `check(grantId, userId, action)` SHALL verify existence, non-revocation, non-expiry, permission inclusion, and grantee identity. `revoke(grantId)` SHALL mark the grant revoked. `list(query?)` SHALL filter by `granteeId`, `partitionId`, and `active` status. #### Scenario: Grant check succeeds for valid grantee and action - **WHEN** a grant with `permissions: ['read']` is created for user B and `check(grantId, userB, 'read')` is called - **THEN** `allowed` is `true` #### Scenario: Expired grant is denied - **WHEN** a grant with `expiresAt: 100` is created at clock `0` and `check` is called at clock `101` - **THEN** `allowed` is `false` and `reason` mentions expiry #### Scenario: Revoked grant is denied - **WHEN** `grantManager.revoke(grantId)` is called and then `check(grantId, userId, 'read')` is called - **THEN** `allowed` is `false` and `reason` mentions revocation **Verified by:** `src/sharing/grants/grants.test.ts`::"check returns allowed true for valid grantee and permitted action" AND `src/sharing/grants/grants.test.ts`::"check returns allowed false for expired grant" AND `src/sharing/grants/grants.test.ts`::"check returns allowed false for revoked grant" --- ### Requirement: Token manager create, validate, and revoke with TTL `TokenManager` SHALL create, validate, revoke, and list share tokens. `create(options)` SHALL compute `expiresAt = clock.now() + (options.expiresIn ?? defaultTTL)`. `validate(tokenId)` SHALL check existence, non-revocation, and `expiresAt > clock.now()`. `revoke(tokenId)` SHALL mark the token revoked permanently. #### Scenario: Valid token passes validation - **WHEN** a token is created with `expiresIn: 3600` at clock `0` and `validate` is called at clock `100` - **THEN** `valid` is `true` and the token is returned #### Scenario: Expired token fails validation - **WHEN** a token is created with `expiresIn: 100` at clock `0` and `validate` is called at clock `101` - **THEN** `valid` is `false` and `reason` mentions expiry #### Scenario: Revoked token is permanently invalid - **WHEN** `tokenManager.revoke(tokenId)` is called and `validate(tokenId)` is called repeatedly - **THEN** every call returns `valid: false` **Verified by:** `src/sharing/tokens/tokens.test.ts`::"validate returns valid true before expiry" AND `src/sharing/tokens/tokens.test.ts`::"validate returns valid false after expiry" AND `src/sharing/tokens/tokens.test.ts`::"revoked token validate always returns valid false" --- ### Requirement: Cross-partition file transfer `TransferManager` SHALL resolve source and target KEKs, retrieve the file DEK, validate source permissions, resolve target policy, rewrap the DEK, and record a `TransferRecord`. The result SHALL include `success`, `record`, and `rewrappedKey`. Permission or policy violations SHALL throw `DriveShareError(SHARE_TRANSFER_FAILED)` or `SHARE_POLICY_VIOLATION`. #### Scenario: Successful export transfer - **WHEN** `transferManager.transfer({ direction: 'export', ... })` is called with valid keys and policies - **THEN** `success` is `true` and `record.completedAt` is a positive timestamp #### Scenario: Policy violation throws - **WHEN** `transferManager.transfer` is called and the target partition policy denies `write` - **THEN** it throws `DriveShareError(SHARE_POLICY_VIOLATION)` #### Scenario: Missing file key throws - **WHEN** `transferManager.transfer` is called for a fileId not present in `fileKeyStore` - **THEN** it throws `DriveShareError(SHARE_KEY_NOT_FOUND)` **Verified by:** `src/transfer/transfer/transfer.test.ts`::"successful export returns success true and completed record" AND `src/transfer/transfer/transfer.test.ts`::"export throws SHARE_POLICY_VIOLATION when target denies write" AND `src/transfer/transfer/transfer.test.ts`::"transfer throws SHARE_KEY_NOT_FOUND when file key missing" --- ### Requirement: Branded ID utilities The package SHALL export branded types `UserId`, `PartitionId`, `GrantId`, `TokenId`, `FileId`, `KeyId` using the `Brand` pattern. Factory functions (`userId`, `partitionId`, `grantId`, `tokenId`, `fileId`, `keyId`) SHALL construct branded values. `generateId` SHALL return a string. `ok` and `err` SHALL construct `Result` values. #### Scenario: Branded IDs are type-distinct - **WHEN** `userId("u1")` and `partitionId("p1")` are created - **THEN** TypeScript reports an error if a `UserId` is assigned to a `PartitionId` parameter #### Scenario: Result discriminant - **WHEN** `ok(42)` is created - **THEN** `result.ok` is `true` and `result.value` is `42` **Verified by:** `src/types/common.spec-conformance.test.ts`::"userId and partitionId are not assignable to each other" AND `src/types/common.spec-conformance.test.ts`::"ok result has ok true and correct value" --- ### Requirement: Property-based test invariants The package SHALL include fast-check property tests covering: grant expiry after TTL advance, token revocation finality, policy merge commutativity for non-conflicting fields, and partition member count invariant. #### Scenario: Grant expiry after clock advance - **WHEN** fast-check generates a grant with TTL T (1–1000 ms), clock advances by T+1 - **THEN** `grantManager.check` returns `allowed: false` #### Scenario: Token revocation is final - **WHEN** fast-check generates a token, revokes it, then validates any number of times - **THEN** every validation returns `valid: false` #### Scenario: Policy merge on disjoint fields - **WHEN** fast-check generates two policies with disjoint `allowedActions` - **THEN** `mergePolicies(a, b).allowedActions` contains the union of both sets **Verified by:** `tests/property.test.ts`::"grant check returns allowed false after advancing clock beyond TTL" AND `tests/property.test.ts`::"revoked token validate always returns valid false" AND `tests/property.test.ts`::"mergePolicies on disjoint allowedActions yields union" --- ### Requirement: Benchmark suite coverage The package SHALL include a mitata benchmark suite in `src/__benchmarks__/drive-sharing.bench.ts` covering: 10 000 grant checks, 10 000 token validations, 100-key key-ring add/get, and envelope encrypt+decrypt on 10 KiB. #### Scenario: Benchmarks run without error - **WHEN** `bun run bench` is executed - **THEN** all four benchmarks complete and print mean ops/sec **Verified by:** `src/__benchmarks__/drive-sharing.bench.ts` (mitata output) AND `src/__benchmarks__/drive-sharing.spec-conformance.test.ts`::"all benchmark functions complete without throwing" --- ## MODIFIED Requirements > None — this is a new package migration with no prior folded spec. ## REMOVED Requirements > None — this is a new package migration with no prior folded spec.