# 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`: ```ts // chain-bundle.ts — current const HEADER_SCHEMA = defineBitSchema([ { 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: ```ts 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.