Files
suaveplan/openspec/archive/2026-05-03-chain-bundle-adopt-envelope-schema/proposal.md
2026-05-27 23:29:53 +01:00

9.0 KiB

chain-bundle adopts EnvelopeSchema — first canary of the cycle-broken DI primitives

Packages: @suaveplan/crypto Status: PROPOSED Type: internal refactor (no public-API change, no wire-format change)

Why

The just-archived extract-shared-crypto-interfaces (2026-05-03) eliminated the crypto ↔ bitpacker-crypto cycle by extracting Authority / DomainTag / AeadOps to @suaveplan/types and refactoring bitpacker-crypto's primitives to dependency injection. That change unblocked the canary migrations that were originally deferred from bitpacker-crypto-rewrite (2026-05-02).

This is the first of those canary migrations. @suaveplan/crypto::ChainBundle currently re-implements the magic + formatVersion + linkCount header pattern inline using defineBitSchema:

// chain-bundle.ts — current
const HEADER_SCHEMA = defineBitSchema<ChainBundleHeaderRaw>([
    { name: "magic", type: "bytes", byteLength: 4 },
    { name: "formatVersion", type: "uint", bits: 8 },
    { name: "linkCount", type: "uint", bits: 8 },
]);

…and re-implements the magic-mismatch / version-mismatch / length-mismatch error paths with hand-written checks. The same pattern lives in crypto::PublicBundle, the upstream packer::chunkedPacketSchema, and several other places. @suaveplan/bitpacker-crypto::EnvelopeSchema exists specifically to consolidate this pattern; this change proves the primitive works against a real consumer in the genesis tree (the canary use-case the rewrite was always supposed to deliver).

The migration is byte-identical — wire format does not change. This is enforced by a snapshot test pinning the bytes for a fixed input.

What changes

MODIFIED — packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.ts

  • Drop the inline HEADER_SCHEMA = defineBitSchema([{magic}, {formatVersion}, {linkCount}]) declaration.
  • Add an EnvelopeSchema-based header definition:
    const HEADER_BODY_SCHEMA = defineBitSchema<{ linkCount: number }>([
        { name: "linkCount", type: "uint", bits: 8 },
    ]);
    const CHAIN_BUNDLE_ENVELOPE = defineEnvelopeSchema<{ linkCount: number }>({
        magic: CHAIN_BUNDLE_MAGIC,
        formatVersion: CHAIN_BUNDLE_FORMAT_VERSION,
        bodyCodec: {
            pack: (b) => HEADER_BODY_SCHEMA.pack(b),
            unpack: (bytes) => HEADER_BODY_SCHEMA.unpack(bytes),
        },
    });
    
  • Update packChainBundle to call CHAIN_BUNDLE_ENVELOPE.pack({ linkCount: ... }) instead of HEADER_SCHEMA.pack({...}). The variable-length link payloads stay concatenated after the envelope header (the envelope's body is 1 byte = linkCount; the link payloads are not inside the envelope's body).
  • Update parseChainBundle to call CHAIN_BUNDLE_ENVELOPE.parseStrict(...) instead of inline HEADER_SCHEMA.unpack(...). Map BitpackerError codes (CRYPTO_ENVELOPE_MAGIC_MISMATCH, CRYPTO_ENVELOPE_VERSION_MISMATCH) back to existing CryptoError codes (CHAIN.WRONG_FORMAT with crypto.chain-bundle.bad-magic / crypto.chain-bundle.unsupported-version messages) for back-compat — existing consumers catching by CryptoError.code continue to work unchanged.
  • Preserve every other behavior verbatim: linkCount range check (CHAIN.TOO_DEEP), expectedTotal length check (CHAIN.WRONG_FORMAT length-mismatch), verifyChain walk-and-verify logic.
  • Add snapshot test pinning the bytes of packChainBundle([fixedLinkBytes]) so any future drift in the underlying primitive is caught immediately.

MODIFIED — packages/crypto/crypto/package.json

  • Add @suaveplan/bitpacker-crypto: workspace:^ to dependencies. No new cycle: bitpacker-crypto no longer depends on @suaveplan/crypto at runtime (per the just-archived extract-shared-crypto-interfaces).

MODIFIED — packages/crypto/crypto/vite.config.ts

  • Add "@suaveplan/bitpacker-crypto" and /^@suaveplan\/bitpacker-crypto\// to externals.

Out of scope (queued as follow-ups)

  • Canary of crypto::PublicBundle → SignedBitSchema + EnvelopeSchema — same shape; queued as public-bundle-adopt-envelope-schema. Larger because PublicBundle uses both signing and envelope framing.
  • Canary of upstream packer::chunkedPacketSchema — out of genesis scope until packer is ported.

Parity Checklist

Capability Pre-refactor Post-refactor Notes
Public exports (packChainBundle, parseChainBundle, verifyChain, CHAIN_BUNDLE_MAGIC, CHAIN_BUNDLE_FORMAT_VERSION, MAX_CHAIN_LINKS, CHAIN_BUNDLE_HEADER_BYTE_SIZE, ChainBundle, IsRevokedPredicate, VerifyChainOptions) ✅ ✅ Unchanged
Wire format [magic 4][version u8][linkCount u8][...links] ✅ ✅ Byte-identical — pinned by snapshot test
CHAIN_BUNDLE_HEADER_BYTE_SIZE = 6 ✅ ✅ Same value, computed differently
CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.bad-magic on bad magic ✅ ✅ Mapped from BitpackerError CRYPTO_ENVELOPE_MAGIC_MISMATCH
CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.unsupported-version on bad version ✅ ✅ Mapped from BitpackerError CRYPTO_ENVELOPE_VERSION_MISMATCH
CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.bytes-too-short on too-short input ✅ ✅ Pre-check before envelope.parseStrict
CryptoError CHAIN.TOO_DEEP crypto.chain-bundle.link-count-out-of-range on bad linkCount ✅ ✅ Unchanged
CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.length-mismatch on length mismatch ✅ ✅ Unchanged
All 14 existing chain-bundle.test.ts tests pass green ✅ ✅ No test modifications needed
Coverage of chain-bundle.ts 100% 100% Reasserted by §5
New crypto → bitpacker-crypto dep edge 🚫 (absent) ✅ (added) Acyclic (verified by manifest regen)
bitpacker-crypto → crypto runtime dep 🚫 🚫 Stays absent — eliminated by extract-shared-crypto-interfaces

Source mapping

Every requirement in the spec delta is grounded in the existing chain-bundle test surface or in the just-archived extract-shared-crypto-interfaces requirements.

Requirement Source
Wire format byte-identical Existing chain-bundle.test.ts::"packs and parses a 1-link chain" and "packs and parses a 3-link chain"
CryptoError CHAIN.WRONG_FORMAT bad-magic mapping Existing chain-bundle.test.ts::"parseChainBundle rejects bad magic"
CryptoError CHAIN.WRONG_FORMAT unsupported-version mapping Existing chain-bundle.test.ts::"parseChainBundle rejects bad version"
CryptoError CHAIN.WRONG_FORMAT bytes-too-short mapping Existing chain-bundle.test.ts::"parseChainBundle rejects too-short payload"
EnvelopeSchema adoption proves the cycle-broken DI primitives compose against a real consumer extract-shared-crypto-interfaces/proposal.md "Out of scope (queued as follow-ups)" — this is the first follow-up

Migration impact

Internal (in this change)

  • One file changed in crypto: chain-bundle.ts (~30 lines net).
  • One snapshot test added: chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot".
  • package.json adds one workspace dep (@suaveplan/bitpacker-crypto).
  • vite.config.ts adds two externals entries.
  • Coverage stays 100% on chain-bundle.ts.

External

  • Zero consumer-side changes. packChainBundle / parseChainBundle / verifyChain signatures and behavior are unchanged. Existing consumers (which catch CryptoError by code, not by message) continue to work.

Verification approach

  • bunx turbo lint typecheck test --filter=@suaveplan/crypto — all 265 existing tests stay green PLUS the new snapshot test passes.
  • bunx turbo test:coverage --filter=@suaveplan/crypto — coverage of chain-bundle.ts stays 100/100/100/100.
  • bunx turbo build --filter=...@suaveplan/crypto — every dependent compiles green.
  • bun scripts/folder-reorg/generate-manifest.ts — confirms the new crypto → bitpacker-crypto edge is acyclic.
  • Snapshot test: chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot" pins the exact bytes for a fixed input — any future drift caught immediately.

Risks

  • Wire-format drift. Mitigated by snapshot test that pins the bytes for a fixed input. If EnvelopeSchema ever changes its byte layout (it has no reason to — the layout is locked by spec), the snapshot test will fail loudly.
  • Error code remapping bug. The BitpackerError → CryptoError mapping inside parseChainBundle is the only behavioral change. Mitigated by the four existing tests that assert specific CryptoError codes (bad-magic, bad-version, too-short, length-mismatch) on each error path.
  • Cycle re-introduction. The new crypto → bitpacker-crypto edge is safe ONLY because bitpacker-crypto no longer depends on crypto at runtime (just-archived extract-shared-crypto-interfaces). If a future PR re-adds @suaveplan/crypto to bitpacker-crypto/dependencies, the cycle reappears. Mitigated by the existing grep guard in bitpacker-crypto/src/ and by manifest.ts regeneration as a CI step.