12 KiB
Design: complete-rng-unification
Goal
A single canonical Rng interface across every @suaveplan/* package, with
zero runtime behaviour change and a one-release-cycle deprecation window
for the Rng aliases that are being phased out. The audit is exhaustive
— every match from the mandated repo-wide grep is categorised and either
migrated, preserved with justification (subtype-extension or distinct
namespace), or renamed to remove a misleading collision.
Strategy
-
Structural conformance over re-declaration. The canonical
Rngin@suaveplan/types/src/rng.tsis the contract:export interface Rng { nextUint32(): number; nextFloat(): number; nextBytes?(n: number): Uint8Array; nextIntInRange?(min: number, maxExclusive: number): number; }Local types that exactly match (or are a subset of) this shape become deprecated re-exports of the canonical. Local types that diverge structurally (object vs callable) are dropped under the misleading name and kept under their real name (
ByteSource). -
Deprecated forwarders, not deletions. Per CLAUDE.md §2 the JSDoc
@deprecatedtag is banned. The aliases are kept as named re-exports with prose comments — and theMIGRATION.mdroot-level doc is updated with the search command and recommended replacement, per §13a. Comment example:// Back-compat re-export of @suaveplan/types' canonical Rng. Prefer // importing Rng from @suaveplan/types directly; this forwarder will // be removed in the follow-up `remove-deprecated-rng-aliases` change. export type { Rng } from "@suaveplan/types"; -
Type-only migration. No runtime behaviour changes. Every package's full test suite must remain green with zero assertion edits before and after the swap. Verified per package with
bunx turbo typecheck test --filter=@suaveplan/<pkg>(CLAUDE.md rule 1: nevertscdirectly). -
Foundation dependent-sweeps are non-negotiable. Per CLAUDE.md §13 step 10 and §14a,
typesandtestingare foundation packages;bunx turbo test --filter=...@suaveplan/typesand...@suaveplan/testingMUST both run green before archival.
Per-package categorisation
| # | Package | Local symbol | Local shape | Canonical shape | Verdict | Action |
|---|---|---|---|---|---|---|
| 1 | @suaveplan/types |
Rng |
object {nextUint32; nextFloat; nextBytes?; nextIntInRange?} |
— (canonical) | — | None — source of truth. |
| 2 | @suaveplan/testing |
interface Rng { nextUint32; nextFloat } |
object, exact subset of canonical | object | Compatible (canonical adds two OPTIONAL members) | Replace with re-export of canonical. cryptoRng / seededRng continue to typecheck against canonical — they implement the required members and omit the optional ones. |
| 3 | @suaveplan/crypto-core |
type Rng = ByteSource where ByteSource = (n: number) => Uint8Array |
callable (n) => Uint8Array |
object with nextUint32/nextFloat |
Incompatible — structurally distinct contracts | Remove the misleading Rng alias. Keep ByteSource under its real name. Add a back-compat re-export of canonical Rng so any external code that imported crypto-core's Rng keeps compiling, but now sees the canonical (object) shape. Any consumer that was using crypto-core's Rng as a callable will fail typecheck — this is intentional and flagged in proposal.md §Migration. |
| 4 | @suaveplan/crypto-core |
defaultRng = defaultByteSource (a ByteSource) |
callable | object | Incompatible | Remove. It was a deprecation-shaped alias of defaultByteSource — consumers should use defaultByteSource directly. Repo-wide grep confirms zero internal callsites use the defaultRng re-export. |
| 5 | @suaveplan/crypto-core |
RandomBytesOptions { byteSource?: ByteSource; rng?: ByteSource } |
both are callable; rng is a legacy alias of byteSource |
— (not an Rng declaration; misleading field name) |
Field rename / drop | Remove the rng field. Internal selector becomes options.byteSource ?? defaultByteSource. Repo-wide grep confirms zero callsites pass { rng: ... } to randomBytes. |
| 6 | @suaveplan/crypto |
re-exports Rng, defaultRng from crypto-core |
inherits from #3, #4 | object | inherits | Update src/index.ts to forward the canonical Rng via crypto-core's new back-compat re-export; drop the defaultRng re-export. |
| 7 | @suaveplan/crypto-utils |
type ByteSource = (n: number) => Uint8Array (no Rng alias) |
callable | — (no Rng declared) |
Clean | No change. ByteSource stays under its real name as the package's callable byte producer. Spec delta on crypto-core documents the type-class distinction so future agents do not re-introduce the type Rng = ByteSource alias. |
| 8 | @suaveplan/mime |
type ByteSource = ReadableStream<Uint8Array> | AsyncIterable<Uint8Array> (file-scope, not exported) |
union of stream types | — (different concept) | Name collision, distinct concept | Rename to StreamByteSource to remove the cross-package collision with @suaveplan/crypto-{utils,core}'s ByteSource. File-scope only — zero external consumers. |
| 9 | @suaveplan/crypto-rsa |
function defaultRng(): Rng |
object (imports canonical Rng from @suaveplan/types) |
object | Already compliant — reference pattern | No change. This is the shape every future defaultRng() factory should follow when it sits outside @suaveplan/types. |
| 10 | @suaveplan/pseudo-random |
interface SeededRng extends Rng (from @suaveplan/types) |
object, extends canonical | object | Subtype extension — explicitly permitted by the spec clause | No change. Reference pattern for any future *Rng interface that needs more members than canonical. |
| 11 | @suaveplan/events-schema |
interface SchemaRng |
schema-registry abbreviation (Rng = "Registry") | — (not an RNG) | Distinct namespace; misleading name | No change in this OpenSpec change scope. Documented here so future audits don't re-flag it; a future rename (SchemaRegistryBinding or similar) is out of scope. |
Files this change edits
packages/core/types/src/rng.ts # comment-only: tighten "canonical" wording
packages/core/types/src/rng.md # add "Single source of truth" section
packages/testing/testing/src/context/context.ts # swap interface for re-export
packages/testing/testing/src/context/context.md # update API reference
packages/testing/testing/package.json # add @suaveplan/types: workspace:^
packages/crypto/crypto-core/src/primitives/random-bytes/random-bytes.ts # drop Rng alias, drop defaultRng, drop rng field, add canonical re-export
packages/crypto/crypto-core/src/primitives/random-bytes/random-bytes.md # update API reference
packages/crypto/crypto-core/src/primitives/random-bytes/index.ts # drop defaultRng from barrel; Rng forwards canonical
packages/crypto/crypto-core/src/primitives/index.ts # same
packages/crypto/crypto-core/src/index.ts # same
packages/crypto/crypto/src/index.ts # drop defaultRng; Rng forwards canonical
packages/data/mime/src/detection/from-stream.ts # rename ByteSource → StreamByteSource
packages/data/mime/src/detection/from-stream.md # update API reference
MIGRATION.md # root-level migration note per §13a
Why crypto-core's old type Rng = ByteSource was wrong
The canonical Rng is an object with nextUint32 / nextFloat
(uniform numeric source). ByteSource is a function (n) => Uint8Array
(callable byte producer — key material, nonces, AEAD entropy, salts).
These are fundamentally different shapes and use cases. Aliasing one as
the other meant:
- A consumer importing
Rngfromcrypto-coregot a callable; importing the same name from@suaveplan/typesgot an object. The two were silently incompatible at the typesystem level. - Cross-package code that needed both (e.g.
crypto-rsa/default-rng.ts) ended up disambiguating via import-path discipline, not via the type itself — fragile.
The fix splits the names by their real contracts: Rng is always the
object; ByteSource is always the callable.
Back-compat strategy
For one release cycle (until the follow-up removal change archives):
@suaveplan/crypto-core/random-bytesexports a back-compatRngre-export pointing at@suaveplan/types. The localtype Rng = ByteSourcealias is removed (keeping both shapes under the same name would break typecheck).@suaveplan/crypto-core/index,@suaveplan/crypto-core/primitives/index,@suaveplan/crypto-core/primitives/random-bytes/indexforward the same re-export.@suaveplan/crypto/indexforwards the same re-export.@suaveplan/testing/contextre-exports canonicalRng. The localinterface Rngdeclaration is removed.defaultRngis not preserved as a back-compat re-export anywhere (it was incorrectly typed —defaultRng: ByteSourcecannot satisfy canonicalRng). Internal audit confirms zero callsites; callers useddefaultByteSourcedirectly.
The deprecation prose comment names the canonical import path and the
removal change ID (remove-deprecated-rng-aliases).
Removal follow-up (out of scope here)
A separate OpenSpec change titled remove-deprecated-rng-aliases
(opened at least one release cycle after this change archives) will:
- Delete the
Rngre-export from@suaveplan/crypto-core/random-bytes. - Delete the
Rngre-export from@suaveplan/crypto-core/index,primitives/index,random-bytes/index. - Delete the
Rngre-export from@suaveplan/crypto/index. - Delete the
Rngre-export from@suaveplan/testing/context. - Run dependent sweeps and update any caught consumers.
This is tracked as a follow-up — not a phase-2 split (per CLAUDE.md §2b this change is complete on its own surface).
Error codes
No new error codes. The migration is structural-types-only; no new throw sites, no new error paths.
Verification commands
# Per affected package — full pre-publish gate
bunx turbo lint typecheck test --filter=@suaveplan/types
bunx turbo lint typecheck test --filter=@suaveplan/testing
bunx turbo lint typecheck test --filter=@suaveplan/crypto-core
bunx turbo lint typecheck test --filter=@suaveplan/crypto
bunx turbo lint typecheck test --filter=@suaveplan/mime
# Foundation dependent sweeps (mandatory for types and testing)
bunx turbo test --filter=...@suaveplan/types
bunx turbo test --filter=...@suaveplan/testing
bunx turbo test --filter=...@suaveplan/crypto-core
# Repo-wide grep — only the canonical declaration, the legitimate
# SeededRng extension, and the documented back-compat re-export
# forwarders should remain
grep -rln "interface Rng\b\|type Rng\s*=\|class Rng\b\|RngLike\b\|RandomSource\b" \
packages/ --include="*.ts" --exclude-dir=dist --exclude-dir=node_modules
Pre-flight cross-check
Mandatory reads completed:
openspec/AGENTS.md— spec-delta format, dependent-sweep requirements, archival flow.openspec/project.md— Bun + Turborepo conventions; rule 8 (testing-infra → only@suaveplan/typespermitted).openspec/changes/_superseded-narrow-deferrals-2026-05-17/unify-rng-across-packages/— prior 3-package draft; this draft extends to every package.packages/core/types/src/rng.ts— canonical shape verified.- Exhaustive grep across
packages/forinterface Rng\b|type Rng\s*=|class Rng\b|RngLike\b|RandomSource\b|ByteSource\b— all 30+ source-file matches inspected and categorised in the table above. Zero matches forRngLikeorRandomSourcein genesis (those identifiers exist only in upstream../suaveplan/and are not migrated here).