Files
suaveplan/openspec/specs/drive-mesh-protocol/spec.md
2026-05-27 23:29:53 +01:00

208 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Capability Spec Delta: drive-mesh-protocol (drive-mesh-protocol-v1)
**Capability**: `drive-mesh-protocol`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` §13.)
---
## ADDED Requirements
### Requirement: ChunkRequest discriminated union
The package SHALL export a `ChunkRequest` discriminated union with three forms: `{ kind: 'whole' }`, `{ kind: 'specific'; chunkIds: number[] }`, and `{ kind: 'bitset'; bytes: Uint8Array }`. The `kind` field is the discriminator; consumers MUST match exhaustively.
**Verified by:** `tests/three-form-roundtrip.test.ts`::"every ChunkRequest form roundtrips through encode/decode"
#### Scenario: Exhaustiveness check
- **WHEN** a `switch` covers `'whole'`, `'specific'`, `'bitset'`
- **THEN** TypeScript reports no exhaustiveness errors
#### Scenario: Unknown kind rejected
- **WHEN** an envelope arrives with `kind: 'unknown'`
- **THEN** validation throws `DMP.UNKNOWN_REQUEST_KIND`
---
### Requirement: `kind: 'whole'` returns capped chunk set
When a responder receives a `ChunkRequestEnvelope` with `request.kind === 'whole'`, it SHALL return up to `maxChunks` chunks ordered by chunkId ascending, starting from chunkId 0. The responder MUST NOT return chunks it does not have. The responder MAY return fewer chunks than `maxChunks` if its inventory is smaller.
**Verified by:** `tests/cap-respected.test.ts`::"whole-form request returns at most maxChunks chunks"
#### Scenario: Whole-request with full inventory
- **WHEN** a responder has chunks 0–999 and receives `{ kind: 'whole' }` with `maxChunks: 64`
- **THEN** the response contains exactly 64 chunks, ids 0–63, ordered ascending
---
### Requirement: `kind: 'specific'` rejects empty array
When a responder receives `{ kind: 'specific'; chunkIds: [] }`, it SHALL reject the request with `DMP.EMPTY_REQUEST`. Empty-array semantics are NOT a synonym for "everything"; callers MUST use `{ kind: 'whole' }` for that.
**Verified by:** `tests/empty-specific-rejected.test.ts`::"kind:specific with empty chunkIds is rejected"
---
### Requirement: `kind: 'bitset'` payload via bitpacker-compressed
The `bytes` field of a `bitset` request SHALL be encoded via `@suaveplan/bitpacker-compressed`. Bit `i` set to 1 means "chunk i is needed"; bit `i` set to 0 means "chunk i is not needed." Padding bits in the final byte MUST be zero.
**Verified by:** `tests/bitset-rle.test.ts`::"bitset roundtrip preserves bit positions for random densities"
#### Scenario: Direct bitset request
- **WHEN** a requester needs chunks 100, 105, 200 from a 1000-chunk file
- **THEN** the bitset has exactly three set bits at positions 100, 105, 200
- **AND** the encoded payload is <40 bytes after `@suaveplan/bitpacker-compressed`
---
### Requirement: Form auto-selection threshold
The protocol SHALL provide a `selectForm(needed: number[]): ChunkRequest` helper. When `needed.length <= 64`, the helper SHALL return `{ kind: 'specific', chunkIds: needed }`. When `needed.length > 64`, the helper SHALL return `{ kind: 'bitset', bytes: <encoded> }`. The threshold MAY be overridden by callers but the default SHALL be 64.
**Verified by:** `tests/codec-mandate.test.ts`::"selectForm picks specific for ≤64, bitset for >64"
---
### Requirement: ChunkRequestEnvelope priority
`ChunkRequestEnvelope.priority` SHALL be one of `'critical' | 'high' | 'normal' | 'low' | 'bulk'`. The default is `'normal'`. CRITICAL requests bypass the priority queue (per `network-plugin-protocol` `NET-57`); BULK uses backpressure-aware transport selection.
**Verified by:** `tests/three-form-roundtrip.test.ts`::"priority field round-trips through envelope encoding"
---
### Requirement: maxChunks default and enforcement
`ChunkRequestEnvelope.maxChunks` SHALL default to 64. Responders MUST honor `maxChunks` and MUST NOT return more than `maxChunks` chunks per response envelope. A request with `maxChunks > 1024` SHALL be rejected with `DMP.MAX_CHUNKS_EXCEEDED`.
**Verified by:** `tests/cap-respected.test.ts`::"responder honors maxChunks" AND `tests/cap-respected.test.ts`::"maxChunks > 1024 rejected"
---
### Requirement: Advertisement bitset length invariant
In `ChunkAdvertisePayload`, the bit-length of `presence` (the count of bits, not bytes) MUST equal `totalChunks`. Padding bits in the final byte MUST be zero. Receivers MUST reject envelopes where these conditions do not hold with `DMP.BITSET_LENGTH_MISMATCH` or `DMP.BITSET_PADDING_NONZERO`.
**Verified by:** `tests/bitset-length-invariant.test.ts`::"advertisement with presence.length ≠ totalChunks rejected" AND `tests/bitset-length-invariant.test.ts`::"non-zero padding bits rejected"
---
### Requirement: Generation counter monotonicity
`ChunkAdvertisePayload.generation` MUST monotonically increase per `(peerId, fileId)` from the issuer's perspective. Receivers MUST track the highest generation seen for each `(peerId, fileId)` and SHALL drop advertisements with `generation < lastSeen`. Generation wraps modulo 2^32; receivers MAY accept a wrap if `(lastSeen - generation) > 2^31` (one-cycle anomaly tolerated).
**Verified by:** `tests/generation-monotonic.test.ts`::"out-of-order advertisement is dropped" AND `tests/generation-monotonic.test.ts`::"wrap detected past 2^31 delta"
---
### Requirement: Advertisement throttling
A peer SHALL emit at most one outgoing `cd:advertise` per file per second per direct neighbor. A change to the local presence map between throttle windows SHALL be coalesced into the next emission. The throttle interval MAY be adjusted by `network-plugin-qos` based on bus congestion but SHALL NOT exceed 60 seconds.
**Verified by:** `tests/generation-monotonic.test.ts`::"throttle coalesces multiple presence changes within 1s window"
---
### Requirement: Response presence piggyback
`ChunkResponsePayload` SHALL include `presence: Uint8Array` (bitpacker-compressed bitset of the responder's full inventory for `fileId`) and `generation: number`. Receivers SHALL update their peer-index for `(responder, fileId)` from this payload without requiring a separate `cd:advertise` exchange. Piggyback SHALL be applied even on partial responses.
**Verified by:** `tests/piggyback-presence.test.ts`::"cd:response payload updates peer-index without cd:advertise"
---
### Requirement: Response chunks ordered by chunkId ascending
`ChunkResponsePayload.chunks` SHALL be ordered by `chunkId` ascending. Out-of-order chunks within a single response SHALL be rejected with `DMP.UNORDERED_RESPONSE`. The ordering enables receivers to track presence updates incrementally as bytes stream in.
**Verified by:** `tests/three-form-roundtrip.test.ts`::"response with unordered chunkIds rejected"
---
### Requirement: Per-chunk verification before commit
Every received chunk SHALL be verified before its bytes are committed to local storage. The verification order is (1) parse 164-byte header, (2) Ed25519-verify `sig` over `metadata ‖ aeadTag` against the writer's pubkey from the registry, (3) AEAD-decrypt and verify the Poly1305 tag, (4) (on whole-file completion) verify the BLAKE3 checksum against the manifest. Any verification failure SHALL discard the bytes and NOT update the local presence bitset.
**Verified by:** `tests/trust-decay.test.ts`::"chunk with bad signature is discarded and not committed"
---
### Requirement: Bad-signature trust termination
When a chunk fails Ed25519 sig verification, the per-`(peerId, fileId)` trust score SHALL be set to `-Infinity` immediately. Subsequent requests for `fileId` SHALL NOT include this peer in the candidate set. The termination is per-file; the peer's trust for OTHER files is unaffected.
**Verified by:** `tests/trust-decay.test.ts`::"bad signature sets per-file trust to -Infinity"
---
### Requirement: Probabilistic skip on advertised-but-missing
For each `(peerId, fileId)`, the protocol SHALL track `advertised` (chunks the peer claimed to have) and `missing` (chunks the peer was asked for but did not deliver within timeout). When `popcount(missing) / popcount(advertised) > 0.05`, future requests SHALL probabilistically skip this peer with `P(skip) = (missRate - 0.05) * 20`, capped at 1.0.
**Verified by:** `tests/trust-decay.test.ts`::"peer at 30% miss rate skipped on 50% of requests"
---
### Requirement: Trust score per (peerId, fileId)
Trust state SHALL be keyed by `(peerId, fileId)`. A peer demoted for one file SHALL retain default trust for unrelated files. The trust map cleans up entries when the peer disconnects (registry `peer:left` event) or when the file is removed from the local FileLocationIndex.
**Verified by:** `tests/trust-decay.test.ts`::"trust score is per-file; one bad file does not affect others"
---
### Requirement: Adaptive sample-verification rate
The protocol SHALL provide a sampling layer that, on incoming `cd:advertise`, MAY request a single chunk from the advertised set to verify the peer actually has it. The default sampling rate is 1 in 100. Peers in "suspect" status (trust score < 0.95 but > -Infinity) SHALL be sampled at 1 in 10. Sample-verification failure increments `missing` (per `DMP-15`).
**Verified by:** `tests/trust-decay.test.ts`::"suspect peer sampled at 1-in-10; healthy peer at 1-in-100"
---
### Requirement: ChunkErrorEnvelope shape and codes
The protocol SHALL export a `ChunkErrorEnvelope` with `correlationId` (matches the originating request id) and `code: ChunkErrorCode`. `ChunkErrorCode` SHALL be the union: `'NOT_FOUND' | 'ACCESS_DENIED' | 'BANDWIDTH_EXCEEDED' | 'GENERATION_STALE' | 'INTERNAL'`. Codes MUST be frozen via `defineCodes("DMP", [...])`.
**Verified by:** `tests/error-envelope-shape.test.ts`::"every ChunkErrorCode round-trips through envelope encoding" AND `tests/error-codes.spec-conformance.test.ts`::"DMP_CODES is frozen"
---
### Requirement: GENERATION_STALE includes responder generation
When a responder rejects a request with `code: 'GENERATION_STALE'`, the error payload SHALL include the responder's current `generation: number` for the requested `fileId`. The requester MAY use this to update its peer-index without a separate advertisement round-trip.
**Verified by:** `tests/error-envelope-shape.test.ts`::"GENERATION_STALE error includes responder's current generation"
---
### Requirement: Codec mandates
All envelopes SHALL be framed via `@suaveplan/bitpacker-network`. Payload bitsets (advertisement `presence`, request `bytes`, response `presence`) SHALL be encoded via `@suaveplan/bitpacker-compressed`. Schema validation SHALL be performed via `@suaveplan/bitpacker-schema` before any payload is processed. Envelopes that do not satisfy these codec mandates SHALL be rejected with `DMP.CODEC_MISMATCH`.
**Verified by:** `tests/codec-mandate.test.ts`::"non-bitpacker-network framed envelope rejected" AND `tests/codec-mandate.test.ts`::"non-bitpacker-compressed bitset rejected"
---
### Requirement: Empty `kind: 'specific'` rejection
A `ChunkRequest` with `kind: 'specific'` and `chunkIds: []` SHALL be rejected at envelope validation with `DMP.EMPTY_REQUEST`. This rule disambiguates the empty-array case from `kind: 'whole'` ("everything"). Callers needing "everything" MUST use `kind: 'whole'`.
**Verified by:** `tests/empty-specific-rejected.test.ts`::"specific with empty chunkIds rejected at validation"
---
## MODIFIED Requirements
> None — this is a new capability. Downstream proposals (`drive-mesh-port`, `network-plugin-content-delivery`) will reference this spec instead of redefining the protocol locally; their MODIFIED requirements live in their own change folders, not here.
## REMOVED Requirements
> None.