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
packChainBundleto callCHAIN_BUNDLE_ENVELOPE.pack({ linkCount: ... })instead ofHEADER_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
parseChainBundleto callCHAIN_BUNDLE_ENVELOPE.parseStrict(...)instead of inlineHEADER_SCHEMA.unpack(...). MapBitpackerErrorcodes (CRYPTO_ENVELOPE_MAGIC_MISMATCH,CRYPTO_ENVELOPE_VERSION_MISMATCH) back to existingCryptoErrorcodes (CHAIN.WRONG_FORMATwithcrypto.chain-bundle.bad-magic/crypto.chain-bundle.unsupported-versionmessages) for back-compat — existing consumers catching byCryptoError.codecontinue to work unchanged. - Preserve every other behavior verbatim:
linkCountrange check (CHAIN.TOO_DEEP),expectedTotallength check (CHAIN.WRONG_FORMATlength-mismatch),verifyChainwalk-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:^todependencies. No new cycle:bitpacker-cryptono longer depends on@suaveplan/cryptoat runtime (per the just-archivedextract-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 aspublic-bundle-adopt-envelope-schema. Larger because PublicBundle uses both signing and envelope framing. - Canary of upstream
packer::chunkedPacketSchema— out of genesis scope untilpackeris 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.jsonadds one workspace dep (@suaveplan/bitpacker-crypto).vite.config.tsadds two externals entries.- Coverage stays 100% on
chain-bundle.ts.
External
- Zero consumer-side changes.
packChainBundle/parseChainBundle/verifyChainsignatures and behavior are unchanged. Existing consumers (which catchCryptoErrorby 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 ofchain-bundle.tsstays 100/100/100/100.bunx turbo build --filter=...@suaveplan/crypto— every dependent compiles green.bun scripts/folder-reorg/generate-manifest.ts— confirms the newcrypto → bitpacker-cryptoedge 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
EnvelopeSchemaever 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→CryptoErrormapping insideparseChainBundleis the only behavioral change. Mitigated by the four existing tests that assert specificCryptoErrorcodes (bad-magic, bad-version, too-short, length-mismatch) on each error path. - Cycle re-introduction. The new
crypto → bitpacker-cryptoedge is safe ONLY becausebitpacker-cryptono longer depends oncryptoat runtime (just-archivedextract-shared-crypto-interfaces). If a future PR re-adds@suaveplan/cryptotobitpacker-crypto/dependencies, the cycle reappears. Mitigated by the existing grep guard inbitpacker-crypto/src/and bymanifest.tsregeneration as a CI step.