Files
suaveplan/openspec/archive/2026-05-02-bitpacker-stub-completion/specs/bitpacker-delta/spec.md
2026-05-27 23:29:53 +01:00

6.3 KiB

Capability Spec Delta: bitpacker-delta (bitpacker-stub-completion)

Package: @suaveplan/bitpacker-delta Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time.

ADDED Requirements

Requirement: Field-level change detection

The package SHALL expose a DeltaManager class that, given a current object, a BitPackerSchema describing it, and a previous state retrieved by state key, returns the ordered list of fields whose values changed since that previous state. Field order in the returned FieldChange[] SHALL match Object.keys(schema) order so encoders can rely on field indexes.

Scenario: first call returns empty changes

  • WHEN detectChanges is called for a state key with no stored previous state
  • THEN the result is an empty array AND the current state is stored under that key

Scenario: changed fields surface as ordered FieldChange[]

  • WHEN the previous state had { a: 1, b: "x" } and the current state is { a: 1, b: "y" } under schema { a: "u8", b: "-32" }
  • THEN the result is [{ index: 1, key: "b", previousValue: "x", currentValue: "y" }]

Scenario: maxFieldCount cap enforced

  • WHEN the schema has more entries than DeltaConfig.maxFieldCount
  • THEN the manager throws BitpackerError with code BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED and the offending field count in context.fieldCount

Verified by: packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.test.ts::"detects changed fields in schema order" AND packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.test.ts::"returns empty array on first call for a state key" AND packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.test.ts::"throws DELTA_FIELD_LIMIT_EXCEEDED when schema exceeds maxFieldCount"


Requirement: Configurable delta modes via DeltaConfig

The package SHALL accept a DeltaConfig object (mode, xorNumbers, diffStrings, threshold, maxFieldCount, stateKey, equalityMode) validated by Zod. Unknown fields and invalid mode values SHALL be rejected at construction time with BitpackerError code BITPACKER.DELTA_INVALID_CONFIG.

Scenario: unknown mode rejected

  • WHEN new DeltaManager({ mode: "magic" } as never) is constructed
  • THEN it throws BitpackerError with code BITPACKER.DELTA_INVALID_CONFIG and context.mode === "magic"

Scenario: equalityMode: "json" opts into stringify-based equality

  • WHEN equalityMode is "json"
  • THEN the manager uses JSON.stringify for equality (parity with upstream behavior)

Scenario: structural equality is the default

  • WHEN equalityMode is omitted
  • THEN equality is computed by walking the schema tree, not by JSON.stringify, so key-order differences in nested objects do not register as changes

Verified by: packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"rejects invalid DeltaConfig with DELTA_INVALID_CONFIG" AND packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"uses structural equality by default" AND packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"opts into JSON equality when equalityMode is 'json'"


Requirement: State storage with LRU eviction

The package SHALL expose a DeltaStateManager class that stores previous states keyed by string, deep-clones on store to prevent caller mutation, and evicts in insertion order when the configured maxStates (default 100) is exceeded.

Scenario: stored state is isolated from mutation

  • WHEN a caller stores a state, then mutates the original object
  • THEN getPreviousState returns the unmutated snapshot

Scenario: oldest state evicted at capacity

  • WHEN maxStates is 3 and a 4th key is stored
  • THEN the first key is evicted; hasPreviousState(firstKey) returns false

Scenario: re-store under existing key does not advance LRU position

  • WHEN a key is stored, then re-stored after intervening writes
  • THEN the key remains at its original LRU position (re-store updates value, not order — matches upstream behavior in delta-state.ts L22-L37)

Verified by: packages/bitpacker/bitpacker-delta/src/delta-state/delta-state.test.ts::"deep-clones stored states to isolate from caller mutation" AND packages/bitpacker/bitpacker-delta/src/delta-state/delta-state.test.ts::"evicts oldest state when maxStates exceeded" AND packages/bitpacker/bitpacker-delta/src/delta-state/delta-state.test.ts::"re-store under existing key preserves LRU position"


Requirement: Deterministic clock injection

The package SHALL accept an optional Clock from @suaveplan/types; all timestamps recorded internally (e.g. for OTel histograms) SHALL come from the injected clock. The package SHALL NOT call Date.now() at module scope or inside hot paths.

Scenario: injected clock observed in OTel histogram

  • WHEN a fake Clock advancing 7ms per now() call is injected and detectChanges is called twice
  • THEN the OTel histogram bitpacker.delta.changes_detected records two samples whose timestamps differ by exactly the fake clock's delta

Scenario: no module-scope Date.now()

  • WHEN grep -r "Date\.now()" packages/bitpacker/bitpacker-delta/src/ is run
  • THEN there are zero matches in implementation files

Verified by: packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"records OTel timestamps from injected Clock" AND packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"no module-scope Date.now in implementation files"

REMOVED Requirements

Requirement: Inline 35-line DeltaManager in index.ts

Reason: Stub. Used Date.now() and JSON.stringify (CLAUDE.md determinism rule violation), tracked no field-level diffs, and held only one global previous state. Replaced wholesale by the DeltaManager + DeltaStateManager pair above.

Migration: Existing call sites doing new DeltaManager() continue to work — the new class accepts an optional config and clock. Call sites that read manager.getPreviousState() (no arg) should switch to manager.getPreviousState(stateKey); the default stateKey is "default".