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

119 lines
9.0 KiB
Markdown

# 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<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:
```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.