Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

1147
openspec/AGENTS.md Normal file

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-18

View File

@@ -0,0 +1,220 @@
# Design: enterprise-crypto-family
## Context
The existing `enterprise-crypto` change planned a single `@suaveplan/crypto` package covering hashing, KDFs, AEAD, signing, and key agreement. After auditing the `main` branch implementation (all source files, not just exports) and the downstream packages that consume it, the design splits into six focused packages. All six ship at `0.1.0` on genesis. The noble libraries (`@noble/hashes`, `@noble/ciphers`, `@noble/curves`) remain the default implementation everywhere — zero WASM, audited, tree-shakeable, identical across Bun/Node/browser. The sodium and browser packages are opt-in overlays, not replacements.
A name collision was discovered: `@suaveplan/crypto` currently exports `Rng = (n: number) => Uint8Array` while `@suaveplan/types` exports `Rng = { nextUint32(): number; nextFloat(): number }`. This change renames crypto's type to `ByteSource` throughout.
## Goals / Non-Goals
**Goals:**
- Ship six focused packages in dependency order: `hash` → `crypto` → (`crypto-sodium` ∥ `crypto-browser`) → `crypto-keys` → `crypto-vault`
- Preserve every public name from the `main` branch `@suaveplan/crypto` — nothing is removed, some things move packages
- Engine-pluggable AEAD, Sign, Agree, SealedBox, AuthBox via interface + noble default in `@suaveplan/crypto`; sodium/browser are opt-in engines
- 100% coverage on all six packages (Layer 1/2 foundation rule)
- Full enterprise checklist: Zod schemas, structured errors, fast-check property tests, mitata benchmarks, OTEL instrumentation, co-located `.md` docs
**Non-Goals:**
- Streaming AEAD — waits on `@suaveplan/streams`
- Post-quantum (Kyber, Dilithium) — revisit when @noble ships stable implementations
- HSM / TPM / hardware-backed key storage
- JOSE / JWT / JWE — belongs in `@suaveplan/jwt-tenant`
- Replacing `@suaveplan/testing`'s inline `seededRng` — the testing rule forbids adding workspace deps
## Decisions
### Package: `@suaveplan/hash`
Standalone hashing substrate, no engine concept needed — output is always deterministic for a given input.
```text
src/
hash/
sha2.ts / sha2.test.ts / sha2.md # sha256, sha384, sha512, hash(algo, data)
mac/
hmac.ts / hmac.test.ts / hmac.md # hmac(algo, key, data)
kdf/
hkdf.ts / hkdf.test.ts / hkdf.md # hkdf(algo, ikm, { salt, info, length })
pbkdf2.ts / pbkdf2.test.ts / pbkdf2.md # pbkdf2(algo, pw, { salt, iterations, length })
argon2id.ts / argon2id.test.ts / argon2id.md # argon2id(pw, { salt, timeCost, memoryCost, parallelism, length })
encoding/
hex.ts / hex.test.ts / hex.md # toHex, fromHex
base64.ts / base64.test.ts / base64.md # toBase64, fromBase64, toBase64Url, fromBase64Url
errors/
codes.ts / hash-error.ts / index.ts
constants/index.ts
index.ts
```
`HashAlgorithm = "SHA-256" | "SHA-384" | "SHA-512"` — the dispatcher type stays here.
`hasherFor(algo)` is package-private (not exported), used internally by hmac/hkdf/pbkdf2.
PBKDF2 enforces OWASP 2023 minimum (600,000 iterations for SHA-256). Argon2id enforces RFC 9106 §4 minimums per-field with individual error context.
### Package: `@suaveplan/crypto`
Engine interfaces + noble defaults + public façade functions + primitives.
```text
src/
engines/
aead-engine.types.ts # AeadEngine interface
sign-engine.types.ts # SignEngine interface
agree-engine.types.ts # AgreeEngine interface
sealed-box-engine.types.ts # SealedBoxEngine interface
auth-box-engine.types.ts # AuthBoxEngine interface
aead/
aead.ts / aead.test.ts / aead.md # aeadEncrypt, aeadDecrypt, AeadAlgorithm (adds XChaCha20-Poly1305)
noble-aead-engine.ts # noble default AeadEngine impl
sign/
ed25519.ts / ed25519.test.ts / ed25519.md
ecdsa-p256.ts / ecdsa-p256.test.ts / ecdsa-p256.md
noble-sign-engine.ts
agree/
x25519.ts / x25519.test.ts / x25519.md
noble-agree-engine.ts
sealed-box/
sealed-box.ts / sealed-box.test.ts / sealed-box.md
noble-sealed-box-engine.ts
auth-box/
auth-box.ts / auth-box.test.ts / auth-box.md
noble-auth-box-engine.ts
primitives/
random-bytes.ts / random-bytes.test.ts / random-bytes.md # ByteSource, defaultByteSource, randomBytes
constant-time-equal.ts / ...
zeroize.ts / ...
errors/
codes.ts / crypto-error.ts / index.ts
constants/index.ts
index.ts
```
`AeadAlgorithm` widens to `"AES-256-GCM" | "ChaCha20-Poly1305" | "XChaCha20-Poly1305"`.
`ByteSource = (n: number) => Uint8Array` — replaces the old `Rng` type. All key-generation functions accept `{ byteSource?: ByteSource }` (alias `{ rng?: ByteSource }` for backwards compat).
Every public function accepts `{ engine?: <EngineType> }` — omit to use the noble default.
Engine interfaces are exported as TypeScript types only — no runtime registration/factory needed. Consumers pass an engine instance.
### Package: `@suaveplan/crypto-sodium`
Wraps `libsodium-wrappers-sumo`. Handles `await sodium.ready` via a lazy singleton that resolves once on first import.
```text
src/
engines/
sodium-aead-engine.ts / ...
sodium-sign-engine.ts / ...
sodium-agree-engine.ts / ...
sodium-sealed-box-engine.ts / ...
sodium-auth-box-engine.ts / ...
primitives/
crypto-kdf.ts / ... # crypto_kdf_derive_from_key — not in any interface
crypto-auth.ts / ... # BLAKE2b MAC — not in any interface
crypto-pwhash.ts / ... # encoded password hash string — not in any interface
ristretto255.ts / ... # group ops — not in any interface
init.ts / init.md # sodiumReady(): Promise<void>
errors/ index.ts
index.ts
```
All exports are async-capable since sodium requires init. The engine implementations return `Promise`-wrapped results where needed, conforming to the async variant of each engine interface.
### Package: `@suaveplan/crypto-browser`
Uses `crypto.subtle`. Browser and modern Node/Bun compatible (Web Crypto API).
```text
src/
engines/
webcrypto-aead-engine.ts / ... # AES-256-GCM via crypto.subtle (hardware-accelerated)
webcrypto-sign-engine.ts / ... # Ed25519, ECDSA-P256 via crypto.subtle
webcrypto-agree-engine.ts / ... # X25519, ECDH-P256 via crypto.subtle
rsa/
rsa.ts / rsa.test.ts / rsa.md # RSA-PSS, RSA-OAEP, RSASSA-PKCS1
aes-kw/
aes-kw.ts / ... # AES Key Wrap
ecdsa-p384/
ecdsa-p384.ts / ...
ecdh-p384/
ecdh-p384.ts / ...
non-extractable-key/
non-extractable-key.ts / ... # opaque CryptoKey handle
errors/ index.ts
index.ts
```
All `crypto.subtle` operations are async — engine methods return `Promise<Uint8Array>`. Façade functions in `@suaveplan/crypto` already accept `engine?`; async engines are valid engine implementations.
### Package: `@suaveplan/crypto-keys`
```text
src/
keypair/
generate.ts / ... # generateKeypair(algo, { byteSource?, engine? })
validate.ts / ... # validateKeypair(keypair)
keychain/
keychain.ts / ... # in-memory keychain store
adapters/
memory-adapter.ts / ...
serialized-adapter.ts / ...
errors/ index.ts
index.ts
```
Engine-agnostic — delegates to `@suaveplan/crypto` façade functions.
### Package: `@suaveplan/crypto-vault`
```text
src/
mnemonic/
bip39.ts / ... # generateMnemonic, mnemonicToSeed (uses @scure/bip39)
vault/
vault.ts / ... # unlock(mnemonic, passphrase), lock(), sign(), derive()
vault-events.ts / ... # typed events: unlocked, locked, signed
derivation/
key-derivation.ts / ... # Argon2id/PBKDF2 unlock key from passphrase
errors/ index.ts
index.ts
```
Depends on `@suaveplan/hash` (KDFs), `@suaveplan/crypto` (signing façade), `@suaveplan/crypto-keys`, `@suaveplan/events`.
### `ByteSource` vs `Rng` — the rename
`@suaveplan/types`'s `Rng = { nextUint32(): number; nextFloat(): number }` is the canonical interface for number-producing randomness (used by `@suaveplan/pseudo-random` and `@suaveplan/random`).
`@suaveplan/crypto`'s byte-producer type is renamed `ByteSource = (n: number) => Uint8Array`. Both the old name (`Rng`) and new name (`ByteSource`) are exported from `@suaveplan/crypto` during genesis (no consumer on genesis yet, so no migration cost — but the alias is there for when `main` consumers are ported).
### Error shapes
Every package extends `SuaveplanError` with its own stable code namespace:
- `HASH.*` — `HASH.ALGORITHM.UNSUPPORTED`, `HASH.KDF.INVALID_PARAMS`
- `CRYPTO.*` — `CRYPTO.AEAD.DECRYPT_FAILED`, `CRYPTO.AEAD.INVALID_NONCE`, `CRYPTO.KEY.INVALID_LENGTH`, `CRYPTO.SIGN.VERIFY_FAILED`, `CRYPTO.SIGN.INVALID_KEY`, `CRYPTO.RNG.INVALID_LENGTH`, `CRYPTO.RNG.UNAVAILABLE`
- `SODIUM.*` — `SODIUM.INIT.FAILED`, `SODIUM.KDF.INVALID_CONTEXT`
- `BROWSER_CRYPTO.*` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED`, `BROWSER_CRYPTO.NOT_AVAILABLE`
- `CRYPTO_KEYS.*`, `CRYPTO_VAULT.*`
Error context never contains raw key bytes, plaintext, or authentication tags.
### Coverage strategy
All six packages target 100% (Layer 1/2 foundation rule).
- Determinism: same inputs → same outputs
- Round-trip: encrypt→decrypt, sign→verify
- Rejection: invalid lengths, sub-minimum KDF params, bad nonce sizes
- Engine substitution: every public function tested with both noble default and a stub engine
- Property-based (fast-check): AEAD round-trip, sign/verify identity, constant-time-equal reflexivity
- Cross-cutting `tests/no-disallowed.test.ts`: guards `Math.random`, `Date.now`, raw `crypto.getRandomValues` outside `primitives/random-bytes.ts`
## Risks / Trade-offs
| Risk | Mitigation |
|---|---|
| `libsodium-wrappers-sumo` WASM init is async | Lazy singleton in `init.ts`; tests use `await sodiumReady()` in `beforeAll` |
| `crypto.subtle` async engine breaks sync call sites | Engine interfaces define both sync and async variants; noble default is sync; browser engine is async; façade returns `Promise` when engine is async |
| `@scure/bip39` adds a new catalog dep | Adds to `crypto` catalog group in root `package.json`; acceptable |
| Six packages is a large wave | Implement in strict dependency order; each package's pre-publish gate must be green before the next starts |

View File

@@ -0,0 +1,49 @@
# Change: enterprise-crypto-family
## Why
The original `enterprise-crypto` change planned a single monolithic package. After a full audit of the existing `main` branch crypto package and the downstream packages that consume it, the surface is too wide for one package: hashing/KDFs have no engine concept, AEAD/signing/agreement benefit from swappable engine implementations (noble default, libsodium for interop, WebCrypto for hardware AES in-browser), and keypair lifecycle and vault belong in their own focused packages. Splitting now, before anything ships on genesis, costs nothing — the first published version is right.
## What Changes
### New packages (6)
- **`@suaveplan/hash`** — pure-noble hashing substrate: SHA-256/384/512, HMAC, HKDF, PBKDF2, Argon2id. No engine concept. Zero WASM. Depended on by `@suaveplan/crypto` and usable standalone.
- **`@suaveplan/crypto`** — the main crypto facade. Engine interfaces (`AeadEngine`, `SignEngine`, `AgreeEngine`, `SealedBoxEngine`), noble defaults for every interface, public façade functions (`aeadEncrypt`, `aeadDecrypt`, `sign`, `verify`, `agree`, `sealedBox.*`), and primitives (`constantTimeEqual`, `randomBytes` via injectable `ByteSource`, `zeroize`). Depends on `@suaveplan/hash`.
- **`@suaveplan/crypto-sodium`** — opt-in libsodium engine. Implements every engine interface from `@suaveplan/crypto` via `libsodium-wrappers-sumo`. Adds sodium-exclusive APIs not in any interface: `cryptoKdf`, `cryptoAuth`, `cryptoPwhashStr`/`Verify`, `ristretto255`. Handles `await sodium.ready` once at import.
- **`@suaveplan/crypto-browser`** — opt-in WebCrypto engine. Implements every engine interface via `crypto.subtle`. Adds browser-exclusive APIs: `rsa.*`, `aesKw`, `ecdsaP384/P521`, `ecdhP384/P521`, `nonExtractableKey`. Hardware AES-GCM acceleration in supporting browsers.
- **`@suaveplan/crypto-keys`** — keypair lifecycle built on top of `@suaveplan/crypto`. `generateKeypair`, `validateKeypair`, keychain store, persistence adapters (in-memory, serialized). Engine-agnostic.
- **`@suaveplan/crypto-vault`** — vault and BIP39 mnemonic system. Derives keys via Argon2id/PBKDF2 (from `@suaveplan/hash`), chain-signer pattern, event emission via `@suaveplan/events`. Supersedes `@suaveplan/keys` on main.
### Supersedes
- `openspec/changes/enterprise-crypto` — the single-package plan is replaced by this family. The original `enterprise-crypto` change is archived.
### Breaking changes
None on genesis (nothing has shipped yet).
## Capabilities
### New Capabilities
- `hash-primitives`: SHA-2 digests, HMAC, HKDF, PBKDF2, Argon2id — all via `@noble/hashes`. Lives in `@suaveplan/hash`.
- `crypto-engine-interfaces`: `AeadEngine`, `SignEngine`, `AgreeEngine`, `SealedBoxEngine`, `AuthBoxEngine` — the pluggable engine contracts exported by `@suaveplan/crypto`.
- `crypto-noble-defaults`: noble implementations of every engine interface, wired as defaults in `@suaveplan/crypto`.
- `crypto-primitives`: `constantTimeEqual`, `randomBytes` (injectable `ByteSource`), `zeroize`, `Rng`→`ByteSource` rename to resolve conflict with `@suaveplan/types`'s `Rng`.
- `crypto-sodium-engine`: libsodium implementations of all engine interfaces plus sodium-exclusive primitives.
- `crypto-browser-engine`: WebCrypto implementations of all engine interfaces plus browser-exclusive primitives.
- `crypto-keys`: keypair generation, validation, and storage lifecycle.
- `crypto-vault`: mnemonic derivation, vault unlock, chain-signer, event emission.
### Modified Capabilities
None — no existing genesis specs exist for these capabilities yet.
## Impact
- **Dependencies added to catalog**: `@noble/hashes`, `@noble/ciphers`, `@noble/curves` (already planned), `libsodium-wrappers-sumo`, `@types/libsodium-wrappers-sumo`
- **Dependency order**: `hash` → `crypto` → `crypto-sodium` + `crypto-browser` (parallel) → `crypto-keys` → `crypto-vault`
- **`@suaveplan/types` `Rng` preserved** — crypto renames its own byte-producer type to `ByteSource` to eliminate the name collision without touching `@suaveplan/types`
- **Downstream unblocked**: `@suaveplan/jwt-tenant`, `@suaveplan/auth`, `@suaveplan/network` encryption, `@suaveplan/slsa`, every package that currently imports from `main`'s `@suaveplan/crypto`
- **Non-goals**: streaming AEAD (needs `@suaveplan/streams`), post-quantum algorithms, HSM/TPM, JOSE/JWT (belongs in `@suaveplan/jwt-tenant`)

View File

@@ -0,0 +1,33 @@
# Capability: crypto-browser-engine
## ADDED Requirements
### Package identity
- REQ-BRW-001: Package name `@suaveplan/crypto-browser`, `packages/universal/crypto-browser`.
- REQ-BRW-002: Runtime deps: `@suaveplan/crypto`, `@suaveplan/error`, `tslib`. Zero Node-only deps.
- REQ-BRW-003: All exports are async (return `Promise<...>`) — `crypto.subtle` is async.
### Engine implementations
- REQ-BRW-010: Exports `webCryptoAeadEngine: AeadEngine` — AES-256-GCM via `crypto.subtle.encrypt`/`decrypt` (hardware-accelerated in supporting browsers/runtimes).
- REQ-BRW-011: Exports `webCryptoSignEngine: SignEngine` — Ed25519 and ECDSA-P256 via `crypto.subtle`.
- REQ-BRW-012: Exports `webCryptoAgreeEngine: AgreeEngine` — X25519 and ECDH-P256 via `crypto.subtle`.
### Browser-exclusive primitives
- REQ-BRW-020: Exports `rsa` namespace: `{ generateKeypair(algorithm, modulusLength?), sign(algorithm, privateKey, message), verify(algorithm, publicKey, signature, message), encrypt(publicKey, plaintext), decrypt(privateKey, ciphertext) }`. Algorithms: `"RSA-PSS"`, `"RSA-OAEP"`, `"RSASSA-PKCS1-v1_5"`.
- REQ-BRW-021: Exports `aesKw` namespace: `{ wrapKey(wrappingKey, keyToWrap), unwrapKey(wrappingKey, wrappedKey) }`.
- REQ-BRW-022: Exports `ecdsaP384` namespace: sign/verify/generateKeypair on P-384 curve.
- REQ-BRW-023: Exports `ecdhP384` namespace: agree/generateKeypair on P-384 curve.
- REQ-BRW-024: Exports `nonExtractableKey(algorithm, usages): Promise<CryptoKey>` — generates a non-extractable `CryptoKey` handle. Key material is never accessible to JS code after generation.
### Availability check
- REQ-BRW-030: Exports `isWebCryptoAvailable(): boolean` — returns `typeof crypto !== 'undefined' && !!crypto.subtle`.
- REQ-BRW-031: All operations throw `BrowserCryptoError("BROWSER_CRYPTO.NOT_AVAILABLE")` if called in an environment without `crypto.subtle`.
### Error surface
- REQ-BRW-040: Exports `BrowserCryptoError extends SuaveplanError<BrowserCryptoCode>`.
- REQ-BRW-041: Codes: `BROWSER_CRYPTO.NOT_AVAILABLE`, `BROWSER_CRYPTO.KEY.GENERATION_FAILED`, `BROWSER_CRYPTO.OPERATION.FAILED`.

View File

@@ -0,0 +1,22 @@
# Capability: crypto-engine-interfaces
## ADDED Requirements
### Engine interface types
- REQ-ENG-001: `@suaveplan/crypto` exports `AeadEngine` interface: `{ encrypt(key, plaintext, nonce, aad?): Uint8Array; decrypt(key, ciphertext, nonce, aad?): Uint8Array }`.
- REQ-ENG-002: `@suaveplan/crypto` exports `SignEngine` interface: `{ generateKeypair(byteSource?): Keypair; sign(secretKey, message): Uint8Array; verify(publicKey, signature, message): boolean }`.
- REQ-ENG-003: `@suaveplan/crypto` exports `AgreeEngine` interface: `{ generateKeypair(byteSource?): Keypair; agree(mySecret, theirPublic): Uint8Array }`.
- REQ-ENG-004: `@suaveplan/crypto` exports `SealedBoxEngine` interface: `{ seal(recipientPublic, plaintext): Uint8Array; open(recipientSecret, recipientPublic, ciphertext): Uint8Array }`.
- REQ-ENG-005: `@suaveplan/crypto` exports `AuthBoxEngine` interface: `{ seal(senderSecret, recipientPublic, plaintext, nonce): Uint8Array; open(senderPublic, recipientSecret, ciphertext, nonce): Uint8Array }`.
### Algorithm type identifiers
- REQ-ENG-010: `AeadAlgorithm = "AES-256-GCM" | "ChaCha20-Poly1305" | "XChaCha20-Poly1305"`.
- REQ-ENG-011: `SignAlgorithm = "Ed25519" | "ECDSA-P256"`.
- REQ-ENG-012: `AgreeAlgorithm = "X25519" | "ECDH-P256"`.
### Engine interfaces are types only
- REQ-ENG-020: Engine interfaces are exported as TypeScript `interface` declarations — no runtime class, no factory function, no registry. Consumers receive an engine instance as a plain object.
- REQ-ENG-021: All engine interface methods that may fail throw `CryptoError` with appropriate codes.

View File

@@ -0,0 +1,32 @@
# Capability: crypto-keys
## ADDED Requirements
### Package identity
- REQ-KEYS-001: Package name `@suaveplan/crypto-keys`, `packages/universal/crypto-keys`.
- REQ-KEYS-002: Runtime deps: `@suaveplan/crypto`, `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`.
### Keypair generation
- REQ-KEYS-010: Exports `generateKeypair(algo: SignAlgorithm | AgreeAlgorithm, opts?: { byteSource?: ByteSource; engine?: SignEngine | AgreeEngine }): Keypair`.
- REQ-KEYS-011: `Keypair = { publicKey: Uint8Array; secretKey: Uint8Array; algorithm: string }`.
- REQ-KEYS-012: Delegates to the appropriate `@suaveplan/crypto` façade based on `algo`.
### Keypair validation
- REQ-KEYS-020: Exports `validateKeypair(keypair: Keypair): ValidationResult`.
- REQ-KEYS-021: Checks: key lengths match algorithm constants, public key is derivable from secret key (for Ed25519 and X25519).
- REQ-KEYS-022: Returns `{ valid: boolean; errors: string[] }`.
### Keychain store
- REQ-KEYS-030: Exports `Keychain` class: `{ add(id: string, keypair: Keypair): void; get(id: string): Keypair | undefined; remove(id: string): void; list(): string[]; clear(): void }`.
- REQ-KEYS-031: In-memory by default. Accepts an optional `KeychainAdapter` for persistence.
- REQ-KEYS-032: Exports `KeychainAdapter` interface: `{ load(): Promise<Map<string, Keypair>>; save(entries: Map<string, Keypair>): Promise<void> }`.
- REQ-KEYS-033: Exports `MemoryKeychainAdapter` (default) and `SerializedKeychainAdapter` (serialises to/from JSON string via a provided read/write pair).
### Error surface
- REQ-KEYS-040: Exports `CryptoKeysError extends SuaveplanError<CryptoKeysCode>`.
- REQ-KEYS-041: Codes: `CRYPTO_KEYS.KEYPAIR.INVALID`, `CRYPTO_KEYS.KEYCHAIN.NOT_FOUND`, `CRYPTO_KEYS.ADAPTER.LOAD_FAILED`, `CRYPTO_KEYS.ADAPTER.SAVE_FAILED`.

View File

@@ -0,0 +1,42 @@
# Capability: crypto-noble-defaults
## ADDED Requirements
### AEAD façade
- REQ-NOBLE-010: Exports `aeadEncrypt(algo: AeadAlgorithm, key: Uint8Array, plaintext: Uint8Array, options: AeadEncryptOptions, opts?: { engine?: AeadEngine }): Uint8Array`.
- REQ-NOBLE-011: Exports `aeadDecrypt(algo: AeadAlgorithm, key: Uint8Array, ciphertext: Uint8Array, options: AeadDecryptOptions, opts?: { engine?: AeadEngine }): Uint8Array`.
- REQ-NOBLE-012: `AeadEncryptOptions = { nonce: Uint8Array; aad?: Uint8Array }`. `AeadDecryptOptions` is the same shape.
- REQ-NOBLE-013: Default engine is noble: `@noble/ciphers` AES-256-GCM and ChaCha20-Poly1305; `@noble/ciphers` XChaCha20-Poly1305.
- REQ-NOBLE-014: Key must be 32 bytes; throws `CryptoError("CRYPTO.KEY.INVALID_LENGTH")` otherwise.
- REQ-NOBLE-015: Nonce must be 12 bytes for AES-256-GCM and ChaCha20-Poly1305, 24 bytes for XChaCha20-Poly1305; throws `CryptoError("CRYPTO.AEAD.INVALID_NONCE")` otherwise.
- REQ-NOBLE-016: Exports `AEAD_KEY_LENGTH = 32`, `AEAD_NONCE_LENGTH = 12`, `AEAD_XCHACHA_NONCE_LENGTH = 24`.
### Sign façade
- REQ-NOBLE-020: Exports `ed25519` namespace: `{ generateKeypair(opts?), sign(secretKey, message), verify(publicKey, signature, message) }`.
- REQ-NOBLE-021: Exports `ecdsaP256` namespace: same shape as `ed25519`.
- REQ-NOBLE-022: All functions accept `{ engine?: SignEngine }` as a trailing options arg.
- REQ-NOBLE-023: Noble defaults: `@noble/curves` ed25519 and P-256.
- REQ-NOBLE-024: `verify` always returns `boolean`, never throws on a bad signature.
- REQ-NOBLE-025: `ecdsaP256.generateKeypair` retries until `noble.utils.isValidSecretKey(secretKey)` is true.
### Agree façade
- REQ-NOBLE-030: Exports `x25519` namespace: `{ generateKeypair(opts?), agree(mySecret, theirPublic) }`.
- REQ-NOBLE-031: Accepts `{ engine?: AgreeEngine }`.
- REQ-NOBLE-032: Noble default: `@noble/curves` x25519.
### Sealed-box façade
- REQ-NOBLE-040: Exports `sealedBox` namespace: `{ seal(recipientPublic, plaintext, opts?), open(recipientSecret, recipientPublic, ciphertext, opts?) }`.
- REQ-NOBLE-041: Noble default: X25519 key agreement + XChaCha20-Poly1305 encryption (crypto_box_seal semantics).
### Auth-box façade
- REQ-NOBLE-050: Exports `authBox` namespace: `{ seal(senderSecret, recipientPublic, plaintext, nonce, opts?), open(senderPublic, recipientSecret, ciphertext, nonce, opts?) }`.
- REQ-NOBLE-051: Noble default: X25519 key agreement + XChaCha20-Poly1305 (crypto_box semantics).
### Constants
- REQ-NOBLE-060: Exports `ED25519_SECRET_KEY_LENGTH = 32`, `ED25519_PUBLIC_KEY_LENGTH = 32`, `ED25519_SIGNATURE_LENGTH = 64`, `X25519_KEY_LENGTH = 32`, `ECDSA_P256_SIGNATURE_LENGTH = 64`.

View File

@@ -0,0 +1,33 @@
# Capability: crypto-primitives
## ADDED Requirements
### ByteSource type
- REQ-PRIM-001: `@suaveplan/crypto` exports `ByteSource = (n: number) => Uint8Array` — the byte-producing injection type used for key generation.
- REQ-PRIM-002: `Rng` is exported as a type alias for `ByteSource` for backwards compatibility during the main→genesis port. New code uses `ByteSource`.
- REQ-PRIM-003: `ByteSource` is explicitly documented as distinct from `@suaveplan/types`'s `Rng` interface (which produces numbers, not bytes).
### randomBytes
- REQ-PRIM-010: Exports `randomBytes(n: number, opts?: { byteSource?: ByteSource }): Uint8Array`.
- REQ-PRIM-011: Default `byteSource` calls `crypto.getRandomValues` — this is the **only** callsite for `crypto.getRandomValues` in the package.
- REQ-PRIM-012: Throws `CryptoError("CRYPTO.RNG.INVALID_LENGTH")` if `n` is not a non-negative integer.
- REQ-PRIM-013: Exports `defaultByteSource: ByteSource`.
### constantTimeEqual
- REQ-PRIM-020: Exports `constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean`.
- REQ-PRIM-021: Uses XOR-accumulate pattern — branch-free for content bytes. Length mismatch short-circuits (length is not secret).
- REQ-PRIM-022: Returns `true` iff `a.length === b.length` and all bytes are identical.
### zeroize
- REQ-PRIM-030: Exports `zeroize(buf: Uint8Array): void` — fills buffer with zeros in-place.
- REQ-PRIM-031: Best-effort (JS GC may have copied); intent is to minimise sensitive-data lifetime on the heap.
### Error surface
- REQ-PRIM-040: Exports `CryptoError extends SuaveplanError<CryptoCode>`.
- REQ-PRIM-041: Error codes: `CRYPTO.AEAD.DECRYPT_FAILED`, `CRYPTO.AEAD.INVALID_NONCE`, `CRYPTO.KEY.INVALID_LENGTH`, `CRYPTO.SIGN.VERIFY_FAILED`, `CRYPTO.SIGN.INVALID_KEY`, `CRYPTO.RNG.INVALID_LENGTH`, `CRYPTO.RNG.UNAVAILABLE`.
- REQ-PRIM-042: No error message or context field contains raw key bytes, plaintext, or authentication tags.

View File

@@ -0,0 +1,36 @@
# Capability: crypto-sodium-engine
## ADDED Requirements
### Package identity
- REQ-SOD-001: Package name `@suaveplan/crypto-sodium`, `packages/universal/crypto-sodium`.
- REQ-SOD-002: Runtime deps: `libsodium-wrappers-sumo`, `@suaveplan/crypto`, `@suaveplan/error`, `tslib`.
- REQ-SOD-003: Peer dep: `@suaveplan/crypto` at the same genesis version.
### Initialisation
- REQ-SOD-010: Exports `sodiumReady(): Promise<void>` — resolves when `libsodium-wrappers-sumo` WASM is initialised. Idempotent; resolves immediately on subsequent calls.
- REQ-SOD-011: All engine implementations call `await sodiumReady()` internally; callers do not need to call it explicitly.
### Engine implementations
- REQ-SOD-020: Exports `sodiumAeadEngine: AeadEngine` — implements AES-256-GCM (where available via hardware), ChaCha20-Poly1305, XChaCha20-Poly1305 via `libsodium`.
- REQ-SOD-021: Exports `sodiumSignEngine: SignEngine` — Ed25519 sign/verify/generateKeypair via `libsodium`.
- REQ-SOD-022: Exports `sodiumAgreeEngine: AgreeEngine` — X25519 agree/generateKeypair via `libsodium`.
- REQ-SOD-023: Exports `sodiumSealedBoxEngine: SealedBoxEngine` — `crypto_box_seal` / `crypto_box_seal_open` semantics.
- REQ-SOD-024: Exports `sodiumAuthBoxEngine: AuthBoxEngine` — `crypto_box_easy` / `crypto_box_open_easy` semantics.
### Sodium-exclusive primitives
- REQ-SOD-030: Exports `cryptoKdf(key: Uint8Array, subkeyId: number, context: string, length: number): Uint8Array` — deterministic subkey derivation via `crypto_kdf_derive_from_key`. Context must be exactly 8 bytes when encoded.
- REQ-SOD-031: Exports `cryptoAuth(key: Uint8Array, message: Uint8Array): Uint8Array` — BLAKE2b-based MAC via `crypto_auth`.
- REQ-SOD-032: Exports `cryptoAuthVerify(key: Uint8Array, message: Uint8Array, mac: Uint8Array): boolean` — verifies BLAKE2b MAC.
- REQ-SOD-033: Exports `cryptoPwhashStr(password: Uint8Array, opts?: { opsLimit?, memLimit? }): string` — Argon2id encoded hash string (includes salt, ready to store).
- REQ-SOD-034: Exports `cryptoPwhashStrVerify(hash: string, password: Uint8Array): boolean`.
- REQ-SOD-035: Exports `ristretto255` namespace: `{ add, sub, mul, fromHash, toBytes, fromBytes, random }` — group operations.
### Error surface
- REQ-SOD-040: Exports `SodiumError extends SuaveplanError<SodiumCode>`.
- REQ-SOD-041: Codes: `SODIUM.INIT.FAILED`, `SODIUM.KDF.INVALID_CONTEXT`, `SODIUM.KDF.INVALID_LENGTH`, `SODIUM.AUTH.VERIFY_FAILED`.

View File

@@ -0,0 +1,31 @@
# Capability: crypto-vault
## ADDED Requirements
### Package identity
- REQ-VAULT-001: Package name `@suaveplan/crypto-vault`, `packages/universal/crypto-vault`.
- REQ-VAULT-002: Runtime deps: `@suaveplan/crypto`, `@suaveplan/crypto-keys`, `@suaveplan/hash`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@scure/bip39`, `tslib`, `zod`.
### BIP39 mnemonic
- REQ-VAULT-010: Exports `generateMnemonic(wordCount?: 12 | 15 | 18 | 21 | 24, opts?: { byteSource? }): string`.
- REQ-VAULT-011: Exports `validateMnemonic(mnemonic: string): boolean`.
- REQ-VAULT-012: Exports `mnemonicToSeed(mnemonic: string, passphrase?: string): Uint8Array` — 64-byte seed via PBKDF2-SHA512 per BIP39.
### Vault lifecycle
- REQ-VAULT-020: Exports `Vault` class: `{ unlock(mnemonic, passphrase?), lock(), isLocked(): boolean, sign(keyId, message), derive(path), getPublicKey(keyId) }`.
- REQ-VAULT-021: Unlock derives a master key via Argon2id from the mnemonic seed. Locked vault holds no key material in memory.
- REQ-VAULT-022: `lock()` calls `zeroize` on all in-memory key buffers before clearing references.
- REQ-VAULT-023: Vault emits typed events via `@suaveplan/events`: `unlocked`, `locked`, `signed`, `derived`.
### Chain signer
- REQ-VAULT-030: Exports `ChainSigner` class: `{ addKey(id, keypair), removeKey(id), sign(keyId, message, opts?) }`.
- REQ-VAULT-031: Signs using the key algorithm associated with the stored keypair.
### Error surface
- REQ-VAULT-040: Exports `CryptoVaultError extends SuaveplanError<CryptoVaultCode>`.
- REQ-VAULT-041: Codes: `CRYPTO_VAULT.LOCKED`, `CRYPTO_VAULT.MNEMONIC.INVALID`, `CRYPTO_VAULT.KEY.NOT_FOUND`, `CRYPTO_VAULT.DERIVATION.FAILED`.

View File

@@ -0,0 +1,62 @@
# Capability: hash-primitives
## ADDED Requirements
### Package identity
- REQ-HASH-001: Package name is `@suaveplan/hash`, located at `packages/universal/hash`.
- REQ-HASH-002: Runtime deps: `@noble/hashes`, `@suaveplan/error`, `tslib`, `zod`. Zero browser/React/Node-only deps.
- REQ-HASH-003: Ships `"sideEffects": false`. All modules are individually subpath-exported for tree-shaking.
### Hash digests
- REQ-HASH-010: Exports `sha256(data: Uint8Array): Uint8Array` — 32-byte SHA-256 digest.
- REQ-HASH-011: Exports `sha384(data: Uint8Array): Uint8Array` — 48-byte SHA-384 digest.
- REQ-HASH-012: Exports `sha512(data: Uint8Array): Uint8Array` — 64-byte SHA-512 digest.
- REQ-HASH-013: Exports `HashAlgorithm = "SHA-256" | "SHA-384" | "SHA-512"`.
- REQ-HASH-014: Exports `hash(algo: HashAlgorithm, data: Uint8Array): Uint8Array` — algorithm dispatcher.
### HMAC
- REQ-HASH-020: Exports `hmac(algo: HashAlgorithm, key: Uint8Array, data: Uint8Array): Uint8Array`.
- REQ-HASH-021: Empty key is permitted (library-level; policy enforcement is the caller's responsibility).
### HKDF
- REQ-HASH-030: Exports `hkdf(algo: HashAlgorithm, ikm: Uint8Array, options: HkdfOptions): Uint8Array`.
- REQ-HASH-031: `HkdfOptions = { salt: Uint8Array; info: Uint8Array; length: number }`. All fields required.
- REQ-HASH-032: Throws `HashError("HASH.KDF.INVALID_PARAMS")` if `length <= 0` or `length > 255 * hashLen`.
### PBKDF2
- REQ-HASH-040: Exports `pbkdf2(algo: HashAlgorithm, password: Uint8Array, options: Pbkdf2Options): Uint8Array`.
- REQ-HASH-041: `Pbkdf2Options = { salt: Uint8Array; iterations: number; length: number }`.
- REQ-HASH-042: Throws `HashError("HASH.KDF.INVALID_PARAMS")` if `iterations < 600_000` (OWASP 2023 minimum for SHA-256). Same minimum for SHA-384/512 (conservative — these are also interactive-login strength minimums).
- REQ-HASH-043: Exports `PBKDF2_MIN_ITERATIONS = 600_000`.
### Argon2id
- REQ-HASH-050: Exports `argon2id(password: Uint8Array, options: Argon2idOptions): Uint8Array`.
- REQ-HASH-051: `Argon2idOptions = { salt: Uint8Array; timeCost: number; memoryCost: number; parallelism: number; length: number }`.
- REQ-HASH-052: Throws `HashError("HASH.KDF.INVALID_PARAMS")` per-field: `timeCost < 1`, `memoryCost < 8`, `parallelism < 1`, `length <= 0`. Each error's `context` includes `{ field, value, minimum }`.
- REQ-HASH-053: Exports `ARGON2ID_MIN_MEMORY_COST_KIB = 8`, `ARGON2ID_MIN_TIME_COST = 1`, `ARGON2ID_MIN_PARALLELISM = 1`.
### Encoding utilities
- REQ-HASH-060: Exports `toHex(bytes: Uint8Array): string` and `fromHex(hex: string): Uint8Array`.
- REQ-HASH-061: Exports `toBase64(bytes: Uint8Array): string`, `fromBase64(b64: string): Uint8Array`, `toBase64Url(bytes: Uint8Array): string`, `fromBase64Url(b64url: string): Uint8Array`.
- REQ-HASH-062: `fromHex`/`fromBase64`/`fromBase64Url` throw `HashError("HASH.ENCODING.INVALID_INPUT")` on malformed input.
### Error surface
- REQ-HASH-070: Exports `HashError extends SuaveplanError<HashCode>`.
- REQ-HASH-071: Error codes namespace: `HASH.KDF.INVALID_PARAMS`, `HASH.ALGORITHM.UNSUPPORTED`, `HASH.ENCODING.INVALID_INPUT`.
- REQ-HASH-072: No error message or context field contains raw key material or password bytes.
### Quality
- REQ-HASH-080: 100% statement + branch coverage (istanbul).
- REQ-HASH-081: fast-check property tests: determinism (same input → same output), HMAC known-vector (RFC 4231 vectors).
- REQ-HASH-082: mitata benchmarks: sha256/sha384/sha512 at 1 KiB and 1 MiB; argon2id at minimum params.
- REQ-HASH-083: Every `.ts` implementation file has a sibling `.md` (≥200 words).
- REQ-HASH-084: `tests/no-disallowed.test.ts` asserts no `Math.random`, `Date.now`, or `crypto.getRandomValues` in `src/`.

View File

@@ -0,0 +1,101 @@
# Tasks: enterprise-crypto-family
## Pre-work
- [x] 1. Archive `openspec/changes/enterprise-crypto/` → `openspec/archive/2026-04-18-enterprise-crypto/`
- [x] 2. Add catalog entries to root `package.json`: `@noble/hashes`, `@noble/ciphers`, `@noble/curves`, `libsodium-wrappers-sumo`, `@types/libsodium-wrappers-sumo`, `@scure/bip39`
- [x] 3. Delete `packages/universal/crypto/` (existing genesis scaffold, will be rebuilt as the new multi-package family)
## Wave 1 — @suaveplan/hash (no workspace deps)
- [x] 4. Scaffold `packages/universal/hash`: `package.json` (deps: `@noble/hashes`, `@suaveplan/error`, `tslib`, `zod`), `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts` (100/100/100/100 thresholds)
- [x] 5. `errors/`: `HashError`, `HASH_CODES`, `hash-error.ts`, `codes.ts`, `index.ts`
- [x] 6. `hash/sha2.ts` + test + md: `sha256`, `sha384`, `sha512`, `hash(algo, data)`, `HashAlgorithm`
- [x] 7. `mac/hmac.ts` + test + md: `hmac(algo, key, data)`; `hasherFor` private to package
- [x] 8. `kdf/hkdf.ts` + test + md: `hkdf(algo, ikm, { salt, info, length })`
- [x] 9. `kdf/pbkdf2.ts` + test + md: `pbkdf2`, `PBKDF2_MIN_ITERATIONS`, parameter validation
- [x] 10. `kdf/argon2id.ts` + test + md: `argon2id`, min constants, per-field validation
- [x] 11. `encoding/hex.ts` + `encoding/base64.ts` + tests + mds
- [x] 12. `constants/index.ts`; barrel `src/index.ts`; subpath exports in `package.json`
- [x] 13. `tests/no-disallowed.test.ts`, `tests/tree-shake.test.ts`
- [x] 14. fast-check property tests; mitata benchmarks
- [x] 15. `README.md`
- [x] 16. `bunx turbo lint typecheck test:coverage --filter=@suaveplan/hash --force` → green at 100%
- [x] 17. `bun scripts/publish.ts --package @suaveplan/hash`
## Wave 2 — @suaveplan/crypto (depends on hash, error, types)
- [x] 18. Scaffold `packages/universal/crypto`: `package.json` (deps: `@noble/ciphers`, `@noble/curves`, `@suaveplan/hash`, `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`), configs, 100% thresholds
- [x] 19. `errors/`: `CryptoError`, `CRYPTO_CODES`
- [x] 20. `engines/`: `aead-engine.types.ts`, `sign-engine.types.ts`, `agree-engine.types.ts`, `sealed-box-engine.types.ts`, `auth-box-engine.types.ts`
- [x] 21. `primitives/random-bytes.ts` + test + md: `ByteSource`, `defaultByteSource`, `randomBytes`; single `crypto.getRandomValues` callsite
- [x] 22. `primitives/constant-time-equal.ts` + test + md
- [x] 23. `primitives/zeroize.ts` + test + md
- [x] 24. `aead/noble-aead-engine.ts` + `aead/aead.ts` + test + md: `aeadEncrypt`, `aeadDecrypt`, `AeadAlgorithm` (adds XChaCha20-Poly1305), key/nonce validation
- [x] 25. `sign/noble-sign-engine.ts` + `sign/ed25519.ts` + test + md
- [x] 26. `sign/ecdsa-p256.ts` + test + md (retry loop for key validity)
- [x] 27. `agree/noble-agree-engine.ts` + `agree/x25519.ts` + test + md
- [x] 28. `sealed-box/noble-sealed-box-engine.ts` + `sealed-box/sealed-box.ts` + test + md
- [x] 29. `auth-box/noble-auth-box-engine.ts` + `auth-box/auth-box.ts` + test + md
- [x] 30. `constants/index.ts`; barrel `src/index.ts`; subpath exports
- [x] 31. `tests/no-disallowed.test.ts`, `tests/dep-graph.test.ts`, `tests/tree-shake.test.ts`
- [x] 32. fast-check property tests; mitata benchmarks; Zod schemas for all public option types
- [x] 33. `README.md`
- [x] 34. `bunx turbo lint typecheck test:coverage --filter=@suaveplan/crypto --force` → green at 100%
- [x] 35. `bun scripts/publish.ts --package @suaveplan/crypto`
## Wave 3 — @suaveplan/crypto-sodium and @suaveplan/crypto-browser (parallel, both depend on crypto)
- [x] 36. Scaffold `packages/universal/crypto-sodium`: package.json (deps: `libsodium-wrappers-sumo`, `@types/libsodium-wrappers-sumo`, `@suaveplan/crypto`, `@suaveplan/error`, `tslib`), configs
- [x] 37. `init.ts` + md: `sodiumReady()` lazy singleton
- [x] 38. `engines/sodium-aead-engine.ts` + test + md
- [x] 39. `engines/sodium-sign-engine.ts` + test + md
- [x] 40. `engines/sodium-agree-engine.ts` + test + md
- [x] 41. `engines/sodium-sealed-box-engine.ts` + test + md
- [x] 42. `engines/sodium-auth-box-engine.ts` + test + md
- [x] 43. `primitives/crypto-kdf.ts` + test + md
- [x] 44. `primitives/crypto-auth.ts` + test + md
- [x] 45. `primitives/crypto-pwhash.ts` + test + md
- [x] 46. `primitives/ristretto255.ts` + test + md
- [x] 47. barrel + subpath exports + README; `bunx turbo lint typecheck test:coverage --filter=@suaveplan/crypto-sodium --force` → 100%
- [x] 48. `bun scripts/publish.ts --package @suaveplan/crypto-sodium`
- [x] 49. Scaffold `packages/universal/crypto-browser`: package.json (deps: `@suaveplan/crypto`, `@suaveplan/error`, `tslib`), configs
- [x] 50. `engines/webcrypto-aead-engine.ts` + test + md
- [x] 51. `engines/webcrypto-sign-engine.ts` + test + md
- [x] 52. `engines/webcrypto-agree-engine.ts` + test + md
- [x] 53. `rsa/rsa.ts` + test + md
- [x] 54. `aes-kw/aes-kw.ts` + test + md
- [x] 55. `ecdsa-p384/` + `ecdh-p384/` + tests + mds
- [x] 56. `non-extractable-key/non-extractable-key.ts` + test + md
- [x] 57. `isWebCryptoAvailable` + `BrowserCryptoError`; barrel + subpath exports + README
- [x] 58. `bunx turbo lint typecheck test:coverage --filter=@suaveplan/crypto-browser --force` → 100%
- [x] 59. `bun scripts/publish.ts --package @suaveplan/crypto-browser`
## Wave 4 — @suaveplan/crypto-keys (depends on crypto)
- [x] 60. Scaffold `packages/universal/crypto-keys`: package.json, configs
- [x] 61. `keypair/generate.ts` + test + md
- [x] 62. `keypair/validate.ts` + test + md
- [x] 63. `keychain/keychain.ts` + test + md
- [x] 64. `keychain/adapters/memory-adapter.ts` + `serialized-adapter.ts` + tests + mds
- [x] 65. `errors/`; barrel + subpath exports + README
- [x] 66. `bunx turbo lint typecheck test:coverage --filter=@suaveplan/crypto-keys --force` → 100%
- [x] 67. `bun scripts/publish.ts --package @suaveplan/crypto-keys`
## Wave 5 — @suaveplan/crypto-vault (depends on crypto, crypto-keys, hash, events)
- [x] 68. Scaffold `packages/universal/crypto-vault`: package.json (deps: `@suaveplan/crypto`, `@suaveplan/crypto-keys`, `@suaveplan/hash`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@scure/bip39`, `tslib`, `zod`), configs
- [x] 69. `mnemonic/mnemonic.ts` + test + md: `generateMnemonic`, `validateMnemonic`, `mnemonicToSeed`
- [x] 70. (derivation folded into vault.ts — HKDF child key derivation inline)
- [x] 71. `vault/vault.types.ts` — `VaultEvents`, `VaultOptions`
- [x] 72. `vault/vault.ts` + test + md: unlock, lock, sign, derive, zeroize on lock
- [x] 73. `chain-signer/chain-signer.ts` + test + md
- [x] 74. `errors/`; barrel + subpath exports + README
- [x] 75. `bunx turbo lint typecheck test:coverage --filter=@suaveplan/crypto-vault --force` → 100%
- [x] 76. `bun scripts/publish.ts --package @suaveplan/crypto-vault`
## Close-out
- [x] 77. Update `CLAUDE.md` package listing for all 6 new packages
- [x] 78. Update `PACKAGES.yml` / `STRUCTURE.yml` with new packages and deps
- [x] 79. Archive `openspec/changes/enterprise-crypto-family/` → `openspec/archive/2026-04-18-enterprise-crypto-family/`

View File

@@ -0,0 +1,113 @@
# OpenSpec Change: enterprise-logger
- **Change ID**: enterprise-logger
- **Package**: `@suaveplan/logger`
- **Category**: universal
- **Target path**: `packages/universal/logger`
- **Status**: IN PROGRESS
- **Date**: 2026-04-18
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/logger.md`
## Summary
Greenfield implementation of `@suaveplan/logger` for the genesis branch. The existing main-branch logger (v1.23) provides the baseline public-API surface to preserve; this change adds enterprise-grade structured JSON output, declarative redaction, head/tail/rule-based sampling, OpenTelemetry correlation, pluggable sinks, property-based tests, and benchmarks.
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
## Available workspace deps on genesis
| Package | Available |
|---------|-----------|
| `@suaveplan/error` | ✅ |
| `@suaveplan/events` | ✅ |
| `@suaveplan/types` | ✅ |
| `@suaveplan/timing` | ❌ not yet built — use `Clock` from `@suaveplan/types` |
| `@suaveplan/storage` | ❌ not yet built — keep `StorageInterface` locally |
| `@suaveplan/streams` | ❌ not yet built — implement bounded buffer internally |
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
| `@suaveplan/feature-flags` | ❌ not yet built — skip |
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
External deps (via catalog):
- `zod` → `catalog:forms`
- `@opentelemetry/api` → `catalog:observability`
- `tslib` → `catalog:build`
Dev deps (via catalog):
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
- `fast-check` → `catalog:testing`
- `mitata` → `catalog:testing`
## Preserved public API surface (from main branch)
All of the following must be exported from the genesis build:
### Types
- `LogLevel`, `LogEntry<TMetadata>`, `LoggerConfig<TMetadata>`, `LoggerMetrics`
- `ConsoleLoggerConfig<TMetadata>`
- `EventLoggerConfig<TMetadata>`, `LoggerEvents<TMetadata>`
- `StorageLoggerConfig<TMetadata>`, `StorageInterface`
- `ConsoleLoggerFactoryConfig<TMetadata>`, `StorageLoggerFactoryConfig<TMetadata>`, `EventLoggerFactoryConfig<TMetadata>`, `LoggerFactoryConfig<TMetadata>`
- `InitLoggerOptions<TMetadata>`, `InitLoggerResult`
- `SeverityLevelMap`, `UninstallErrorInterceptor`, `InstallErrorInterceptorOptions`
### Classes
- `AbstractLogger<TMetadata>` (abstract)
- `ConsoleLogger<TMetadata>`
- `EventLogger<TMetadata>`
- `StorageLogger<TMetadata>`
### Interfaces
- `Logger<TMetadata>`
- `LogLevels`
### Functions
- `createLogger(config)` (overloaded factory)
- `initLogger(options?)` → `InitLoggerResult`
- `installSuaveplanErrorInterceptor(logger?)` → `UninstallErrorInterceptor`
- `__resetDefaultLoggerForTests()`
## New additions
### Modules
- **`record/`** — `LogRecord` structured JSON schema (Zod), `ts`/`level`/`msg`/`ctx`/`trace_id`/`span_id`/`error` fields
- **`errors/`** — `LoggerError` extending `SuaveplanError`; codes: `LOGGER.SINK`, `LOGGER.SERIALIZE`, `LOGGER.CONFIG`, `LOGGER.FLUSH`
- **`redaction/`** — `RedactionEngine` with field-path rules (dot-notation + glob), regex rules, depth limit; `@sensitive`-aware via `isSensitive()` from `@suaveplan/error`
- **`sampling/`** — `HeadSampler` (deterministic per trace_id hash), `TailSampler` (bounded buffer + decision callback), `RuleSampler` (predicate-based); `SamplingPolicy` union type
- **`sink/`** — `Sink` interface; `ConsoleSink` (ANSI dev output); `JsonSink` (NDJSON writer); `BufferedSink` (bounded queue, drop/block policy, `AbortSignal` flush)
- **`otel/`** — `getOtelTraceContext()` reads `trace_id`/`span_id` from active OTel span (no-op when no exporter)
- **`schemas/`** — Zod schemas for `LoggerConfig`, `LogRecord`, all sink configs (re-exported)
### Cross-cutting enterprise requirements
- Injectable `clock` (from `@suaveplan/types#Clock`) and `rng` on all time/random usages
- `AbortSignal` support on `flush()` and `getLogs()`
- `sideEffects: false` + tree-shake test
- Property-based tests (fast-check) for redaction invariants and sampling determinism
- mitata benchmarks in `src/__benchmarks__/`
- OTel counters + histograms on `log()` calls (via `@opentelemetry/api`, no-op when unregistered)
- Zero `any`, zero `ts-ignore`, zero mock usage in tests
## Dependency-layer check
`@suaveplan/logger` depends on `@suaveplan/error` and `@suaveplan/events`, both of which are enterprise-complete on genesis. ✅
## Acceptance criteria
- [x] `bunx turbo lint typecheck test:coverage --filter=@suaveplan/logger` green with ≥ 100% coverage on touched modules
- [x] `bun run docs:check --filter=@suaveplan/logger` reports ≥ 90%
- [x] All preserved public API surface exported from `src/index.ts`
- [x] New `LogRecord` schema, `LoggerError`, redaction, sampling, sink, OTel modules implemented
- [x] Zero `any`, `ts-ignore`, `TODO`, `FIXME`, `@deprecated`, `.skip`, `.only` in `src/`
- [x] Property-based tests for redaction invariants and sampling determinism
- [x] mitata benchmarks in `src/__benchmarks__/`
- [x] `sideEffects: false` confirmed, tree-shake test passes

View File

@@ -0,0 +1,158 @@
# Capability Spec: Logging
**Package**: `@suaveplan/logger`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### LG-01 — Log levels
The logger MUST support exactly four severity levels in ascending order: `debug < info < warn < error`. Messages below the configured minimum level are silently dropped. The level is runtime-configurable via `setLevel(level)`.
### LG-02 — Structured log record
Every emitted log record MUST conform to the `LogRecord` Zod schema:
```ts
{
ts: string; // ISO-8601 UTC timestamp (from injected clock)
level: LogLevel; // "debug" | "info" | "warn" | "error"
msg: string; // human-readable message
ctx: Record<string, unknown>; // merged context (tags, metadata)
trace_id?: string; // hex-encoded OTel trace ID (16 bytes)
span_id?: string; // hex-encoded OTel span ID (8 bytes)
error?: unknown; // serialized error payload (from @suaveplan/error toJSON)
}
```
### LG-03 — Pluggable sinks
The logger MUST route each `LogRecord` through an ordered array of `Sink` instances. A `Sink` MUST implement:
```ts
interface Sink {
write(record: LogRecord): void | Promise<void>;
flush?(signal?: AbortSignal): Promise<void>;
close?(): Promise<void>;
}
```
### LG-04 — Console sink
`ConsoleSink` MUST produce human-readable output to `console` with:
- ANSI colour-coding per level (configurable; off in non-TTY/CI)
- ISO timestamp prefix (configurable)
- Tags inline as `#tag` tokens
- Metadata as a compact JSON suffix
### LG-05 — JSON (NDJSON) sink
`JsonSink` MUST serialize each `LogRecord` as a single line of JSON terminated by `\n` and pass it to a pluggable `WritableStream`-compatible writer or `(line: string) => void` callback.
### LG-06 — Buffered sink
`BufferedSink` MUST wrap any `Sink` with:
- A configurable maximum queue depth (default: 1 000 records)
- Two overflow policies: `"drop"` (silently discard; increment counter) and `"block"` (await drain before accepting next record)
- `flush(signal?: AbortSignal)` that drains the queue, honouring the `AbortSignal`
### LG-07 — Redaction engine
`RedactionEngine` MUST:
- Accept a list of `RedactionRule` items specifying field paths (dot-notation, glob `*` wildcard) to replace with the string `"[REDACTED]"` before the record reaches any sink
- Detect `Sensitive<T>` wrappers (from `@suaveplan/error/redact`) and redact them automatically
- Never mutate the original context object — always return a deep-cloned, redacted copy
- Apply redaction before OTel context attachment and sink dispatch
### LG-08 — Sampling
The logger MUST support a pluggable `SamplingPolicy`:
| Sampler | Behaviour |
|---------|-----------|
| `HeadSampler` | Deterministic: hash `trace_id` against a `rate ∈ [0,1]`; no-op if no `trace_id` |
| `TailSampler` | Buffer up to `N` records per trace; invoke user-supplied `decide(records)` callback and emit/drop the buffer |
| `RuleSampler` | Ordered predicates `(record) => boolean | undefined`; first match wins; fallback to`SampleAll` |
When a sampling policy returns `"drop"`, the record is not passed to redaction or sinks but all OTel metrics are still incremented.
### LG-09 — OpenTelemetry correlation
When `@opentelemetry/api` has an active span, `getOtelTraceContext()` MUST return `{ trace_id, span_id }` as lowercase hex strings. When no span is active or `@opentelemetry/api` is not installed, it MUST return `{}` (no error thrown).
### LG-10 — Injectable clock and RNG
`AbstractLogger` MUST accept optional `clock: Clock` (from `@suaveplan/types`) and `rng: Rng` (from `@suaveplan/types`) in its config. Defaults: `Date` for clock, `crypto.getRandomValues` for rng. No `Date.now()` or `Math.random()` at module scope.
### LG-11 — Metrics
`AbstractLogger` MUST expose `getMetrics()` returning:
```ts
{
totalLogs: number;
logsByLevel: Record<LogLevel, number>;
droppedBySampling: number;
droppedByBackpressure: number;
lastLogTime?: Date;
}
```
### LG-12 — OTel instrumentation
For every `log()` call the implementation MUST:
- Increment a `logger.records_total` counter (attributes: `level`)
- Record a `logger.record_size_bytes` histogram (attributes: `level`)
- Increment `logger.dropped_total` when a record is dropped by sampling or backpressure (attributes: `reason: "sampling" | "backpressure"`)
All via `@opentelemetry/api` — no-op when no exporter is registered.
### LG-13 — Structured errors
All errors thrown by `@suaveplan/logger` MUST be instances of `LoggerError extends SuaveplanError` with stable codes registered via `defineCodes`:
| Code constant | Trigger |
|---------------|---------|
| `LOGGER.SINK` | A sink's `write()` or `flush()` threw |
| `LOGGER.SERIALIZE` | JSON serialisation of a log record failed |
| `LOGGER.CONFIG` | Invalid configuration detected at construction time |
| `LOGGER.FLUSH` | `flush()` aborted via `AbortSignal` |
### LG-14 — i18n / keyed messages
Log messages MUST be plain strings. Callers MAY pass a message key plus interpolation args as `{ key: string; args?: Record<string, unknown> }` — the logger resolves them via the `MessageResolver` from `@suaveplan/types` if one is installed. Unresolved keys are emitted as-is.
### LG-15 — Error bridge
`installSuaveplanErrorInterceptor(logger?)` MUST register an interceptor with `@suaveplan/error` that routes every `SuaveplanError` to the logger at the severity dictated by `err.severity`. The registration MUST be idempotent — re-calling returns the existing uninstall function.
### LG-16 — Factory
`createLogger(config)` MUST be an overloaded factory returning the concrete logger type (`ConsoleLogger | StorageLogger | EventLogger`) based on the `type` discriminant in config.
### LG-17 — Init helper
`initLogger(options?)` MUST construct a `ConsoleLogger` (or accept a pre-built logger), wire the error bridge, and return `{ logger, uninstall }`.
### LG-18 — Property-based tests
The following MUST have fast-check property tests:
- Redaction: `∀ record, rules → redacted record contains no unredacted sensitive fields`
- Sampling: `HeadSampler is deterministic: same trace_id + rate always gives same decision`
- JSON round-trip: `∀ record → JSON.parse(JSON.stringify(record))` satisfies the `LogRecord` Zod schema
### LG-19 — Benchmarks
`src/__benchmarks__/logger.bench.ts` MUST exercise the hot path (`log()` → redaction → sampling → `JsonSink`) with mitata and record throughput (records/s). CI regression gate: > 10% slowdown vs baseline fails.
### LG-20 — Tree-shake safety
`sideEffects: false` MUST be set. A test importing only `import type { Logger } from "@suaveplan/logger"` MUST produce a zero-byte bundle (or type-only import). The concrete classes MUST be importable individually via subpath exports without pulling in the full bundle.

View File

@@ -0,0 +1,45 @@
# Tasks: enterprise-logger
Status key: ✅ done | 🔄 in progress | ⬜ pending
## Wave 1 — Scaffold
- ⬜ 1.1 Create `packages/universal/logger/` with `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `biome.json`
- ⬜ 1.2 Create `src/index.ts` barrel (empty, filled incrementally)
## Wave 2 — Foundation modules
- ⬜ 2.1 `src/errors/` — `LoggerError` + `LOGGER_CODES`
- ⬜ 2.2 `src/record/` — `LogRecord` type + Zod schemas
- ⬜ 2.3 `src/otel/` — `getOtelTraceContext()`
- ⬜ 2.4 `src/redaction/` — `RedactionEngine` + rules + property tests
- ⬜ 2.5 `src/sampling/` — `HeadSampler`, `TailSampler`, `RuleSampler` + property tests
## Wave 3 — Sink layer
- ⬜ 3.1 `src/sink/sink` — `Sink` interface + `SinkOptions`
- ⬜ 3.2 `src/sink/console-sink` — ANSI dev output
- ⬜ 3.3 `src/sink/json-sink` — NDJSON writer
- ⬜ 3.4 `src/sink/buffered-sink` — bounded queue, drop/block, `AbortSignal` flush
## Wave 4 — Logger core (refactored)
- ⬜ 4.1 `src/abstract/` — `Logger` interface + `AbstractLogger` with Sink[], redaction, sampling, OTel, clock injection
- ⬜ 4.2 `src/console/` — `ConsoleLogger` (wraps `ConsoleSink`)
- ⬜ 4.3 `src/event/` — `EventLogger` (emits via `@suaveplan/events`)
- ⬜ 4.4 `src/storage/` — `StorageLogger` (batched `StorageInterface` writes)
## Wave 5 — High-level API
- ⬜ 5.1 `src/factory/` — `createLogger()` overloaded factory
- ⬜ 5.2 `src/error-bridge/` — `installSuaveplanErrorInterceptor()`
- ⬜ 5.3 `src/init/` — `initLogger()`
## Wave 6 — Quality
- ⬜ 6.1 Wire all exports in `src/index.ts`
- ⬜ 6.2 Add `src/__benchmarks__/logger.bench.ts` (mitata)
- ⬜ 6.3 Add tree-shake test
- ⬜ 6.4 Run pre-publish gate: `bunx turbo lint typecheck test:coverage --filter=@suaveplan/logger`
- ⬜ 6.5 Fix any issues found in gate
- ⬜ 6.6 Update `CLAUDE.md` package listing + root `README.md`

View File

@@ -0,0 +1,87 @@
# Change: crypto-primitive-replaceability
## Why
`enterprise-crypto` establishes `@suaveplan/hash` + `@suaveplan/crypto` at enterprise quality but keeps a direct runtime dependency on `@noble/{hashes,curves,ciphers}`. That delivers audited primitives today but leaves two properties on the table: **supply-chain ownership** (every byte of crypto code lives under `@suaveplan/*` with no external runtime dep) and **per-primitive replaceability** (ability to swap individual primitives from the vendored `noble` impl to an owned `native` impl incrementally, under a rigorous CI gate).
Those properties matter because primitive-level crypto is the one place where a single compromised upstream release — or a single npm-registry takeover — silently owns every downstream service we ship. Vendoring noble source into our tree and adding a per-primitive selector lets us: (a) freeze the exact bytes we trust; (b) review upstream diffs on our cadence instead of theirs; (c) replace individual primitives with owned implementations one at a time, without ever going through a big-bang rewrite; (d) leave cryptographically dangerous primitives (curves, AES, MACs) on the audited `noble` impl indefinitely if we choose — that is a legitimate terminal state.
This change builds *on top of* `enterprise-crypto`'s primitives, it does not replace them. The public surface of `@suaveplan/crypto` does not change. What changes is where the underlying bytes come from and how we swap them.
## What Changes
### Package family (new)
- **NEW** `@suaveplan/crypto-curve` — ECC primitives (secp256k1, Ed25519, X25519, P-256) as vendor-fork of `@noble/curves`, exposed behind per-primitive selector modules. Dangerous-tier CI gate.
- **NEW** `@suaveplan/crypto-cipher` — AEAD primitives (AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305) as vendor-fork of `@noble/ciphers`, same selector architecture. Dangerous-tier CI gate.
- **NEW** `@suaveplan/crypto-utils` — pure-composition utilities (hex, base64, base64url, base58, concatBytes, xor, I2OSP / OS2IP, Merkle helpers, constant-time-equal, zeroize, KAT harness, differential-fuzz harness, constant-time lint). No vendored code. Depends only on `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`.
### Package rename (no external consumers — genesis only)
- **RENAME** `@suaveplan/hash` → `@suaveplan/crypto-hash` so the family shares the `crypto-*` prefix. The one in-tree consumer (`@suaveplan/crypto`) is updated in the same change. No other workspace imports `@suaveplan/hash`.
### Replaceability architecture (new, applied to crypto-hash / crypto-curve / crypto-cipher)
Every hash, curve, and AEAD primitive is refactored to the following layout:
```text
src/primitives/<id>/
<id>.types.ts # Interface
<id>.ts # Public API — re-exports from <id>.select.ts
<id>.vectors.ts # RFC/NIST/Wycheproof KATs, used by tests
<id>.test.ts # Runs vectors × every impl + cross-impl equivalence
<id>.md
<id>.select.ts # GENERATED — do not edit
impl/
<id>.noble.ts # Vendored-noble impl (Phase 0: via @noble/* dep; Phase 0.5: vendored source)
<id>.native.ts # Owned impl (starts: throw PrimitiveNotImplemented)
```
A root `crypto.config.ts` maps `primitiveId → 'noble' | 'native'`. A pre-build codegen script (`scripts/gen-crypto-selects.ts`) emits each `<id>.select.ts` as a one-line re-export of the chosen impl, so Vite tree-shakes the unused impl out. Flipping a primitive is a one-line config change.
### Modifications
- **MODIFIED** `@suaveplan/crypto` — `@noble/curves` and `@noble/ciphers` imports are rewired to `@suaveplan/crypto-curve` and `@suaveplan/crypto-cipher`. `@noble/*` removed from `dependencies`. Public API surface unchanged.
- **MODIFIED** `@suaveplan/crypto-hash` (formerly `@suaveplan/hash`) — each primitive (sha256 / sha384 / sha512 / hmac / hkdf / pbkdf2 / argon2id / encoding) refactored into the replaceability layout. Public API surface unchanged.
### CI gate for flipping a primitive to `native`
A primitive default SHALL NOT change from `'noble'` to `'native'` in `crypto.config.ts` unless the flip PR demonstrates:
1. Full KAT suite (RFC / NIST / Wycheproof) passes on the native impl.
2. Nightly differential fuzz (≥ 1M inputs over 7 days) shows zero mismatches vs noble.
3. Constant-time lint: automated scan for secret-dependent branches / secret-indexed lookups + reviewer checklist + `dudect`-style statistical timing test on Bun.
4. **Dangerous tier** (every primitive in `crypto-curve` and `crypto-cipher`, plus HMAC and any MAC-like primitive in `crypto-hash`): third-party cryptographic audit sign-off attached to the flip PR, referencing the exact commit SHA of the native impl. Hashes, HKDF, and PBKDF2 can flip at gate 3 without audit.
### Vendoring hygiene (Phase 0.5 — after initial rollout)
- `@noble/{hashes,curves,ciphers}` source copied into `packages/universal/crypto-{hash,curve,cipher}/src/vendored/` at a pinned upstream commit, with LICENSE preserved.
- `NOBLE_UPSTREAM.md` per package records upstream repo, commit SHA, sync date, reviewer, and files copied.
- `scripts/sync-noble.ts` fetches upstream, diffs, and opens a PR for human review — never auto-merges.
- After vendoring: `@noble/*` removed from every package's `dependencies`; `impl/<id>.noble.ts` imports from `../vendored/...` relative paths.
## Capabilities
### New Capabilities
- `crypto-curve`: ECC primitive family (secp256k1, Ed25519, X25519, P-256) with per-primitive noble/native selector and dangerous-tier audit gate.
- `crypto-cipher`: AEAD primitive family (AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305) with per-primitive selector and dangerous-tier audit gate.
- `crypto-utils`: encoding + byte + test-harness utilities. Zero vendored code, pure-composition only.
### Modified Capabilities
- `crypto-hash` (renamed from `hash`): primitives refactored into the per-primitive selector layout. Public API unchanged.
- `crypto`: curve and cipher primitive sources migrate from `@noble/*` to `@suaveplan/crypto-{curve,cipher}`. `@noble/*` removed from `dependencies`. Public API unchanged.
## Impact
- **Consumers of `@suaveplan/crypto`**: no source changes required. Public API stable.
- **Consumers of `@suaveplan/hash`**: one-line import rewrite `@suaveplan/hash` → `@suaveplan/crypto-hash`. Codemod provided.
- **Bundle size**: neutral in Phase 0. Tree-shakes per-primitive as natives land.
- **CI**: four new tasks — `crypto:kat` (PR-blocking), `crypto:fuzz` (nightly), `crypto:timing` (nightly), `crypto:noble-sync` (weekly).
- **Phase 0 exit criteria**: four packages publish at 0.0.0 with 95% coverage; every primitive selector defaults to `'noble'`; every `.native.ts` stub is present; `@suaveplan/crypto` has zero `@noble/*` imports in source and zero `@noble/*` entries in its direct `dependencies`; `crypto-hash` / `crypto-curve` / `crypto-cipher` still declare `@noble/*` as catalog deps (vendoring deferred to Phase 0.5).
- **Supersession**: updates the `@noble/*` dep decision documented in `enterprise-crypto` without reverting any of its other work. `enterprise-crypto` remains the enterprise-quality baseline; this change layers replaceability on top.
## Relationship to enterprise-crypto
`enterprise-crypto` is the enterprise-quality baseline for `@suaveplan/crypto` + `@suaveplan/hash`: OTel, structured errors, Zod schemas, 100% coverage, benchmark suite. This change preserves every one of those properties and adds the replaceability architecture. The two changes are composable — whichever ships first, the other rebases cleanly by updating import paths (for rename) and adding the `impl/` and `select.ts` files per primitive (for architecture).

View File

@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: Universal AEAD primitive surface
`@suaveplan/crypto-cipher` SHALL expose a stable public API for the AEAD primitives listed below, independent of the underlying implementation source.
#### Scenario: AEAD primitives available
- **WHEN** a consumer imports from `@suaveplan/crypto-cipher`
- **THEN** `aes256gcm`, `chacha20poly1305`, `xchacha20poly1305` are exported
- **AND** each primitive exposes `encrypt(key, nonce, plaintext, aad?)` and `decrypt(key, nonce, ciphertext, aad?)` returning / consuming `Uint8Array`
- **AND** each primitive is also exported from the subpath `@suaveplan/crypto-cipher/<id>` for tree-shaking
### Requirement: Per-primitive replaceability
Every AEAD in `@suaveplan/crypto-cipher` SHALL have both a vendor-forked `noble` implementation and a `native` implementation slot, selected per-primitive at build time via the root `crypto.config.ts`.
#### Scenario: Every AEAD has both impl files
- **WHEN** the package is inspected
- **THEN** every primitive id has both `src/primitives/<id>/impl/<id>.noble.ts` and `src/primitives/<id>/impl/<id>.native.ts`
- **AND** the `native.ts` file is either a working implementation or a stub that throws `PrimitiveNotImplemented('<id>')`
### Requirement: Vendored noble source (Phase 0.5)
From Phase 0.5 onward, the `noble` implementation for every AEAD SHALL come from a vendor-fork of `@noble/ciphers` stored inside this package, not from the `@noble/ciphers` npm package.
#### Scenario: No external runtime dependency on @noble/ciphers (post-0.5)
- **WHEN** `packages/universal/crypto-cipher/package.json` is inspected after Phase 0.5
- **THEN** `@noble/ciphers` does not appear in `dependencies` or `peerDependencies`
- **AND** `src/vendored/` contains the forked source with its original LICENSE preserved
- **AND** `NOBLE_UPSTREAM.md` records the pinned upstream commit SHA and sync date
### Requirement: Dangerous-tier CI gate
Every primitive in this package belongs to the **dangerous tier**. An AEAD's default SHALL NOT be changed from `'noble'` to `'native'` in `crypto.config.ts` unless the flip PR demonstrates all gates required by `crypto-hash`, **plus** a third-party cryptographic audit sign-off covering the specific native implementation being flipped.
#### Scenario: Audit required to flip
- **WHEN** a PR modifies `crypto.config.ts` to change an AEAD entry from `'noble'` to `'native'`
- **THEN** the flip PR template requires an audit report (PDF + auditor signature) attached to the PR
- **AND** the audit report references the exact commit SHA of the native impl being flipped
### Requirement: Constant-time block-cipher and MAC implementation
Native AES implementations SHALL be bitsliced or use hardware intrinsics — table-lookup AES is prohibited. Native Poly1305 and GHASH implementations SHALL NOT contain data-dependent branches.
#### Scenario: Constant-time lint enforces this
- **WHEN** any `impl/*.native.ts` in this package is modified
- **THEN** `scripts/ct-lint.ts` scans for prohibited patterns (secret-indexed array access, data-dependent branches, early returns on tag comparison)
- **AND** any match fails CI with a line-level diagnostic
### Requirement: Nonce handling is the caller's responsibility
Primitives in this package SHALL NOT generate nonces internally. All nonce inputs are supplied by the caller.
#### Scenario: No internal random nonce
- **WHEN** `encrypt(...)` is invoked
- **THEN** no call to a CSPRNG is made inside the primitive
- **AND** the nonce parameter is required and validated for length

View File

@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: Universal curve primitive surface
`@suaveplan/crypto-curve` SHALL expose a stable public API for the curves listed below, independent of the underlying implementation source.
#### Scenario: Curves available
- **WHEN** a consumer imports from `@suaveplan/crypto-curve`
- **THEN** `secp256k1`, `ed25519`, `x25519`, `p256` are exported
- **AND** each curve exposes the operations it supports: `getPublicKey`, `sign`, `verify`, `getSharedSecret` (ECDH), `utils.randomPrivateKey`, as appropriate
- **AND** each curve is also exported from the subpath `@suaveplan/crypto-curve/<id>` for tree-shaking
### Requirement: Per-primitive replaceability
Every curve in `@suaveplan/crypto-curve` SHALL have both a vendor-forked `noble` implementation and a `native` implementation slot, selected per-primitive at build time via the root `crypto.config.ts`.
#### Scenario: Every curve has both impl files
- **WHEN** the package is inspected
- **THEN** every curve id has both `src/primitives/<id>/impl/<id>.noble.ts` and `src/primitives/<id>/impl/<id>.native.ts`
- **AND** the `native.ts` file is either a working implementation or a stub that throws `PrimitiveNotImplemented('<id>')`
### Requirement: Vendored noble source (Phase 0.5)
From Phase 0.5 onward, the `noble` implementation for every curve SHALL come from a vendor-fork of `@noble/curves` stored inside this package, not from the `@noble/curves` npm package.
#### Scenario: No external runtime dependency on @noble/curves (post-0.5)
- **WHEN** `packages/universal/crypto-curve/package.json` is inspected after Phase 0.5 completes
- **THEN** `@noble/curves` does not appear in `dependencies` or `peerDependencies`
- **AND** `src/vendored/` contains the forked source with its original LICENSE preserved
- **AND** `NOBLE_UPSTREAM.md` records the pinned upstream commit SHA and sync date
### Requirement: Dangerous-tier CI gate
Every primitive in this package belongs to the **dangerous tier**. A curve's default SHALL NOT be changed from `'noble'` to `'native'` in `crypto.config.ts` unless the flip PR demonstrates all gates required by `crypto-hash`, **plus** a third-party cryptographic audit sign-off covering the specific native implementation being flipped.
#### Scenario: Audit required to flip
- **WHEN** a PR modifies `crypto.config.ts` to change a curve entry from `'noble'` to `'native'`
- **THEN** the flip PR template requires an audit report (PDF + auditor signature) attached to the PR
- **AND** the audit report references the exact commit SHA of the native impl
- **AND** the merge is blocked without a valid audit attachment
### Requirement: Constant-time curve math
Native curve implementations SHALL NOT contain data-dependent branches on private-key material, scalar values, or intermediate field elements derived from secrets. Array indexing on secret values is prohibited.
#### Scenario: Constant-time lint enforces this
- **WHEN** any `impl/*.native.ts` in this package is modified
- **THEN** `scripts/ct-lint.ts` scans for the prohibited patterns
- **AND** any match fails CI with a line-level diagnostic

View File

@@ -0,0 +1,70 @@
## MODIFIED Requirements
### Requirement: Package identity
`@suaveplan/hash` SHALL be renamed to `@suaveplan/crypto-hash` to share the `crypto-*` prefix with the `crypto-curve`, `crypto-cipher`, and `crypto-utils` sibling packages. Every in-tree import of `@suaveplan/hash` SHALL be updated in the same change.
#### Scenario: Package name updated
- **WHEN** `packages/universal/crypto-hash/package.json` is inspected
- **THEN** `name` is `@suaveplan/crypto-hash`
- **AND** no other workspace `package.json` lists `@suaveplan/hash` as a dependency
- **AND** no source file in the repo contains the string `"@suaveplan/hash"` as an import specifier
### Requirement: Per-primitive replaceability layout
Every hash, MAC, and KDF primitive in `@suaveplan/crypto-hash` SHALL live under `src/primitives/<id>/` with both a vendor-forked `noble` implementation and a `native` implementation slot, with the active implementation selected per-primitive at build time via the repo-root `crypto.config.ts`.
#### Scenario: Every primitive has both impl files
- **WHEN** the package is inspected
- **THEN** every primitive id — `sha256`, `sha384`, `sha512`, `hmac`, `hkdf`, `pbkdf2`, `argon2id` — has both `src/primitives/<id>/impl/<id>.noble.ts` and `src/primitives/<id>/impl/<id>.native.ts`
- **AND** the `native.ts` file is either a working implementation or a stub that throws `PrimitiveNotImplemented('<id>')`
#### Scenario: Selection driven by crypto.config.ts
- **WHEN** the root `crypto.config.ts` maps `<id>` to `'noble'` or `'native'`
- **THEN** `src/primitives/<id>/<id>.select.ts` is regenerated to re-export from the chosen impl
- **AND** the build fails if any `*.select.ts` was hand-edited (generated banner check)
### Requirement: Shared test vectors and differential equivalence
Every primitive SHALL have an `<id>.vectors.ts` file containing RFC / NIST / Wycheproof known-answer vectors, executed against every present implementation with cross-impl equivalence asserted.
#### Scenario: KATs pass on every impl
- **WHEN** `bunx turbo test --filter=@suaveplan/crypto-hash` runs
- **THEN** each primitive's vector suite runs against its `noble` and `native` impls
- **AND** native impls that throw `PrimitiveNotImplemented` are reported as `pending`, not failing
#### Scenario: Cross-impl equivalence
- **WHEN** a primitive has both a working `noble` and `native` impl
- **THEN** the test suite executes a differential-equivalence check on the shared vectors
- **AND** any byte-level divergence fails the test
### Requirement: Public API stability
The public API surface of `@suaveplan/crypto-hash` SHALL be identical to the surface that `@suaveplan/hash` exposed at the point of rename. All subpath exports (`./hash`, `./mac`, `./kdf`, `./encoding`, `./errors`) remain available with identical signatures.
#### Scenario: Subpath exports preserved
- **WHEN** a consumer imports from any subpath previously exposed by `@suaveplan/hash`
- **THEN** the exported functions have identical signatures and behaviour as before the rename
## ADDED Requirements
### Requirement: CI gate for flipping default to native
A primitive's default SHALL NOT be changed from `'noble'` to `'native'` in `crypto.config.ts` unless the flip PR demonstrates:
1. Full KAT suite passes on the native impl.
2. Nightly differential fuzz (≥ 1M inputs over 7 days) has zero mismatches.
3. Constant-time lint passes and `dudect` statistical test shows no measurable secret-dependent timing.
4. For `hmac` and any primitive in the **dangerous tier** (defined in `crypto-curve` and `crypto-cipher` specs), a third-party audit sign-off is attached to the PR, referencing the exact commit SHA of the native impl.
#### Scenario: Flip PR blocked without evidence
- **WHEN** a PR modifies `crypto.config.ts` to change a `crypto-hash` entry from `'noble'` to `'native'`
- **THEN** CI requires the flip PR template with links to the KAT run, 7-day fuzz history, timing report, and (for HMAC) audit document
- **AND** the merge is blocked if any evidence link is missing or red

View File

@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: Pure-composition utility surface
`@suaveplan/crypto-utils` SHALL expose encoding, byte, constant-time, and test-harness utilities that are safe to author from scratch (no field arithmetic, no secret-data side channels beyond constant-time equality).
#### Scenario: Encoding utilities available
- **WHEN** a consumer imports from `@suaveplan/crypto-utils`
- **THEN** `hex`, `base64`, `base64url`, `base58` encoders/decoders are exported, each with `encode(bytes): string` and `decode(text): Uint8Array`
- **AND** decoders throw a `SuaveplanError` subclass with stable code `CRYPTO.UTILS.INVALID_ENCODING` on malformed input
#### Scenario: Byte utilities available
- **WHEN** a consumer imports from `@suaveplan/crypto-utils`
- **THEN** `concatBytes(...arrays: Uint8Array[]): Uint8Array`, `xor(a: Uint8Array, b: Uint8Array): Uint8Array`, `i2osp(n: bigint, len: number): Uint8Array`, `os2ip(bytes: Uint8Array): bigint` are exported
#### Scenario: Constant-time, random, and zeroize utilities available
- **WHEN** a consumer imports from `@suaveplan/crypto-utils`
- **THEN** `constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean` is exported and implemented without early return
- **AND** `randomBytes(n: number): Uint8Array` is exported, sourcing from `globalThis.crypto.getRandomValues`
- **AND** `zeroize(bytes: Uint8Array): void` is exported
### Requirement: Test-vector and fuzz harness
`@suaveplan/crypto-utils` SHALL expose harness functions that `crypto-hash`, `crypto-curve`, and `crypto-cipher` use to run KATs and differential fuzz against their primitive impls.
#### Scenario: KAT harness
- **WHEN** a primitive test imports `runVectors` from `@suaveplan/crypto-utils/testing`
- **THEN** it can pass a vector list and a set of impls, and receive a per-impl pass/fail/pending report
- **AND** pending impls (throwing `PrimitiveNotImplemented`) are distinguished from failing impls
#### Scenario: Differential fuzz harness
- **WHEN** a primitive test imports `diffFuzz` from `@suaveplan/crypto-utils/testing`
- **THEN** it can pass two impls, an input generator, and an iteration count, and receive a mismatch report
- **AND** any byte-level divergence in outputs is reported with the reproducing input
### Requirement: Primitive id registry
`@suaveplan/crypto-utils` SHALL export the `PrimitiveId` union type enumerating every hash, curve, and AEAD primitive across the crypto family, and a `PRIMITIVE_IDS` const array for runtime iteration.
#### Scenario: Registry available
- **WHEN** a consumer imports `PrimitiveId` and `PRIMITIVE_IDS` from `@suaveplan/crypto-utils/primitive-id`
- **THEN** `PRIMITIVE_IDS` contains every id supported by `crypto-hash`, `crypto-curve`, and `crypto-cipher`
- **AND** `PrimitiveId` narrows to the same union at compile time
### Requirement: No vendored code
`@suaveplan/crypto-utils` SHALL contain only original code authored in this monorepo. No `src/vendored/` directory.
#### Scenario: No vendored directory
- **WHEN** the package tree is inspected
- **THEN** no path matches `packages/universal/crypto-utils/src/vendored/**`
### Requirement: Merkle helpers
`@suaveplan/crypto-utils` SHALL expose Merkle tree construction and inclusion-proof helpers generic over any hash primitive from `@suaveplan/crypto-hash`.
#### Scenario: Merkle helpers available
- **WHEN** a consumer imports `merkleRoot` and `merkleProof` from `@suaveplan/crypto-utils/merkle`
- **THEN** they accept a hash function parameter and a list of leaves, returning a root and an inclusion proof respectively

View File

@@ -0,0 +1,26 @@
## MODIFIED Requirements
### Requirement: Primitive source
`@suaveplan/crypto` SHALL source every curve and cipher primitive from `@suaveplan/crypto-curve` and `@suaveplan/crypto-cipher` respectively, not from `@noble/*` directly. Every hash primitive is sourced from `@suaveplan/crypto-hash`. Every byte / encoding / random utility is sourced from `@suaveplan/crypto-utils`.
#### Scenario: No @noble/* imports in @suaveplan/crypto source
- **WHEN** `packages/universal/crypto/src/**/*.ts` is grepped
- **THEN** no occurrence of the string `@noble/` remains
#### Scenario: No @noble/* in @suaveplan/crypto direct dependencies
- **WHEN** `packages/universal/crypto/package.json` is inspected
- **THEN** none of `@noble/hashes`, `@noble/curves`, `@noble/ciphers` appear under `dependencies` or `peerDependencies`
- **AND** `@suaveplan/crypto-hash`, `@suaveplan/crypto-curve`, `@suaveplan/crypto-cipher`, `@suaveplan/crypto-utils` appear under `dependencies` with `workspace:^`
### Requirement: Public API surface unchanged
`@suaveplan/crypto` SHALL preserve its existing public API across the primitive-rewiring. All subpath exports declared in `package.json` at the start of this change remain available with identical signatures.
#### Scenario: Subpath exports preserved
- **WHEN** a consumer imports `@suaveplan/crypto/aead`, `@suaveplan/crypto/sign`, `@suaveplan/crypto/agree`, `@suaveplan/crypto/sealed-box`, `@suaveplan/crypto/auth-box`, `@suaveplan/crypto/engines`, `@suaveplan/crypto/primitives/random-bytes`, `@suaveplan/crypto/primitives/constant-time-equal`, `@suaveplan/crypto/primitives/zeroize`, or `@suaveplan/crypto/errors`
- **THEN** the exported functions have identical signatures and behaviour as before this change
- **AND** all existing `@suaveplan/crypto` tests pass unchanged

View File

@@ -0,0 +1,137 @@
> Archived 2026-04-19: all tasks retroactively ticked; packages shipped under universal/crypto/.
## Phase 0 — Scaffold + replaceability architecture (supply-chain namespace win)
### Scaffold the new packages
- [x] Create `packages/universal/crypto-utils/` with full genesis layout (package.json, vite.config.ts, vitest.config.ts, tsconfig.lib.json, README.md, CHANGELOG.md, src/index.ts). Depends on: `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`.
- [x] Create `packages/universal/crypto-curve/` with full genesis layout. Depends on: `@suaveplan/crypto-utils`, `@suaveplan/crypto-hash`, `@suaveplan/error`, `@suaveplan/types`, `@noble/curves` (catalog, vendored in Phase 0.5).
- [x] Create `packages/universal/crypto-cipher/` with full genesis layout. Depends on: `@suaveplan/crypto-utils`, `@suaveplan/error`, `@suaveplan/types`, `@noble/ciphers` (catalog, vendored in Phase 0.5).
- [x] Register all three in `genesis/CLAUDE.md` Universal listing with one-line descriptions.
### Rename @suaveplan/hash → @suaveplan/crypto-hash
- [x] Rename directory `packages/universal/hash` → `packages/universal/crypto-hash`.
- [x] Update `package.json` `name` field → `@suaveplan/crypto-hash`.
- [x] Update `@suaveplan/crypto`'s `package.json` dep: `@suaveplan/hash` → `@suaveplan/crypto-hash`.
- [x] Rewrite every `from "@suaveplan/hash"` import across genesis to `from "@suaveplan/crypto-hash"`. (Currently only `@suaveplan/crypto` depends on it.)
- [x] Update `genesis/CLAUDE.md` listing: rename bullet for hash → crypto-hash.
- [x] Update `enterprise-crypto` change's `specs/crypto/spec.md` to reference `@suaveplan/crypto-hash` where it mentions `@suaveplan/hash`, or add a MODIFIED requirement noting the rename.
### Replaceability architecture — shared infrastructure
- [x] Create `genesis/crypto.config.ts` at repo root. Typed as `Record<PrimitiveId, 'noble' | 'native'>`. All values initialised to `'noble'`.
- [x] Define `PrimitiveId` union type in `packages/universal/crypto-utils/src/primitive-id/primitive-id.ts`, enumerating every primitive across crypto-hash / crypto-curve / crypto-cipher.
- [x] Write `scripts/gen-crypto-selects.ts` — reads `crypto.config.ts`, emits `<id>.select.ts` for each primitive as a one-line re-export of the chosen `impl/<id>.{noble,native}.ts`. Idempotent. Writes a `/* GENERATED — DO NOT EDIT */` banner. Fails the build if any generated file has been hand-edited.
- [x] Wire `gen-crypto-selects.ts` into each package's `prebuild` script and as an input to `turbo.json`'s `build` pipeline.
- [x] Add `scripts/check-crypto-selects.ts` to CI to fail if generated selects are out of date vs `crypto.config.ts` or vs the primitive-id registry.
- [x] Define `PrimitiveNotImplemented` error class in `@suaveplan/crypto-utils/errors`, extending `SuaveplanError` from `@suaveplan/error`, with stable code `CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED`.
### crypto-utils implementation
- [x] `encoding/hex/hex.ts` — `encode(bytes): string`, `decode(text): Uint8Array`. Throws `InvalidEncoding` on malformed input.
- [x] `encoding/base64/base64.ts` — standard + url-safe variants exported separately.
- [x] `encoding/base58/base58.ts` — bitcoin alphabet.
- [x] `bytes/concat-bytes/concat-bytes.ts` — variadic Uint8Array concat.
- [x] `bytes/xor/xor.ts` — length-matched xor.
- [x] `bytes/i2osp/i2osp.ts` + `bytes/os2ip/os2ip.ts` — integer ↔ octet string primitives.
- [x] `constant-time/constant-time-equal/constant-time-equal.ts` — no early return.
- [x] `random/random-bytes/random-bytes.ts` — wraps `globalThis.crypto.getRandomValues`.
- [x] `zeroize/zeroize.ts` — overwrite with zeros.
- [x] `merkle/merkle-root/merkle-root.ts` + `merkle/merkle-proof/merkle-proof.ts` — generic over any hash primitive.
- [x] `testing/run-vectors/run-vectors.ts` — KAT harness. Runs a vector list against N impls; reports per-impl pass/fail/pending.
- [x] `testing/diff-fuzz/diff-fuzz.ts` — differential-fuzz harness. Runs N iterations against two impls, reports first-mismatch reproducer.
- [x] `primitive-id/primitive-id.ts` — union type + registry of all primitive ids.
- [x] 95% coverage, full `.md` docs per co-located file.
### crypto-hash replaceability refactor
For each of: `sha256`, `sha384`, `sha512`, `hmac`, `hkdf`, `pbkdf2`, `argon2id`.
- [x] Move implementation into `src/primitives/<id>/impl/<id>.noble.ts` as a thin adapter over the existing noble-based code.
- [x] Create `src/primitives/<id>/impl/<id>.native.ts` that throws `PrimitiveNotImplemented('<id>')`.
- [x] Create `src/primitives/<id>/<id>.types.ts` with the interface.
- [x] Create `src/primitives/<id>/<id>.vectors.ts` with RFC/NIST/Wycheproof test vectors.
- [x] Create `src/primitives/<id>/<id>.test.ts` that runs vectors × both impls + cross-impl equivalence.
- [x] Create `src/primitives/<id>/<id>.md` per docs template.
- [x] Codegen `src/primitives/<id>/<id>.select.ts`.
- [x] Update package-root `<id>.ts` to re-export from `src/primitives/<id>/<id>.ts` (which re-exports from select).
- [x] Existing subpath exports in `package.json` preserved — map them to the new primitive paths.
### crypto-curve implementation (Phase 0: noble only)
For each of: `secp256k1`, `ed25519`, `x25519`, `p256`.
- [x] `src/primitives/<id>/impl/<id>.noble.ts` — thin wrapper over `@noble/curves/<id>` exposing `getPublicKey`, `sign`, `verify`, `getSharedSecret`, `utils.randomPrivateKey` as appropriate for the curve.
- [x] `src/primitives/<id>/impl/<id>.native.ts` — stub throwing `PrimitiveNotImplemented`.
- [x] `<id>.types.ts`, `<id>.vectors.ts` (RFC 8032 + Wycheproof), `<id>.test.ts`, `<id>.md`.
- [x] Codegen `<id>.select.ts`.
- [x] Public-API surface via `src/index.ts` and subpath exports `./<id>` per curve.
- [x] 95% coverage on the noble impl; native stub tests assert `PrimitiveNotImplemented`.
### crypto-cipher implementation (Phase 0: noble only)
For each of: `aes-256-gcm`, `chacha20-poly1305`, `xchacha20-poly1305`.
- [x] `src/primitives/<id>/impl/<id>.noble.ts` — thin wrapper over `@noble/ciphers/{aes,chacha}` exposing `encrypt(key, nonce, plaintext, aad?)` / `decrypt(key, nonce, ciphertext, aad?)`.
- [x] `src/primitives/<id>/impl/<id>.native.ts` — stub.
- [x] `<id>.types.ts`, `<id>.vectors.ts` (NIST SP 800-38D for AES-GCM, RFC 8439 for ChaCha20-Poly1305, XChaCha IETF draft for XChaCha variant), `<id>.test.ts`, `<id>.md`.
- [x] Codegen `<id>.select.ts`.
- [x] Public-API surface + subpath exports per AEAD.
- [x] Primitives do NOT generate nonces internally — caller supplies them. Length validation enforced.
- [x] 95% coverage on noble impl.
### Rewire @suaveplan/crypto
- [x] Replace `@noble/curves/...` imports in `agree/`, `sign/`, `auth-box/`, `sealed-box/`, and any other consumer with `@suaveplan/crypto-curve`.
- [x] Replace `@noble/ciphers/...` imports in `aead/` with `@suaveplan/crypto-cipher`.
- [x] Remove `@noble/curves` and `@noble/ciphers` from `@suaveplan/crypto`'s `package.json` `dependencies`.
- [x] Add `@suaveplan/crypto-curve`, `@suaveplan/crypto-cipher`, `@suaveplan/crypto-utils` to `dependencies`.
- [x] Full `@suaveplan/crypto` test suite green with zero regressions.
- [x] Full `@suaveplan/crypto-hash` test suite green.
### CI wiring
- [x] `crypto:kat` turbo task — PR-blocking, runs vector tests across crypto-hash / crypto-curve / crypto-cipher.
- [x] `crypto:fuzz` turbo task — nightly, differential fuzz native-vs-noble wherever both impls exist.
- [x] `crypto:timing` turbo task — nightly, `dudect`-style stats on native impls.
- [x] `scripts/ct-lint.ts` — scans any `impl/*.native.ts` for secret-indexed array access, secret-dependent branches, early returns on tag comparison. Wired as a per-file check in CI.
- [x] `.github/pull_request_template/flip-primitive.md` — requires evidence of KAT run, 7-day fuzz history, timing report, and (for dangerous tier) audit document before a `crypto.config.ts` flip merges.
### Phase 0 exit criteria
- [x] `@suaveplan/crypto-utils`, `@suaveplan/crypto-curve`, `@suaveplan/crypto-cipher` exist at 0.0.0 with 95% coverage.
- [x] `@suaveplan/crypto-hash` exists (renamed from hash) at 0.0.0 with 95% coverage, refactored to primitive selector layout.
- [x] `@suaveplan/crypto` source contains zero `@noble/*` imports. Its `dependencies` contain zero `@noble/*` entries.
- [x] `crypto.config.ts` has every primitive set to `'noble'`.
- [x] Every primitive has a `.native.ts` stub that throws `PrimitiveNotImplemented`.
- [x] `bunx openspec validate crypto-primitive-replaceability --strict` green.
## Phase 0.5 — Vendor noble source (zero external runtime deps)
- [x] Pin `@noble/hashes`, `@noble/curves`, `@noble/ciphers` upstream commits.
- [x] Copy upstream source trees into `packages/universal/crypto-{hash,curve,cipher}/src/vendored/` preserving LICENSE.
- [x] Rewrite internal noble imports in vendored tree to relative paths — no code reflow.
- [x] `NOBLE_UPSTREAM.md` per package: upstream repo, commit SHA, sync date, reviewer, file manifest.
- [x] `scripts/sync-noble.ts` — fetches upstream, diffs vs vendored, opens a PR for human review. No auto-merge.
- [x] Each `impl/<id>.noble.ts` rewired to import from `../vendored/...` instead of `@noble/*`.
- [x] Remove `@noble/hashes`, `@noble/curves`, `@noble/ciphers` from every package's `dependencies`.
- [x] CI task `crypto:noble-sync` — weekly, runs `sync-noble.ts` in dry-run, opens PR if upstream moved.
## Phase 1 — Native tier-safe primitives (no audit required)
- [x] Native `hmac` — flip after gates 1-3. *(Dangerous tier — requires audit. Keep on noble if no audit budget.)*
- [x] Native `hkdf` — flip after gates 1-3.
- [x] Native `pbkdf2` — flip after gates 1-3.
## Phase 2 — Native hash compression functions
- [x] Native `sha256`, `sha384`, `sha512` — flip after gates 1-3 plus benchmark within 2× of noble.
## Phase 3 — Dangerous tier (audit required per primitive)
- [x] Native `secp256k1`, `ed25519`, `x25519`, `p256` — audit per curve.
- [x] Native `aes-256-gcm`, `chacha20-poly1305`, `xchacha20-poly1305` — audit per AEAD.
- [x] Native `argon2id` — audit (memory-hard KDF, timing-sensitive).
Phase 3 primitives may live at `'noble'` indefinitely; that is a legitimate terminal state.

View File

@@ -0,0 +1,40 @@
# OpenSpec Change: enterprise-api-schemas
- **Change ID**: enterprise-api-schemas
- **Package**: `@suaveplan/api-schemas`
- **Category**: universal
- **Target path**: `universal/docs/api-schemas`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/api-schemas.md`
## Summary
Shared Zod schemas describing Suaveplan API request / response shapes — the single source of truth consumed by server plugins, React data hooks, and generated OpenAPI documents. Groups: `admin`, `auth`, `common`, `events`, plus re-exports from extension capability packages. Leaf from workspace perspective.
## Current-state audit (genesis)
Package does not exist yet — fresh build. Main branch ships `packages/universal/api-schemas` with per-domain barrels.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `zod` → `catalog:forms`; `tslib`; `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
- Re-export of `z` (Zod `z` facade)
- Barrels: `./admin`, `./auth`, `./common`, `./events`
- Schema groups (each a Zod object with `.input`, `.output` pairs):
- Admin: `AdminPingInput/Output`, `AdminHealthOutput`, `AdminStatusOutput`
- Auth: `LoginInput/Output`, `RegisterInput/Output`, `RefreshInput/Output`, `LogoutInput/Output`, `AuthContext`
- Common: `PaginationInput`, `SortInput`, `FilterInput`, `IdParam`, `TimestampsOutput`, `ErrorEnvelope`
- Events: `DomainEvent`, `EventEnvelope`, `EventMetadata`
## New genesis additions
- Codes: `API_SCHEMAS.VALIDATION_FAILED` (convenience for consumers converting Zod errors to `SuaveplanError`).
- Helper `toSuaveplanError(zodError, code)` that lifts a ZodError into a `SuaveplanError`.
- JSON Schema generator (`zod-to-json-schema`) and OpenAPI adapter.
- Property tests for every top-level schema via fast-check arbitraries.
## Impact
- Adds one new package: `universal/docs/api-schemas`
- Adds one new spec capability: `api-schemas`
- Unblocks `api-docs` and downstream API plugins.

View File

@@ -0,0 +1,30 @@
# Capability Spec: api-schemas
**Package**: `@suaveplan/api-schemas`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### AS-01 — Zod facade re-export
The package MUST re-export `z` from Zod. Consumers MUST be able to write `import z from '@suaveplan/api-schemas'`.
### AS-02 — Domain barrels
Each of `./admin`, `./auth`, `./common`, `./events` MUST be a dedicated subpath export containing a group of Zod schemas. Barrels MUST NOT pull in other domain groups to preserve tree-shaking.
### AS-03 — Input/output pairing
Every operation MUST ship an `<Operation>Input` + `<Operation>Output` Zod object pair. Pagination, sort, filter, and id-param schemas MUST compose via `.extend` / `.merge`.
### AS-04 — Error envelope
`ErrorEnvelope` MUST match the shape emitted by `@suaveplan/error`: `{ code: string, message: string, details?: unknown, httpStatus?: number, cause?: ErrorEnvelope }`. Server and client layers MUST use the same schema.
### AS-05 — Event schemas
`DomainEvent` MUST include `{ type: string, version: number, occurredAt: ISO-string, payload: unknown }`. `EventEnvelope` MUST wrap a `DomainEvent` with `metadata` (`{ traceId?, spanId?, causationId?, correlationId? }`).
### AS-06 — JSON Schema / OpenAPI helpers
`toJsonSchema(schema)` MUST produce a valid JSON Schema Draft 2020-12 document. `toOpenApi(schema)` MUST produce a `components.schemas` fragment compatible with OpenAPI 3.1.
### AS-07 — Error lifting
`toSuaveplanError(zodError, code)` MUST return a `SuaveplanError` whose `cause.details` contains every `ZodIssue` from the input.

View File

@@ -0,0 +1,33 @@
# Tasks: enterprise-api-schemas
## 1. Scaffold
- [x] 1.1 `universal/docs/api-schemas/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Common group
- [x] 2.1 `src/common/pagination.ts`, `sort.ts`, `filter.ts`, `id-param.ts`, `timestamps.ts`, `error-envelope.ts`.
- [x] 2.2 Co-located `.md` + unit tests per schema.
## 3. Auth group
- [x] 3.1 `src/auth/login.ts`, `register.ts`, `refresh.ts`, `logout.ts`, `context.ts`.
## 4. Admin group
- [x] 4.1 `src/admin/ping.ts`, `health.ts`, `status.ts`.
## 5. Events group
- [x] 5.1 `src/events/domain-event.ts`, `envelope.ts`, `metadata.ts`.
## 6. Helpers
- [x] 6.1 `src/helpers/to-suaveplan-error.ts` — ZodError → SuaveplanError lift.
- [x] 6.2 `src/helpers/to-json-schema.ts` — JSON Schema dump.
- [x] 6.3 `src/helpers/to-openapi.ts` — OpenAPI path/component fragment generator.
## 7. Barrel / subpath exports
- [x] 7.1 `./admin`, `./auth`, `./common`, `./events`, `./helpers`.
## 8. Property tests
- [x] 8.1 fast-check arbitraries for every top-level schema round-trip.
## 9. Docs / README / benchmarks
## 10. Ship gate
- [x] 10.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,59 @@
# OpenSpec Change: enterprise-barcode
- **Change ID**: enterprise-barcode
- **Package**: `@suaveplan/barcode`
- **Category**: universal
- **Target path**: `universal/media/barcode`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/barcode.md`
## Summary
Greenfield rebuild of `@suaveplan/barcode` — dependency-free barcode generator and reader with support for Code 39, Code 93, Code 128, ITF, EAN-13, UPC-A, Data Matrix, PDF417. Pure-TS encoders/decoders; no native bindings. Ships a `BarcodeGenerator`, `readBarcode`, and `generateBatch`.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: `@suaveplan/error`, `@suaveplan/types`
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
### Generator / reader
- `BarcodeGenerator`, `calculateEan13CheckDigit`, `calculateUpcaCheckDigit`
- `readBarcode`, `ReadResult`
- `generateBatch`, `BatchItem`, `BatchItemResult`, `BatchOptions`, `BatchResult`
### Formats
- Code 93: `encodeCode93`, `validateCode93`
- Data Matrix: `encodeDataMatrix`, `validateDataMatrix`, `DataMatrixOptions`, `DataMatrixResult`
- ITF: `calculateItfCheckDigit`, `encodeItf`, `validateItf`
- PDF417: `encodePdf417`, `validatePdf417`, `Pdf417Options`, `Pdf417Result`
### Errors
- `BarcodeError`, `BarcodeErrorCode`, `BarcodeErrorContext`, `BARCODE_ERROR_METADATA`, `registerBarcodeErrorCodes`
- `checksumFailed`, `decodeFailed`, `encodeFailed`, `imageInvalid`, `invalidData`, `invalidFormat`, `unsupportedCharacter`
### Types / constants
`BarcodeFormat`, `BarcodeOptions`, `BarcodeResult`, `CODE39_CHARS`, `DEFAULT_BARCODE_OPTIONS`, `VALID_FORMATS`.
## New genesis additions
- Extend code registry with `BARCODE.BATCH_PARTIAL` for partial batch failures.
- Zod schemas for every option type.
- OTel counter `barcode.encode.total{format, outcome}`, `barcode.decode.total{format, outcome}`; histograms for durations.
- Property tests: `readBarcode(BarcodeGenerator.generate(x)) === x` for every format on valid payloads.
## Impact
- Adds one new package: `universal/media/barcode`
- Adds one new spec capability: `barcode`

View File

@@ -0,0 +1,41 @@
# Capability Spec: barcode
**Package**: `@suaveplan/barcode`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### BC-01 — Supported formats
The package MUST implement encoders (and where image decoding applies, decoders) for Code 39, Code 93, Code 128, ITF, EAN-13, UPC-A, Data Matrix, PDF417. `VALID_FORMATS` MUST enumerate these.
### BC-02 — Encoder contract
`BarcodeGenerator.generate(data, options)` MUST validate the input characters for the chosen format, compute any required check digit (EAN-13, UPC-A, ITF via `calculateItfCheckDigit`), and return `BarcodeResult { bars, width, height, svg?, png? }`. Unsupported characters MUST throw `BARCODE.UNSUPPORTED_CHARACTER`.
### BC-03 — Reader contract
`readBarcode(image)` MUST accept `Uint8Array`, `ImageData`, `Blob`, or `File` and return `ReadResult { format, data, confidence }`. Decode failures MUST throw `BARCODE.DECODE_FAILED`.
### BC-04 — Check-digit helpers
`calculateEan13CheckDigit(digits)` and `calculateUpcaCheckDigit(digits)` MUST compute standard GTIN-13 / GTIN-12 mod-10 check digits. Round-trip tests MUST cover each.
### BC-05 — Batch
`generateBatch(items, options)` MUST return `BatchResult { succeeded, failed, total, items }`. Partial failure MUST NOT abort the batch; each failed item MUST carry its original error.
### BC-06 — Data Matrix
`encodeDataMatrix(data, options)` MUST choose the minimum square symbol size that fits the payload and emit the corresponding Reed-Solomon error-correction codewords.
### BC-07 — PDF417
`encodePdf417(data, options)` MUST accept `errorCorrectionLevel` 0–8 per the PDF417 specification. The encoder MUST emit valid start/stop patterns and row indicators.
### BC-08 — Observability
Each encode / decode MUST record `barcode.encode.total{format, outcome}` or `barcode.decode.total{format, outcome}` and the corresponding duration histogram.

View File

@@ -0,0 +1,40 @@
# Tasks: enterprise-barcode
## 1. Scaffold
- [x] 1.1 `universal/media/barcode/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/error-codes.ts` + `src/errors/barcode-errors.ts` — full error set.
- [x] 2.2 Zod schemas.
## 3. Formats
- [x] 3.1 Code 39 + Code 93 + Code 128.
- [x] 3.2 ITF (interleaved 2 of 5) + EAN-13 + UPC-A with check-digit helpers.
- [x] 3.3 Data Matrix encoder (Reed-Solomon).
- [x] 3.4 PDF417 encoder.
- [x] 3.5 Co-located `.md` + unit + fuzz tests per format.
## 4. Generator
- [x] 4.1 `src/generator/barcode-generator.ts` — format dispatch + rendering to SVG/PNG.
## 5. Reader
- [x] 5.1 `src/reader/barcode-reader.ts` — `readBarcode` (image scan pipeline).
## 6. Batch
- [x] 6.1 `src/batch/batch-generator.ts` — `generateBatch` with partial-failure reporting.
## 7. Docs / barrel / subpath exports / README
## 8. Benchmarks
- [x] 8.1 Encode / decode benchmarks per format.
## 9. Ship gate
- [x] 9.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,42 @@
# OpenSpec Change: enterprise-compress-image
- **Change ID**: enterprise-compress-image
- **Package**: `@suaveplan/compress-image`
- **Category**: universal
- **Target path**: `universal/compression/compress-image`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/compress-image.md`
## Summary
High-level image compression façade. Orchestrates format-specific encoders (JPEG/MozJPEG, PNG/OxiPNG, WebP, AVIF) behind a uniform `ImageCompressor.compress(input, options)` API. Accepts quality presets, format override, max-dimension resize, and returns a `CompressionResult` with before/after stats.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: `@suaveplan/error`, `@suaveplan/types`
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`, `@jsquash/jpeg`, `@jsquash/png`, `@jsquash/webp`, `@jsquash/avif`, `@jsquash/resize`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
- `ImageCompressor` — `compress(input, options?): Promise<CompressionResult>`
- Errors: `CompressionError`, `CompressionErrorCode`, `CompressionErrorContext`, `compressionFailed`, `dimensionTooLarge`, `invalidQuality`, `unsupportedFormat`, `CompressImageErrorCode`, `COMPRESS_IMAGE_ERROR_METADATA`, `registerCompressImageErrorCodes`
- Types: `CompressionFormat`, `CompressionOptions`, `CompressionPlan`, `CompressionResult`, `CompressionStep`, `PresetConfig`, `QualityPreset`, `ResolvedCompressionOptions`
- Constants: `FORMAT_QUALITY_RANGES`, `MAX_DIMENSION`, `QUALITY_PRESETS`, `VALID_FORMATS`, `VALID_PRESETS`
## New genesis additions
- Additional codes: `COMPRESS_IMAGE.ENCODER_UNAVAILABLE`, `COMPRESS_IMAGE.INPUT_TOO_LARGE`, `COMPRESS_IMAGE.OUTPUT_LARGER_THAN_INPUT`.
- Zod schemas for every option / result type.
- OTel histogram `compress_image.compress.duration_ms{format, preset}`; counter for outcome; gauge `compress_image.bytes.saved`.
- Property tests: output byte length ≤ input byte length for any input under a fixed preset (or error thrown).
## Impact
- Adds one new package: `universal/compression/compress-image`
- Adds one new spec capability: `compress-image`

View File

@@ -0,0 +1,37 @@
# Capability Spec: compress-image
**Package**: `@suaveplan/compress-image`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### CI-01 — Format coverage
`VALID_FORMATS` MUST include `'jpeg' | 'png' | 'webp' | 'avif'`. Each format MUST have a working adapter invoked by the dispatcher.
### CI-02 — Compression API
`ImageCompressor.compress(input, options?)` MUST accept `Uint8Array | ArrayBuffer | Blob`, resolve options to a `ResolvedCompressionOptions`, build a `CompressionPlan`, and execute it to produce `CompressionResult { format, bytes, width, height, durationMs, steps: CompressionStep[], inputBytes, outputBytes, savedBytes }`.
### CI-03 — Quality presets
`QUALITY_PRESETS` MUST define `'maximum' | 'high' | 'balanced' | 'fast' | 'aggressive'` with deterministic per-format quality values obeying `FORMAT_QUALITY_RANGES`. Custom `options.quality` numeric values outside the format range MUST throw `COMPRESS_IMAGE.INVALID_QUALITY`.
### CI-04 — Max dimension
`options.maxDimension` MUST resize the longest side to at most the given pixel count. A resize step MUST appear in `CompressionPlan.steps`. Inputs exceeding `MAX_DIMENSION` (default 20 000) MUST throw `COMPRESS_IMAGE.DIMENSION_TOO_LARGE`.
### CI-05 — Degenerate output
If `outputBytes > inputBytes` and `options.preserveSmaller` is `true` (default), the compressor MUST return the original input with a `CompressionStep { action: 'skip', reason: 'output-larger-than-input' }`. Otherwise the larger compressed output is returned.
### CI-06 — Format selection
When `options.format` is omitted, the compressor MUST keep the input format. `options.format: 'auto'` MUST choose based on input type (`jpeg` → `webp` at lower size if supported; `png` with alpha → `webp`/`avif`; etc.).
### CI-07 — Observability
Every compress call MUST record `compress_image.compress.duration_ms{format, preset}`, `compress_image.compress.total{format, outcome}`, and `compress_image.bytes.saved{format}` gauge.

View File

@@ -0,0 +1,42 @@
# Tasks: enterprise-compress-image
## 1. Scaffold
- [x] 1.1 `universal/compression/compress-image/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/error-codes.ts`, `src/errors/compression-errors.ts`.
- [x] 2.2 Zod schemas per type.
## 3. Format adapters
- [x] 3.1 `src/adapters/jpeg.ts` (jsquash/jpeg).
- [x] 3.2 `src/adapters/png.ts` (jsquash/png).
- [x] 3.3 `src/adapters/webp.ts` (jsquash/webp).
- [x] 3.4 `src/adapters/avif.ts` (jsquash/avif).
- [x] 3.5 Co-located `.md` + fixture tests.
## 4. Resize
- [x] 4.1 `src/resize/resize.ts` — max-dimension constraint via jsquash/resize.
## 5. Planner / compressor
- [x] 5.1 `src/planner/planner.ts` — build `CompressionPlan` from `ResolvedCompressionOptions`.
- [x] 5.2 `src/compressor/image-compressor.ts` — `ImageCompressor`.
- [x] 5.3 OTel histogram + counter + gauge.
## 6. Presets / constants
- [x] 6.1 `src/types/compression.types.ts` exports `QUALITY_PRESETS`, `VALID_FORMATS`, `VALID_PRESETS`, `FORMAT_QUALITY_RANGES`, `MAX_DIMENSION`.
## 7. Docs / barrel / subpath exports / README
## 8. Benchmarks
- [x] 8.1 Compress 1 MB / 10 MB test images per format.
## 9. Ship gate
- [x] 9.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,133 @@
# Design: enterprise-crypto
## Module layout
```text
packages/universal/crypto/
├── package.json # deps: @noble/*, @suaveplan/error, @suaveplan/types, tslib, zod
├── tsconfig.lib.json
├── vite.config.ts
├── vitest.config.ts # thresholds 100/100/100/100
├── README.md
├── src/
│ ├── index.ts # barrel
│ ├── constants/index.ts # standard lengths, default iterations
│ ├── errors/
│ │ ├── codes.ts
│ │ ├── crypto-error.ts
│ │ ├── crypto-error.test.ts
│ │ └── index.ts
│ ├── random/
│ │ ├── random-bytes.ts # default RNG, the one place that reads crypto.getRandomValues
│ │ ├── random-bytes.test.ts
│ │ └── random-bytes.md
│ ├── compare/
│ │ ├── constant-time-equal.ts
│ │ ├── constant-time-equal.test.ts
│ │ └── constant-time-equal.md
│ ├── zeroize/
│ │ ├── zeroize.ts
│ │ ├── zeroize.test.ts
│ │ └── zeroize.md
│ ├── hash/
│ │ ├── sha2.ts # sha256, sha384, sha512 via @noble/hashes
│ │ ├── sha2.test.ts
│ │ └── sha2.md
│ ├── hmac/
│ │ ├── hmac.ts
│ │ ├── hmac.test.ts
│ │ └── hmac.md
│ ├── kdf/
│ │ ├── hkdf.ts + hkdf.test.ts + hkdf.md
│ │ ├── pbkdf2.ts + pbkdf2.test.ts + pbkdf2.md
│ │ └── argon2id.ts + argon2id.test.ts + argon2id.md
│ ├── aead/
│ │ ├── aead.ts # aeadEncrypt / aeadDecrypt dispatcher
│ │ ├── aead.test.ts
│ │ └── aead.md
│ ├── sign/
│ │ ├── ed25519.ts + ed25519.test.ts + ed25519.md
│ │ ├── ecdsa-p256.ts + ecdsa-p256.test.ts + ecdsa-p256.md
│ ├── agree/
│ │ └── x25519.ts + x25519.test.ts + x25519.md
│ └── __benchmarks__/
│ └── crypto.bench.ts
├── tests/
│ ├── setup/polyfills.ts
│ ├── dep-graph.test.ts
│ ├── no-disallowed.test.ts
│ └── tree-shake.test.ts
└── CHANGELOG.md
```
## Key design decisions
### Why @noble/* as the implementation
- Pure TypeScript — zero WASM, zero native builds, zero platform-specific branches
- Audited (Trail of Bits, Cure53)
- Used in production by MetaMask, Ethereum ecosystem, etc.
- Identical behaviour across Bun, Node, and browser — no `node:crypto` vs `crypto.subtle` branching
- Small surface, tree-shakes well
The alternative (capability-detecting between `node:crypto` and `crypto.subtle`) adds branching complexity that doesn't buy anything: noble is fast enough for the hot paths, and for the slow paths (Argon2id) every implementation is equally slow.
### AEAD dispatcher
```ts
type AeadAlgorithm = "AES-256-GCM" | "ChaCha20-Poly1305";
export function aeadEncrypt(
algo: AeadAlgorithm,
key: Uint8Array,
plaintext: Uint8Array,
options: { nonce: Uint8Array; aad?: Uint8Array }
): Uint8Array;
```
One function, algorithm selected by string — consumers don't need to import the specific algorithm module. Nonce is caller-provided (callers must not reuse nonces).
### Key + nonce validation
Every public function validates its inputs at the boundary via Zod-adjacent checks (not Zod itself — these are hot paths, we want branch-and-throw not schema-parse). Wrong key length → `CryptoError("CRYPTO.KEY.INVALID_LENGTH")`. Wrong nonce length → `CRYPTO.AEAD.INVALID_NONCE`. Etc.
### Random source injection
```ts
export interface RandomDeps {
readonly rng?: (n: number) => Uint8Array;
}
export function randomBytes(n: number, deps: RandomDeps = {}): Uint8Array {
const source = deps.rng ?? defaultRandomBytes;
return source(n);
}
```
`defaultRandomBytes` lives in `random/random-bytes.ts` and is the ONLY place that calls `crypto.getRandomValues`. Every other module (`ed25519.generateKeypair`, etc.) accepts `{ rng? }` and forwards.
### Error messages
Messages are i18n keys — `"error.crypto.aead.decrypt-failed"`, not human strings. No dynamic content in messages that could include secret material. Numerical facts (lengths, algorithm names) are allowed in `context`; raw bytes are never put in `context`.
### Zeroing
`zeroize(buf)` fills the array with zero via `buf.fill(0)`. It's not possible to guarantee zeroing in JS (GC may have already copied), but calling it communicates intent and minimises the window. Tests verify `zeroize(x); expect(x.every(b => b === 0)).toBe(true)`.
## Coverage strategy
- Enumeration tests for every branch (valid, boundary, rejected input)
- Property-based tests via fast-check:
- AEAD: `decrypt(encrypt(p, k, n, aad), k, n, aad) === p` for arbitrary p, n, aad
- Sign/verify: `verify(pk, sign(sk, m), m) === true`; wrong message → false
- Hash: determinism
- Compare: `constantTimeEqual(a, a)` always true; `a !== b` → false
- No `istanbul ignore` pragmas
- `tests/no-disallowed.test.ts` guards against `Date.now` / `Math.random` / `crypto.getRandomValues` outside the approved `random/random-bytes.ts`
## Non-goals
- Streaming AEAD — waits on `@suaveplan/streams`
- Hardware key storage, HSM, TPM
- Post-quantum (Kyber, Dilithium) — revisit once NIST standards stabilise and @noble ships them
- JOSE / JWT / JWE — belongs in `@suaveplan/jwt-tenant`, not here

View File

@@ -0,0 +1,58 @@
# Change: enterprise-crypto
## Why
`@suaveplan/crypto` is the single surface every other package uses for hashing, HMAC, HKDF/PBKDF2/Argon2id key derivation, AEAD symmetric encryption, and public-key signing. Having one canonical implementation with identical semantics across Bun, Node, and browser means consumers never pick between `node:crypto` and `crypto.subtle` — they import from one place, get structured errors, constant-time comparisons, buffer zeroing, and injectable RNG/clock for tests.
Per the foundation-100-coverage rule, it ships at 100% coverage.
## What Changes
### New package: `packages/universal/crypto`
Pure-TypeScript cryptographic substrate. Runtime dependencies: `@noble/ciphers`, `@noble/curves`, `@noble/hashes` (audited pure-TS crypto libraries), plus `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`.
#### Primitives
- **`randomBytes(n, { rng? })`** — cryptographic random bytes; default uses `crypto.getRandomValues`
- **`constantTimeEqual(a, b)`** — constant-time byte comparison
- **`zeroize(buf)`** — overwrite a `Uint8Array` with zeros
#### Hashes
- **`sha256(data)`**, **`sha384(data)`**, **`sha512(data)`** — one-shot digests returning `Uint8Array`
#### Keyed MACs + KDFs
- **`hmac(algo, key, data)`** — HMAC-SHA-256/384/512
- **`hkdf(algo, ikm, { salt, info, length })`** — HKDF extract-and-expand
- **`pbkdf2(algo, password, { salt, iterations, length })`** — PBKDF2
- **`argon2id(password, { salt, timeCost, memoryCost, parallelism, length })`** — Argon2id (memory-hard)
#### AEAD
- **`aeadEncrypt(algo, key, plaintext, { nonce, aad? })`** — AES-256-GCM and ChaCha20-Poly1305
- **`aeadDecrypt(algo, key, ciphertext, { nonce, aad? })`** — paired decrypter; throws on tag mismatch
#### Signing + key agreement
- **`ed25519.generateKeypair({ rng? })`**, **`ed25519.sign(secret, message)`**, **`ed25519.verify(public, signature, message)`**
- **`x25519.generateKeypair({ rng? })`**, **`x25519.agree(mySecret, theirPublic)`** — ECDH shared-secret
- **`ecdsaP256.generateKeypair({ rng? })`**, **`ecdsaP256.sign(secret, message)`**, **`ecdsaP256.verify(public, signature, message)`**
#### Error surface
- **`CryptoError`** extends `SuaveplanError<CryptoCode>` with codes like `CRYPTO.AEAD.DECRYPT_FAILED`, `CRYPTO.KEY.INVALID_LENGTH`, `CRYPTO.SIGN.VERIFY_FAILED`, `CRYPTO.KDF.INVALID_PARAMS`, `CRYPTO.RNG.UNAVAILABLE`
- Errors never leak plaintext, key bytes, or tag values in messages/context
#### Determinism
- All randomness flows through an injectable RNG param (`{ rng?: Rng }`) — the default is a thin wrapper over `crypto.getRandomValues`, located in exactly one file so tests can swap it
- No module-scope `Math.random()` / `Date.now()` — guarded by `tests/no-disallowed.test.ts`
## Impact
- Unblocks every downstream package that needs crypto (auth, jwt-tenant, keys, network-encryption, surrealdb keys, etc.)
- Depends on `@suaveplan/error`, `@suaveplan/types`, `@noble/ciphers`, `@noble/curves`, `@noble/hashes`
- One new catalog group (`crypto`) in the root `package.json`
- Non-goal for this change: streaming AEAD (needs `@suaveplan/streams` first), hardware-backed key storage, post-quantum algorithms, HSM integration

View File

@@ -0,0 +1,151 @@
## ADDED Requirements
### Requirement: Single canonical implementation
The package SHALL implement every primitive on top of `@noble/ciphers`, `@noble/curves`, and `@noble/hashes` — NOT on top of `node:crypto` or `crypto.subtle`. Behaviour MUST be identical across Bun, Node, and modern browsers.
#### Scenario: Same output across runtimes
- **WHEN** `sha256(x)` runs under Bun AND under a browser with the same input
- **THEN** the returned bytes are equal
### Requirement: Constant-time comparison
The package SHALL export `constantTimeEqual(a, b)` that returns `true` iff `a` and `b` have the same length AND every byte is equal. Execution time MUST be independent of the position of the first differing byte.
#### Scenario: Equal buffers
- **WHEN** `constantTimeEqual(a, b)` is called with byte-equal inputs
- **THEN** it returns `true`
#### Scenario: Different length rejected in constant time
- **WHEN** `constantTimeEqual(a, b)` is called with unequal-length inputs
- **THEN** it returns `false` without branching on content
### Requirement: Zeroization
The package SHALL export `zeroize(buf)` that fills `buf` with zero bytes.
#### Scenario: In-place zero
- **WHEN** `zeroize(x)` is called on a non-empty `Uint8Array`
- **THEN** every element of `x` is `0` afterwards
### Requirement: Hash surface
The package SHALL export `sha256`, `sha384`, `sha512` one-shot digest functions each returning a `Uint8Array` of the correct length (32, 48, 64 bytes respectively).
#### Scenario: Round-trip determinism
- **WHEN** `sha256(x)` is called twice with identical input
- **THEN** both calls return byte-identical output
### Requirement: HMAC, HKDF, PBKDF2, Argon2id
The package SHALL export `hmac`, `hkdf`, `pbkdf2`, `argon2id` with the algorithm selected by a string parameter for HMAC/HKDF/PBKDF2 (`"SHA-256" | "SHA-384" | "SHA-512"`). Argon2id is parameterised by `timeCost`, `memoryCost` (KiB), `parallelism`, and `length`.
#### Scenario: HMAC determinism
- **WHEN** `hmac("SHA-256", key, data)` is called twice with identical inputs
- **THEN** both calls return byte-identical output
#### Scenario: HKDF output length honoured
- **WHEN** `hkdf("SHA-256", ikm, { salt, info, length: 42 })` is called
- **THEN** the returned array has byte length 42
#### Scenario: PBKDF2 iterations below floor rejected
- **WHEN** `pbkdf2(algo, password, { iterations: 999, ... })` is called
- **THEN** it throws `CryptoError("CRYPTO.KDF.INVALID_PARAMS")`
#### Scenario: Argon2id parameter validation
- **WHEN** `argon2id(password, { memoryCost: 512, ... })` is called with `memoryCost < 8`
- **THEN** it throws `CryptoError("CRYPTO.KDF.INVALID_PARAMS")` (Argon2id rfc 9106 minimum)
### Requirement: AEAD (AES-256-GCM, ChaCha20-Poly1305)
The package SHALL export `aeadEncrypt` and `aeadDecrypt` dispatching by algorithm string. The key/nonce lengths SHALL be validated at the boundary. Decryption with a wrong tag SHALL throw `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` with no plaintext leakage.
#### Scenario: AES-256-GCM round-trip
- **WHEN** `aeadDecrypt("AES-256-GCM", key, aeadEncrypt("AES-256-GCM", key, p, { nonce, aad }), { nonce, aad })` is called
- **THEN** the returned bytes equal `p`
#### Scenario: ChaCha20-Poly1305 round-trip
- **WHEN** the same round-trip is performed with `"ChaCha20-Poly1305"`
- **THEN** the returned bytes equal the plaintext
#### Scenario: Tampered ciphertext rejected
- **WHEN** `aeadDecrypt` is called with a ciphertext whose tag was flipped
- **THEN** it throws `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` AND does not return any bytes
#### Scenario: Wrong key length rejected
- **WHEN** `aeadEncrypt("AES-256-GCM", key, p, { nonce })` is called with `key.length !== 32`
- **THEN** it throws `CryptoError("CRYPTO.KEY.INVALID_LENGTH")`
#### Scenario: Wrong nonce length rejected
- **WHEN** AES-256-GCM nonce length is not 12 OR ChaCha20-Poly1305 nonce length is not 12
- **THEN** `aeadEncrypt` throws `CryptoError("CRYPTO.AEAD.INVALID_NONCE")`
### Requirement: Ed25519, X25519, ECDSA-P256
The package SHALL export namespaces `ed25519`, `x25519`, `ecdsaP256` with `generateKeypair`, `sign`/`agree`, and `verify` where applicable.
#### Scenario: Ed25519 sign/verify round-trip
- **WHEN** `ed25519.verify(pk, ed25519.sign(sk, m), m)` is called
- **THEN** it returns `true`
#### Scenario: Ed25519 mismatched message
- **WHEN** `ed25519.verify(pk, signatureOfM, otherMessage)` is called
- **THEN** it returns `false`
#### Scenario: X25519 shared secret symmetry
- **WHEN** both parties compute `x25519.agree(mySecret, theirPublic)`
- **THEN** they arrive at the same shared secret
#### Scenario: ECDSA-P256 sign/verify round-trip
- **WHEN** `ecdsaP256.verify(pk, ecdsaP256.sign(sk, m), m)` is called
- **THEN** it returns `true`
### Requirement: Deterministic RNG injection
Every function that consumes randomness SHALL accept `{ rng? }` to override the default. The default randomness source lives in exactly one file (`src/random/random-bytes.ts`) and is the only place that reads `crypto.getRandomValues`.
#### Scenario: Injected RNG used
- **WHEN** `randomBytes(16, { rng: (n) => new Uint8Array(n).fill(7) })` is called
- **THEN** it returns a 16-byte array of 0x07s
#### Scenario: No module-scope non-determinism
- **WHEN** `src/` is scanned for `crypto.getRandomValues`, `Math.random`, `Date.now`
- **THEN** those symbols appear only inside `src/random/random-bytes.ts`
### Requirement: Structured errors never leak secrets
All thrown errors SHALL be `CryptoError` subclasses with codes prefixed `CRYPTO.`. Error messages and `context` MUST NOT contain plaintext, key bytes, signatures, or authentication tags. Lengths, algorithm names, and boolean facts are allowed.
#### Scenario: Decryption failure message is generic
- **WHEN** `aeadDecrypt` fails
- **THEN** the thrown `CryptoError`'s `context` contains at most `{ algorithm, ciphertextLength }` — never raw bytes
### Requirement: 100% test coverage
`vitest.config.ts` MUST enforce `statements: 100`, `branches: 100`, `functions: 100`, `lines: 100`. Property-based tests (fast-check) cover: AEAD round-trip, Ed25519 sign/verify, X25519 symmetry, ECDSA-P256 round-trip, hash determinism, constant-time-equal invariants.
#### Scenario: Coverage threshold enforced
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/crypto` runs
- **THEN** it exits non-zero if any metric drops below 100

View File

@@ -0,0 +1,23 @@
> Archived 2026-04-19: all tasks retroactively ticked; packages shipped under universal/crypto/.
# Tasks: enterprise-crypto
- [x] 1. Add `crypto` catalog (@noble/ciphers, @noble/curves, @noble/hashes) to root package.json
- [x] 2. Scaffold package.json, tsconfig, vite/vitest configs (100 thresholds)
- [x] 3. errors: `CryptoError` extending `SuaveplanError` with `CRYPTO_CODES`
- [x] 4. random: `randomBytes` with injectable RNG; single module-level `crypto.getRandomValues` callsite
- [x] 5. compare: `constantTimeEqual`
- [x] 6. zeroize: `zeroize`
- [x] 7. hash: `sha256`/`sha384`/`sha512`
- [x] 8. hmac: `hmac` with algorithm dispatch
- [x] 9. kdf: `hkdf`, `pbkdf2`, `argon2id` with parameter validation
- [x] 10. aead: `aeadEncrypt`/`aeadDecrypt` dispatching AES-256-GCM + ChaCha20-Poly1305
- [x] 11. sign: `ed25519`, `ecdsaP256` namespaces
- [x] 12. agree: `x25519` namespace
- [x] 13. Cross-cutting `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 14. Property-based tests via fast-check for every primitive
- [x] 15. Co-located `.md` per implementation file
- [x] 16. README
- [x] 17. `bunx turbo lint typecheck build test:coverage --filter=@suaveplan/crypto --force` green at 100/100/100/100
- [x] 18. Publish to Verdaccio at 0.0.0
- [x] 19. Archive change folder to `openspec/archive/<yyyy-mm-dd>-enterprise-crypto/`

View File

@@ -0,0 +1,67 @@
# OpenSpec Change: enterprise-data-masking
- **Change ID**: enterprise-data-masking
- **Package**: `@suaveplan/data-masking`
- **Category**: universal
- **Target path**: `universal/data/data-masking`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/data-masking.md`
## Summary
Greenfield rebuild of `@suaveplan/data-masking` — structured PII masking with type detection, pattern-based redaction, conditional role/field-permission masking, strategy library (`hash`, `partial`, `redact`, `maskEmail`, `maskPhone`, `maskIp`, `maskCreditCard`), pluggable `MaskingRegistry`, and an `AuditEntry` trail.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
### Masker / registry
- `DataMasker`, `MaskingRegistry`, `RegistryErrorCode`
- Strategies: `hash`, `maskCreditCard`, `maskEmail`, `maskIp`, `maskPhone`, `partial`, `redact`
### Patterns
- `PatternMasker`, `PatternMaskerOptions`, `PatternMatch`, `PatternRule`
### Detection
- `TypeDetector`, `DetectionOptions`, `DetectionResult`, `SensitiveDataType`
### Rules
- `ConditionalMasker`, `ConditionalMaskResult`, `FieldPermission`, `MaskingContext`, `MaskingPolicy`, `MaskingRole`
### Audit
- `MaskingAudit`, `AuditEntry`, `AuditFilter`, `AuditOptions`, `AuditSink`
### Error codes
- `DataMaskingErrorCode`, `DATA_MASKING_ERROR_METADATA`, `registerDataMaskingErrorCodes`
### Hooks / types
- `DataMaskingHandler`, `MaskingHookData`, `MaskingHookMap`
- `MaskingConfig`, `MaskingOptions`, `MaskingRule`, `MaskingStrategy`, `MaskResult`
## New genesis additions
- Additional codes: `DATA_MASKING.INVALID_POLICY`, `DATA_MASKING.UNKNOWN_STRATEGY`, `DATA_MASKING.AUDIT_SINK_FAILED`.
- Zod schemas for every config type.
- OTel counter `data_masking.masked.total{type}`, histogram `data_masking.mask.duration_ms`.
- Property tests: idempotency (mask twice = mask once), never-leak property on PII patterns.
## Impact
- Adds one new package: `universal/data/data-masking`
- Adds one new spec capability: `data-masking`

View File

@@ -0,0 +1,41 @@
# Capability Spec: data-masking
**Package**: `@suaveplan/data-masking`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### DM-01 — Strategy contract
Every `MaskingStrategy` MUST expose `{ name, mask(value, options?): MaskResult }`. Built-in strategies `hash`, `partial`, `redact`, `maskEmail`, `maskPhone`, `maskIp`, `maskCreditCard` MUST all implement this contract.
### DM-02 — Registry
`MaskingRegistry.register(name, strategy)` MUST reject duplicates unless `overwrite: true`. `MaskingRegistry.resolve(name)` MUST throw `DATA_MASKING.UNKNOWN_STRATEGY` when the name is absent.
### DM-03 — Idempotency
`DataMasker.mask` MUST be idempotent: `mask(mask(x)) === mask(x)` (structural equality) for every built-in strategy on PII inputs.
### DM-04 — Type detection
`TypeDetector.detect(value)` MUST return an array of `DetectionResult { type, start, end, confidence }`. Built-in `SensitiveDataType` values MUST include `email`, `phone`, `credit-card`, `ssn`, `ipv4`, `ipv6`, `jwt`, `api-key`.
### DM-05 — Conditional masking
`ConditionalMasker.apply(value, ctx)` MUST evaluate the `MaskingPolicy` with `MaskingContext { role, permissions: FieldPermission[] }`. Policies MUST either mask, allow, or redact per-field. Unknown policies MUST surface as `DATA_MASKING.INVALID_POLICY`.
### DM-06 — Audit trail
`MaskingAudit.record(entry)` MUST write `AuditEntry { at, actor?, field, strategy, outcome }` to every registered `AuditSink`. Sink failures MUST be captured and surfaced as `DATA_MASKING.AUDIT_SINK_FAILED` without aborting the mask itself.
### DM-07 — Credit-card mask
`maskCreditCard` MUST preserve the last 4 digits and validate Luhn; non-Luhn inputs MUST pass through unchanged (they're not actually credit cards) and record a lower-confidence detection result.
### DM-08 — Observability
Every mask call MUST record `data_masking.mask.duration_ms` histogram and `data_masking.masked.total{type}` counter. Masked values MUST NOT appear in OTel attributes.

View File

@@ -0,0 +1,49 @@
# Tasks: enterprise-data-masking
## 1. Scaffold
- [x] 1.1 `universal/data/data-masking/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/error-codes.ts` + side-effect register.
- [x] 2.2 Zod schemas for `MaskingConfig`, `MaskingRule`, `MaskingPolicy`, `AuditEntry`.
## 3. Strategies
- [x] 3.1 `src/strategies/strategies.ts` — `hash` (HMAC-SHA256), `partial`, `redact`, `maskEmail`, `maskPhone`, `maskIp`, `maskCreditCard` (Luhn-aware).
## 4. Detection
- [x] 4.1 `src/detection/type-detector.ts` — `TypeDetector` with built-in `SensitiveDataType` patterns.
## 5. Patterns
- [x] 5.1 `src/patterns/pattern-masker.ts`.
## 6. Registry
- [x] 6.1 `src/registry/registry.ts` — `MaskingRegistry` + registry error codes.
## 7. Conditional rules
- [x] 7.1 `src/rules/conditional-masker.ts` — role + field permissions.
## 8. Masker façade
- [x] 8.1 `src/masker/masker.ts` — `DataMasker` orchestrator; OTel instrumentation.
- [x] 8.2 `src/types/masking-hooks.ts` — hook surface.
## 9. Audit
- [x] 9.1 `src/audit/masking-audit.ts` — `MaskingAudit`, `AuditSink` interface, filter helpers.
## 10. Docs / barrel / subpath exports / README
## 11. Benchmarks
- [x] 11.1 `mask.bench.ts` on 10 k-record JSON trees.
## 12. Ship gate
- [x] 12.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,135 @@
# OpenSpec Change: enterprise-datetime
- **Change ID**: enterprise-datetime
- **Package**: `@suaveplan/datetime`
- **Category**: universal
- **Target path**: `packages/universal/datetime`
- **Status**: IN PROGRESS
- **Date**: 2026-04-18
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/datetime.md`
## Summary
Greenfield implementation of `@suaveplan/datetime` for the genesis branch. The existing main-branch datetime package provides the baseline public-API surface to preserve; this change delivers timezone-aware date/time utilities with business-day arithmetic, calendar math, recurrence rules (RRule subset), relative time formatting, injectable clock, structured errors, property-based tests, OTel instrumentation, and benchmarks.
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
## Available workspace deps on genesis
| Package | Available |
|---------|-----------|
| `@suaveplan/error` | ✅ |
| `@suaveplan/types` | ✅ |
| `@suaveplan/events` | ✅ |
| `@suaveplan/timing` | ❌ not yet built — no dependency taken |
| `@suaveplan/streams` | ❌ not yet built — not required |
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
| `@suaveplan/feature-flags` | ❌ not yet built — skip |
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
External deps (via catalog):
- `zod` → `catalog:forms`
- `@opentelemetry/api` → `catalog:observability`
- `tslib` → `catalog:build`
Dev deps (via catalog):
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
- `fast-check` → `catalog:testing`
- `mitata` → `catalog:testing`
## Preserved public API surface (from main branch)
All of the following must be exported from the genesis build:
### Functions — Business Days
- `addBusinessDays(date, n, opts?)` → `Date`
- `businessDaysBetween(start, end, opts?)` → `number`
- `isBusinessDay(date, opts?)` → `boolean`
- `nextBusinessDay(date, opts?)` → `Date`
- `previousBusinessDay(date, opts?)` → `Date`
- `subtractBusinessDays(date, n, opts?)` → `Date`
### Functions — Calendar
- `addDuration(date, duration)` → `Date`
- `daysInMonth(year, month)` → `number`
- `endOf(date, unit)` → `Date`
- `getCalendarMonth(year, month)` → `CalendarMonth`
- `isLeapYear(year)` → `boolean`
- `isSameDay(a, b)` → `boolean`
- `isSameMonth(a, b)` → `boolean`
- `startOf(date, unit)` → `Date`
- `subtractDuration(date, duration)` → `Date`
- `toDate(input)` → `Date`
### Functions — Recurrence
- `expandRecurrence(rule, opts)` → `Date[]`
- `formatRRule(rule)` → `string`
- `nextOccurrence(rule, from)` → `Date | null`
- `parseRRule(input)` → `RecurrenceRule`
### Functions — Relative
- `diffInUnits(a, b, unit)` → `number`
- `formatRelative(date, opts?)` → `string`
- `timeAgo(date, opts?)` → `string`
- `timeUntil(date, opts?)` → `string`
### Types
- `BusinessDayOptions` — `{ holidays?: Date[]; weekendDays?: Weekday[]; clock?: Clock }`
- `CalendarDay` — `{ date: Date; dayOfWeek: number; isWeekend: boolean; isToday: boolean }`
- `CalendarMonth` — `{ year: number; month: number; days: CalendarDay[] }`
- `DateInput` — `Date | string | number`
- `Duration` — `{ years?: number; months?: number; weeks?: number; days?: number; hours?: number; minutes?: number; seconds?: number; milliseconds?: number }`
- `DurationUnit` — union of duration field names
- `RelativeTimeOptions` — `{ locale?: string; clock?: Clock; precision?: DurationUnit }`
- `Timezone` — IANA timezone string branded type
- `Weekday` — 0–6 (Sunday–Saturday)
- `DayAbbreviation` — 'Sun' | 'Mon' | … | 'Sat'
- `Frequency` — 'daily' | 'weekly' | 'monthly' | 'yearly'
- `RecurrenceOptions` — options for `expandRecurrence`
- `RecurrenceRule` — `{ freq: Frequency; interval?: number; count?: number; until?: Date; byDay?: Weekday[]; byMonthDay?: number[] }`
## New additions for genesis
### Structured errors
- `DatetimeError` extends `SuaveplanError` with codes under `DATETIME.*` namespace
- Codes: `DATETIME.PARSE.INVALID_INPUT`, `DATETIME.RECURRENCE.INFINITE_EXPANSION`, `DATETIME.TIMEZONE.UNKNOWN`
### Zod schemas
- `DateInputSchema`, `DurationSchema`, `RecurrenceRuleSchema`, `TimezoneSchema` — all public types with Zod schemas in `*.schemas.ts` siblings
### Injectable clock
- All functions that read current time accept optional `{ clock?: Clock }` — zero-config default uses `Date.now()`
### OTel instrumentation
- `@opentelemetry/api` counters and histograms on `expandRecurrence`, `formatRelative`, and business-day batch functions
### Property-based tests (fast-check)
- Round-trip: `parseRRule → formatRRule → parseRRule` identity
- Inverse pairs: `addDuration + subtractDuration`, `addBusinessDays + subtractBusinessDays`
- Boundary invariants: `startOf(date, 'day')` ≤ `date` ≤ `endOf(date, 'day')`
### Benchmarks (mitata)
- `expandRecurrence` 1 000-occurrence daily rule
- `getCalendarMonth` 12-month batch
- `businessDaysBetween` 10-year span
## Impact
- Adds one new package: `packages/universal/datetime`
- Adds one new spec capability: `datetime`
- No existing package consumers on genesis yet

View File

@@ -0,0 +1,53 @@
# Capability Spec: Datetime
**Package**: `@suaveplan/datetime`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### DT-01 — DateInput acceptance
Every function that accepts a date parameter MUST accept `DateInput = Date | string | number`. Strings must be ISO-8601; numbers are Unix milliseconds. Invalid inputs throw `DatetimeError` with code `DATETIME.PARSE.INVALID_INPUT`.
### DT-02 — Injectable clock
Every function that reads current time (e.g. `timeAgo`, `isBusinessDay` for "today") MUST accept an optional `{ clock?: Clock }` option. The `Clock` interface is sourced from `@suaveplan/types`. Zero-config default uses `Date.now()`. No `Date.now()` or `new Date()` calls at module scope.
### DT-03 — Business day arithmetic
`addBusinessDays`, `subtractBusinessDays`, `nextBusinessDay`, `previousBusinessDay`, `isBusinessDay`, `businessDaysBetween` MUST all accept `BusinessDayOptions` including a custom `holidays?: Date[]` list and `weekendDays?: Weekday[]` array (defaults: Saturday and Sunday). `businessDaysBetween` MUST check `AbortSignal` every 256 days to support large ranges.
### DT-04 — Calendar math correctness
`addDuration` and `subtractDuration` MUST correctly handle month rollover (e.g. Jan 31 + 1 month = Feb 28/29) and DST transitions without double-counting hours. `daysInMonth` MUST return correct values for all months including February in leap years.
### DT-05 — Recurrence rules
`parseRRule` MUST parse a simplified RRULE string supporting `FREQ`, `INTERVAL`, `COUNT`, `UNTIL`, `BYDAY`, `BYMONTHDAY`. `expandRecurrence` MUST require either `count` or `until` in the rule; rules missing both MUST throw `DatetimeError` with code `DATETIME.RECURRENCE.INFINITE_EXPANSION`. `AbortSignal` is checked every 64 occurrences.
### DT-06 — Structured errors
All thrown errors MUST extend `DatetimeError` which extends `SuaveplanError`. Error codes are namespaced under `DATETIME.*`. The error includes `{ code, message, cause?, context? }`.
### DT-07 — Zod schemas
Every exported public type that carries runtime data MUST have a corresponding Zod schema in a sibling `*.schemas.ts` file. Schemas are exported directly (not via a schema-registry package).
### DT-08 — i18n-ready output
`formatRelative`, `timeAgo`, and `timeUntil` MUST produce output via i18n-keyed strings. The default locale is English pass-through. The key system is compatible with a future `@suaveplan/i18n` adapter.
### DT-09 — OTel instrumentation
`expandRecurrence` and `businessDaysBetween` MUST emit OpenTelemetry histograms tracking call duration and output size. All instruments are registered via `@opentelemetry/api` and are no-ops when no exporter is registered.
### DT-10 — sideEffects: false
The package MUST set `"sideEffects": false` in `package.json`. A tree-shake test MUST verify no side effects leak at import time.
### DT-11 — Coverage gate
Vitest thresholds MUST be set to 100/100/100/100 (statement/branch/function/line). No `istanbul ignore` pragmas. No `.skip` or `.only` in `src/`.

View File

@@ -0,0 +1,80 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/primitives/datetime.
# Tasks: enterprise-datetime
## 1. Scaffold
- [x] 1.1 Create `packages/universal/datetime/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph (no banned deps), no-disallowed (no `any`/`TODO`/`skip`), tree-shake (`sideEffects: false`)
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
- [x] 1.5 Add `packages/universal/datetime` to Turborepo pipeline (`turbo.json` if needed)
## 2. Types + error codes
- [x] 2.1 Implement `src/types/datetime.types.ts` — `DateInput`, `Duration`, `DurationUnit`, `Weekday`, `DayAbbreviation`, `Timezone`, `CalendarDay`, `CalendarMonth`, `BusinessDayOptions`, `RelativeTimeOptions`, `Frequency`, `RecurrenceOptions`, `RecurrenceRule`
- [x] 2.2 Add Zod schemas in `src/types/datetime.schemas.ts` for every exported type
- [x] 2.3 Implement `src/errors/datetime-error.ts` — `DatetimeError` extends `SuaveplanError`; `defineCodes` for `DATETIME.PARSE.INVALID_INPUT`, `DATETIME.RECURRENCE.INFINITE_EXPANSION`, `DATETIME.TIMEZONE.UNKNOWN`
- [x] 2.4 Tests for error construction, code narrowing, `walkCauses`
## 3. Calendar module
- [x] 3.1 Implement `src/calendar/calendar.ts` — `addDuration`, `subtractDuration`, `daysInMonth`, `endOf`, `startOf`, `getCalendarMonth`, `isLeapYear`, `isSameDay`, `isSameMonth`, `toDate`
- [x] 3.2 All functions accept `{ clock?: Clock }` where they read current time
- [x] 3.3 Unit tests covering leap years, DST boundaries, month rollover, all `DurationUnit` variants
- [x] 3.4 Property-based tests: `addDuration + subtractDuration` inverse, `startOf ≤ date ≤ endOf`
- [x] 3.5 Co-located `src/calendar/calendar.md` (≥200 words, examples, API reference)
## 4. Business days module
- [x] 4.1 Implement `src/business-days/business-days.ts` — `addBusinessDays`, `subtractBusinessDays`, `businessDaysBetween`, `isBusinessDay`, `nextBusinessDay`, `previousBusinessDay`
- [x] 4.2 Support custom `holidays` list and `weekendDays` in `BusinessDayOptions`
- [x] 4.3 Honour `AbortSignal` in `businessDaysBetween` for large ranges (check every 256 iterations)
- [x] 4.4 Unit tests: standard week, custom weekends, holiday lists, edge dates (year boundaries)
- [x] 4.5 Property-based tests: `addBusinessDays(d, n)` always a business day; `businessDaysBetween` ≥ 0
- [x] 4.6 Co-located `src/business-days/business-days.md`
## 5. Recurrence module
- [x] 5.1 Implement `src/recurrence/recurrence.ts` — `parseRRule`, `formatRRule`, `nextOccurrence`, `expandRecurrence`
- [x] 5.2 Support `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY` frequencies; `COUNT`, `UNTIL`, `BYDAY`, `BYMONTHDAY`, `INTERVAL`
- [x] 5.3 Guard `expandRecurrence` against infinite expansion: require `count` or `until`; throw `DATETIME.RECURRENCE.INFINITE_EXPANSION` otherwise
- [x] 5.4 Honour `AbortSignal` in `expandRecurrence` (check every 64 occurrences)
- [x] 5.5 Unit tests: all frequencies, BYDAY, BYMONTHDAY, COUNT vs UNTIL, edge months
- [x] 5.6 Property-based tests: `parseRRule → formatRRule → parseRRule` identity
- [x] 5.7 Co-located `src/recurrence/recurrence.md`
## 6. Relative time module
- [x] 6.1 Implement `src/relative/relative.ts` — `formatRelative`, `timeAgo`, `timeUntil`, `diffInUnits`
- [x] 6.2 Accept `{ clock?: Clock; precision?: DurationUnit }` options
- [x] 6.3 i18n-ready: all user-visible strings are keyed (default: English pass-through)
- [x] 6.4 Unit tests: seconds/minutes/hours/days/weeks/months/years ranges, past/future, precision options
- [x] 6.5 Co-located `src/relative/relative.md`
## 7. OTel instrumentation
- [x] 7.1 Add `@opentelemetry/api` histogram for `expandRecurrence` duration (occurrence count attribute)
- [x] 7.2 Add counter for `businessDaysBetween` calls
- [x] 7.3 All instrumentation no-ops when no OTel exporter registered (verify in tests)
## 8. Benchmarks
- [x] 8.1 `src/__benchmarks__/calendar.bench.ts` — `getCalendarMonth` 12-month batch (mitata)
- [x] 8.2 `src/__benchmarks__/recurrence.bench.ts` — `expandRecurrence` 1 000-occurrence daily rule
- [x] 8.3 `src/__benchmarks__/business-days.bench.ts` — `businessDaysBetween` 10-year span
- [x] 8.4 Publish baseline numbers in README
## 9. Docs
- [x] 9.1 Co-located `.md` for every implementation file (≥200 words, simple + advanced example, full API reference)
- [x] 9.2 Rewrite `README.md` with full public API overview, design rationale, consumer guide
- [x] 9.3 `bun run docs:check --filter=@suaveplan/datetime` ≥ 90%
## 10. Verify and publish
- [x] 10.1 `bunx turbo lint typecheck test --filter=@suaveplan/datetime` green
- [x] 10.2 `bunx turbo test:coverage --filter=@suaveplan/datetime` at 100/100/100/100
- [x] 10.3 `bunx turbo build --filter=@suaveplan/datetime` green
- [x] 10.4 `bun scripts/publish.ts --package @suaveplan/datetime` to Verdaccio at 0.0.0
- [x] 10.5 Archive OpenSpec change to `openspec/archive/2026-04-18-enterprise-datetime/`

View File

@@ -0,0 +1,36 @@
# OpenSpec Change: enterprise-dedup
- **Change ID**: enterprise-dedup
- **Package**: `@suaveplan/dedup`
- **Category**: universal
- **Target path**: `universal/data/dedup`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/dedup.md`
## Summary
Greenfield rebuild of `@suaveplan/dedup` — an entity deduplication engine with pluggable match keys, scoring rules, and merge strategies (latest-wins, manual, scored-merge). Computes similarity via Jaro-Winkler and Levenshtein.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
- `DedupEngine` — `detect(entities, config)`, `merge(group, strategy)`.
- Merge strategies: `latestWinsStrategy`, `manualStrategy`, `scoredMergeStrategy`.
- Similarity: `computeSimilarity`, `exactMatch`, `jaroWinklerSimilarity`, `levenshteinDistance`.
- Types: `DedupConfig`, `DuplicateGroup`, `MatchKey`, `MergeResult`, `MergeStrategy`, `ScoringRule`.
## New genesis additions
- Codes: `DEDUP.INVALID_CONFIG`, `DEDUP.STRATEGY_ERROR`.
- Zod schemas for `DedupConfig`, `ScoringRule`, `MatchKey`.
- OTel counter `dedup.groups.total`, histogram `dedup.detect.duration_ms`.
- Property tests: grouping is reflexive (`detect([x]).length === 0 duplicates`), symmetric, and scoring-rule weights in `[0, 1]`.
## Impact
- Adds one new package: `universal/data/dedup`
- Adds one new spec capability: `dedup`

View File

@@ -0,0 +1,27 @@
# Capability Spec: dedup
**Package**: `@suaveplan/dedup`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### DD-01 — Match keys
`MatchKey` MUST be `{ field, extract?, normaliser?, weight }`. The engine MUST support multiple match keys per config and combine their contributions by weighted sum, normalised to `[0, 1]`.
### DD-02 — Similarity primitives
`exactMatch(a, b)` MUST be `a === b`. `levenshteinDistance` MUST compute edit distance in O(|a|·|b|). `jaroWinklerSimilarity` MUST return a value in `[0, 1]`. `computeSimilarity(a, b, rules)` MUST apply the configured `ScoringRule[]` and return a composite score in `[0, 1]`.
### DD-03 — Detect contract
`DedupEngine.detect(entities, config)` MUST return `DuplicateGroup[]` where each group has `{ representative, members, scores }`. Every entity MUST belong to at most one group. Entities below `config.threshold` MUST NOT be grouped.
### DD-04 — Merge strategies
`latestWinsStrategy` MUST pick the entity with the most recent `updatedAt`. `manualStrategy` MUST defer to the caller via a resolver callback. `scoredMergeStrategy` MUST merge fields per-field using the highest-scored source. All strategies MUST return `MergeResult { merged, sources }`.
### DD-05 — Observability
Every `detect` call MUST record `dedup.detect.duration_ms` histogram and `dedup.groups.total` counter. Every merge MUST record `dedup.merge.total{strategy}`.
### DD-06 — Configuration validation
`DedupConfig` MUST be validated via Zod. Invalid configs MUST surface as `DEDUP.INVALID_CONFIG`.

View File

@@ -0,0 +1,27 @@
# Tasks: enterprise-dedup
## 1. Scaffold
- [x] 1.1 `universal/data/dedup/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/errors/dedup-error.ts` — codes.
- [x] 2.2 Zod schemas.
## 3. Scoring
- [x] 3.1 `src/core/scoring/similarity.ts` — `exactMatch`, `computeSimilarity`, `jaroWinklerSimilarity`, `levenshteinDistance`.
- [x] 3.2 Property tests.
## 4. Engine
- [x] 4.1 `src/core/engine/dedup-engine.ts` — `DedupEngine` (detect + merge dispatch).
- [x] 4.2 OTel counter + histogram.
## 5. Merge strategies
- [x] 5.1 `src/core/merge/merge-strategy.ts` — `latestWinsStrategy`, `manualStrategy`, `scoredMergeStrategy`.
## 6. Docs / barrel / exports / README
## 7. Benchmarks
- [x] 7.1 `detect.bench.ts` on 10 k / 100 k entities.
## 8. Ship gate
- [x] 8.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,63 @@
# OpenSpec Change: enterprise-diff
- **Change ID**: enterprise-diff
- **Package**: `@suaveplan/diff`
- **Category**: universal
- **Target path**: `universal/primitives/diff`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/diff.md`
## Summary
Greenfield implementation of `@suaveplan/diff` for the genesis branch. Ships a zero-dependency RFC 6902 JSON Patch engine with structural diffing, patch application/inversion, a linear history manager (undo/redo with branch handling), a sync manager for optimistic concurrency, and a rich path utility module. The package is a leaf — no `@suaveplan/*` workspace deps — so it unblocks `@suaveplan/undo`, `@suaveplan/crdt`, and event-sourcing packages downstream.
## Current-state audit (genesis)
Package does not exist on genesis yet — fresh build.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `zod` → `catalog:forms` (for wire-format schemas); `tslib` → `catalog:build`; `@opentelemetry/api` → `catalog:observability`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` → `catalog:testing`
## Preserved public API surface (from main)
### Core
- `diff(left, right, options?)` — structural diff producing an RFC 6902 `Patch`
- `applyPatch(doc, patch, options?)` — returns `PatchResult` with transformed document
- `revertPatch(doc, patch)` — reverts a previously applied patch
- `invertPatch(patch)`, `invertOperation(op)`
### Operations
- `createAdd`, `createCopy`, `createMove`, `createRemove`, `createReplace`, `createTest`
- `operationsConflict(a, b)`, `validateOperation(op)`, `validatePatch(patch)`
### History / sync
- `createHistory(options?)`, class `History` — linear undo/redo with branch truncation
- `createSync(options?)`, class `SyncManager` — push/pull deltas with conflict resolution
### Path utilities
- `parsePath`, `formatPath`, `appendPath`, `concatPaths`, `comparePaths`, `pathEquals`, `isPrefix`, `parentPath`, `lastSegment`, `escapeSegment`, `unescapeSegment`
### Utility functions
- `deepClone`, `deepEqual`, `hashValue`, `stringify`, `isObject`, `isPrimitive`
- `getValueAtPath`, `setValueAtPath`, `setValueAtPathMut`, `insertValueAtPath`, `insertValueAtPathMut`, `deleteValueAtPath`, `deleteValueAtPathMut`
### Types
`AddOperation`, `CopyOperation`, `MoveOperation`, `RemoveOperation`, `ReplaceOperation`, `TestOperation`, `BaseOperation`, `Operation`, `OperationType`, `Patch`, `PatchOptions`, `PatchResult`, `PatchError`, `DiffOptions`, `ArrayStrategy`, `Path`, `PathSegment`, `JsonPrimitive`, `JsonValue`, `HistoryEntry`, `HistoryState`, `HistoryOptions`, `SyncDelta`, `SyncState`, `SyncListener`, `SyncOptions`
## New genesis additions
- Structured errors: `DiffError` base extends `SuaveplanError`; codes via `defineCodes` under `DIFF.*` (`DIFF.INVALID_PATCH`, `DIFF.TEST_FAILED`, `DIFF.PATH_INVALID`, `DIFF.APPLY_FAILED`, `DIFF.INVERT_FAILED`).
- Zod schemas for every operation type, `Patch`, `PatchOptions`, `DiffOptions`, `HistoryState`, `SyncDelta` (`*.schemas.ts` siblings).
- OTel counters `diff.apply.total`, `diff.apply.failed`, histogram `diff.apply.duration_ms`; `diff.diff.duration_ms` for diff computation.
- Property-based tests (fast-check): `applyPatch(doc, diff(a, b)) === b`; `revertPatch(applyPatch(doc, p), p) === doc`; `invertPatch(invertPatch(p)) === p`.
- mitata benchmarks for `diff`, `applyPatch`, `revertPatch`, `SyncManager.push`.
## Impact
- Adds one new package: `universal/primitives/diff`
- Adds one new spec capability: `diff`
- Unblocks Tier C: `undo`, `crdt`, `event-sourcing`

View File

@@ -0,0 +1,33 @@
# Capability Spec: Diff
**Package**: `@suaveplan/diff`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### DIFF-01 — RFC 6902 JSON Patch operations
The package MUST implement all six RFC 6902 operation types (`add`, `remove`, `replace`, `move`, `copy`, `test`) with typed factories `createAdd`/`createRemove`/`createReplace`/`createMove`/`createCopy`/`createTest`. Operations MUST be validated via `validateOperation`; invalid shapes MUST throw `DiffError` with code `DIFF.INVALID_PATCH`.
### DIFF-02 — Structural diff round-trip
`diff(a, b, options?)` MUST produce a `Patch` such that `applyPatch(a, diff(a, b)).doc` is structurally equal (`deepEqual`) to `b` for any JSON-serializable pair. `DiffOptions.arrayStrategy` MUST accept `'index'` (positional replacement) or `'lcs'` (longest-common-subsequence minimal edits).
### DIFF-03 — Apply / revert contract
`applyPatch(doc, patch, options?)` MUST return `PatchResult { doc, applied, errors }` where `applied` is the count of successfully applied operations. When a `test` operation fails, the patch MUST abort and throw `DiffError` with code `DIFF.TEST_FAILED`, leaving `doc` unchanged. `revertPatch(doc, patch)` MUST be implemented such that `revertPatch(applyPatch(doc, p).doc, p)` equals `doc` for any patch that does not contain `copy` or `move` operations affecting removed data.
### DIFF-04 — Patch inversion
`invertPatch(patch)` MUST produce a patch that, when applied to the result of the original, yields the original document. `invertOperation(op)` MUST satisfy `invertOperation(invertOperation(op))` deep-equals `op` for all operation types. Operations that cannot be losslessly inverted (`remove` without prior value capture) MUST throw `DiffError` with code `DIFF.INVERT_FAILED`.
### DIFF-05 — Path utilities
`parsePath` and `formatPath` MUST implement RFC 6901 JSON Pointer grammar. `parsePath(formatPath(p))` MUST deep-equal `p` for every valid `Path`. Escape/unescape MUST handle `~0` (`~`) and `~1` (`/`). `isPrefix`, `pathEquals`, `parentPath`, `lastSegment` MUST be pure functions with zero allocation on the hot path where possible.
### DIFF-06 — History manager
`History` MUST implement bounded linear undo/redo with branch truncation: pushing a new entry after an undo MUST discard all redo entries. Size limits MUST be configurable via `HistoryOptions.maxEntries`. `HistoryState` MUST be serializable via `JSON.stringify`. Listeners MUST be notified on every state transition.
### DIFF-07 — Sync manager
`SyncManager` MUST accept local deltas via `push` and remote deltas via `pull`, emitting `SyncDelta` objects to registered listeners. Conflicts MUST be detected via `operationsConflict` and resolved per the configured `SyncOptions.conflictResolution` strategy (`'local-wins' | 'remote-wins' | 'reject'`).
### DIFF-08 — Observability
Every `applyPatch` call MUST emit OTel counters `diff.apply.total` (labels: `outcome: 'success' | 'failure'`) and histogram `diff.apply.duration_ms`. Every `diff` call MUST emit `diff.diff.duration_ms`. Meters MUST use `@opentelemetry/api` and be no-ops when no exporter is registered.

View File

@@ -0,0 +1,64 @@
# Tasks: enterprise-diff
## 1. Scaffold
- [x] 1.1 Create `universal/primitives/diff/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton.
- [x] 1.2 Register 100/100/100/100 coverage thresholds in `vitest.config.ts`; `provider: 'istanbul'`.
- [x] 1.3 Cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake, file-size budget.
- [x] 1.4 Add package to root `CLAUDE.md` listing, `README.md` table, `FOLDERS.yml`.
## 2. Errors + schemas
- [x] 2.1 `src/errors/diff-error.ts` — `DiffError` extends `SuaveplanError`; `defineCodes` for `DIFF.INVALID_PATCH`, `DIFF.TEST_FAILED`, `DIFF.PATH_INVALID`, `DIFF.APPLY_FAILED`, `DIFF.INVERT_FAILED`.
- [x] 2.2 Auto-register codes via side-effect import in `src/index.ts`.
- [x] 2.3 Zod schemas: `OperationSchema`, `PatchSchema`, `PatchOptionsSchema`, `DiffOptionsSchema`, `HistoryStateSchema`, `SyncDeltaSchema` in `*.schemas.ts` siblings.
- [x] 2.4 Tests: error construction, code narrowing, `{ path?, operation?, reason? }` context fields.
## 3. Path module
- [x] 3.1 `src/path/path.ts` — `parsePath`, `formatPath`, `appendPath`, `concatPaths`, `comparePaths`, `pathEquals`, `isPrefix`, `parentPath`, `lastSegment`, `escapeSegment`, `unescapeSegment`.
- [x] 3.2 Co-located `src/path/path.md`.
- [x] 3.3 Unit tests + property tests: `parsePath(formatPath(p)) === p`; escape round-trip.
## 4. Utils module
- [x] 4.1 `src/utils/utils.ts` — `deepClone`, `deepEqual`, `hashValue`, `stringify`, `isObject`, `isPrimitive`, `getValueAtPath`, `setValueAtPath`, `setValueAtPathMut`, `insertValueAtPath`, `insertValueAtPathMut`, `deleteValueAtPath`, `deleteValueAtPathMut`.
- [x] 4.2 Co-located `src/utils/utils.md`.
- [x] 4.3 Unit tests covering edge cases: root path, array index beyond length, circular refs.
## 5. Operations
- [x] 5.1 `src/operations/operations.ts` — `createAdd`, `createCopy`, `createMove`, `createRemove`, `createReplace`, `createTest`, `invertOperation`, `invertPatch`, `operationsConflict`, `validateOperation`, `validatePatch`.
- [x] 5.2 Co-located `src/operations/operations.md`.
- [x] 5.3 Unit tests for each factory + invert round-trip + conflict detection.
## 6. Diff algorithm
- [x] 6.1 `src/diff/diff.ts` — structural diff with configurable `ArrayStrategy` (`'index' | 'lcs'`).
- [x] 6.2 Co-located `src/diff/diff.md`.
- [x] 6.3 Property tests: `applyPatch(doc, diff(a, b)) === b`.
## 7. Patch apply/revert
- [x] 7.1 `src/patch/patch.ts` — `applyPatch`, `revertPatch`; OTel counters + histogram.
- [x] 7.2 Co-located `src/patch/patch.md`.
- [x] 7.3 Unit tests: each operation type, failure modes, `test` op semantics; `PatchResult` shape.
## 8. History
- [x] 8.1 `src/history/history.ts` — `History` class with `push`, `undo`, `redo`, branch truncation; `createHistory` factory.
- [x] 8.2 Co-located `src/history/history.md`.
- [x] 8.3 Unit tests: linear undo/redo, branch-truncation on push, bounded size, listener notifications.
## 9. Sync manager
- [x] 9.1 `src/sync/sync.ts` — `SyncManager` class; `createSync` factory; delta push/pull; optimistic rebasing.
- [x] 9.2 Co-located `src/sync/sync.md`.
- [x] 9.3 Unit tests: concurrent deltas, conflict resolution, listener notifications.
## 10. Documentation + index
- [x] 10.1 Public `src/index.ts` — re-exports only; no side effects besides code-registry import.
- [x] 10.2 Subpath exports in `package.json` per feature module.
- [x] 10.3 Root `README.md` with Install / Quick-start / Modules links.
## 11. Benchmarks
- [x] 11.1 `src/__benchmarks__/diff.bench.ts`, `apply.bench.ts`, `revert.bench.ts`, `sync-push.bench.ts`.
- [x] 11.2 Publish baseline throughput numbers in README.
## 12. Ship gate
- [x] 12.1 `bunx turbo lint typecheck test --filter=@suaveplan/diff` — green.
- [x] 12.2 `bunx turbo test:coverage --filter=@suaveplan/diff` — 100/100/100/100.
- [x] 12.3 `bunx turbo build --filter=@suaveplan/diff` — green.
- [x] 12.4 Banned-pattern scan + missing-`.md` scan return zero output.
- [x] 12.5 Fold spec delta into `openspec/specs/diff/spec.md`; archive change folder.

View File

@@ -0,0 +1,35 @@
# OpenSpec Change: enterprise-dmarc
- **Change ID**: enterprise-dmarc
- **Package**: `@suaveplan/dmarc`
- **Category**: universal
- **Target path**: `universal/email/dmarc`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/dmarc.md`
## Summary
Parses DMARC aggregate (RUA) and forensic (RUF) reports into typed structures. Input: raw XML or `application/gzip`-wrapped XML as produced by Google / Microsoft / Yahoo aggregate reporters. Output: `DmarcAggregateReport` / `DmarcForensicReport`. Leaf package — useful to deliverability analytics.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `fast-xml-parser`, `fflate`, `zod`, `tslib`, `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
- `parseDmarcAggregate(input, options?): DmarcAggregateReport`
- `parseDmarcForensic(input, options?): DmarcForensicReport`
- Types: `DmarcAggregateReport`, `DmarcForensicReport`, `DmarcRecord`, `DmarcPolicyPublished`, `DmarcAuthResult`, `DmarcDateRange`, `DmarcIdentifier`, `DmarcReportMetadata`, `AlignmentMode`, `DispositionMode`
## New genesis additions
- Codes: `DMARC.PARSE_ERROR`, `DMARC.INVALID_REPORT`, `DMARC.UNSUPPORTED_ENCODING`.
- Zod schemas for each report type.
- OTel counter `dmarc.parse.total{kind}`, histogram `dmarc.parse.duration_ms{kind}`.
- Property tests on random but structurally valid XML trees.
## Impact
- Adds one new package: `universal/email/dmarc`
- Adds one new spec capability: `dmarc`

View File

@@ -0,0 +1,24 @@
# Capability Spec: dmarc
**Package**: `@suaveplan/dmarc`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### DMARC-01 — Aggregate report parsing
`parseDmarcAggregate(input)` MUST accept raw XML or gzip-wrapped XML (magic `1F 8B`). It MUST return `DmarcAggregateReport { reportMetadata, policyPublished, records: DmarcRecord[] }` matching the schema published at `https://dmarc.org/dmarc-xml/0.1/rua.xsd`.
### DMARC-02 — Forensic report parsing
`parseDmarcForensic(input)` MUST parse a single RFC 6591 message/feedback-report into `DmarcForensicReport { reportedDomain, sourceIp, identifier, authResults, originalMessage? }`.
### DMARC-03 — Policy / alignment fields
`AlignmentMode` MUST be `'r' | 's'`. `DispositionMode` MUST be `'none' | 'quarantine' | 'reject'`. `DmarcAuthResult` MUST carry `{ dkim: 'pass' | 'fail', spf: 'pass' | 'fail' }` (minimum).
### DMARC-04 — Validation
Every report output MUST pass its Zod schema. Invalid input MUST throw `DMARC.INVALID_REPORT` with the failing XPath in context.
### DMARC-05 — Observability
Parsing MUST record `dmarc.parse.duration_ms{kind}` histogram and `dmarc.parse.total{kind}` counter (`kind` ∈ `aggregate | forensic`).

View File

@@ -0,0 +1,25 @@
# Tasks: enterprise-dmarc
## 1. Scaffold
- [x] 1.1 `universal/email/dmarc/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/errors/dmarc-error.ts` — codes.
- [x] 2.2 Zod schemas for `DmarcAggregateReport`, `DmarcForensicReport`, nested types.
## 3. Decoders
- [x] 3.1 `src/decoders/gzip.ts` — inflate via `fflate`.
- [x] 3.2 `src/decoders/xml.ts` — `fast-xml-parser` with strict option set.
## 4. Parsers
- [x] 4.1 `src/parsers/aggregate.ts` — `parseDmarcAggregate`.
- [x] 4.2 `src/parsers/forensic.ts` — `parseDmarcForensic`.
- [x] 4.3 Fixture-based tests (Google/Microsoft/Yahoo samples).
## 5. Docs / barrel / subpath exports / README
## 6. Benchmarks
- [x] 6.1 Parse a 500 KB aggregate report.
## 7. Ship gate
- [x] 7.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,45 @@
# OpenSpec Change: enterprise-domain-normalizer
- **Change ID**: enterprise-domain-normalizer
- **Package**: `@suaveplan/domain-normalizer`
- **Category**: universal
- **Target path**: `universal/data/domain-normalizer`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/domain-normalizer.md`
## Summary
Normalisation, matching, and alias-canonicalisation for internet domain names. Given heterogeneous URL/domain input (including common aliases such as `m.youtube.com → youtube.com`), produces a canonical registrable domain and computes similarity scores for deduplication.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`, `tldts` (public-suffix resolution) — appropriate catalogs
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
### Normaliser
- `normalizeDomain(input, config?)`, `normalizeUrl(url, config?)`
- `getRegistrableDomain(input)`, `getSubdomain(input)`, `getDomainGroup(input)`
### Aliases
- `BUILT_IN_ALIASES`, `getAliasGroup`, `getCanonicalDomain`, `isKnownAlias`, `resetAliasCache`
### Matcher
- `matchDomains(a, b, config?)`, `computeSimilarity(a, b)`, `findMatchingDomains`, `groupDomains`, `levenshteinDistance`
### Types
`DomainAliasGroup`, `DomainMatch`, `DomainMatchReason`, `NormalizedUrl`, `NormalizerConfig`
## New genesis additions
- Codes: `DOMAIN_NORMALIZER.INVALID_INPUT`, `DOMAIN_NORMALIZER.PSL_LOOKUP_FAILED`.
- Zod schemas for `NormalizerConfig`, `DomainAliasGroup`, `NormalizedUrl`.
- OTel counter `domain_normalizer.normalize.total{outcome}`, histogram `domain_normalizer.normalize.duration_ms`.
- Property tests: normalise is idempotent; `getRegistrableDomain` is a fixed point under repeated application.
## Impact
- Adds one new package: `universal/data/domain-normalizer`
- Adds one new spec capability: `domain-normalizer`

View File

@@ -0,0 +1,30 @@
# Capability Spec: domain-normalizer
**Package**: `@suaveplan/domain-normalizer`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### DN-01 — Normalisation contract
`normalizeDomain(input)` MUST lowercase, trim, strip leading `www.`, strip port, and apply punycode conversion for IDNs. For any valid input, `normalizeDomain(normalizeDomain(x))` MUST equal `normalizeDomain(x)`.
### DN-02 — Registrable domain via PSL
`getRegistrableDomain(input)` MUST return the domain at the public-suffix+1 level using the Public Suffix List via `tldts`. For `a.b.co.uk` → `b.co.uk`. Invalid inputs MUST return `undefined`, not throw.
### DN-03 — Alias canonicalisation
`getCanonicalDomain(input)` MUST resolve built-in aliases (e.g. `m.youtube.com`, `youtu.be` → `youtube.com`) plus any aliases registered via `config.customAliases`. `isKnownAlias` MUST return `true` iff the input matches any alias rule. `resetAliasCache` MUST clear any memoisation.
### DN-04 — Similarity
`computeSimilarity(a, b)` MUST return a value in `[0, 1]` combining normalised Levenshtein distance and PSL-level registrable-domain equality. `levenshteinDistance` MUST be a standard O(|a|·|b|) implementation.
### DN-05 — Match result
`matchDomains(a, b, config?)` MUST return `DomainMatch { matched, reason, score }`. `reason` MUST be one of `'exact' | 'alias' | 'registrable-equal' | 'similarity' | 'none'`.
### DN-06 — URL parsing
`normalizeUrl(url)` MUST return `NormalizedUrl { protocol, host, port?, pathname, search, hash, registrableDomain, subdomain, canonicalDomain }`. Malformed URLs MUST surface as `DOMAIN_NORMALIZER.INVALID_INPUT`.
### DN-07 — Observability
Every normalisation MUST emit `domain_normalizer.normalize.total{outcome}` counter and `domain_normalizer.normalize.duration_ms` histogram.

View File

@@ -0,0 +1,29 @@
# Tasks: enterprise-domain-normalizer
## 1. Scaffold
- [x] 1.1 `universal/data/domain-normalizer/` skeleton; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/errors/*.ts` — codes.
- [x] 2.2 Zod schemas.
## 3. Aliases
- [x] 3.1 `src/core/aliases/aliases.ts` — `BUILT_IN_ALIASES` (curated list: m./mobile./amp./lite./www. → canonical), cache + reset.
## 4. Normaliser
- [x] 4.1 `src/core/normalizer/normalizer.ts` — `normalizeDomain`, `normalizeUrl`, `getRegistrableDomain` (via `tldts`), `getSubdomain`, `getDomainGroup`.
- [x] 4.2 Idempotency property tests.
## 5. Matcher
- [x] 5.1 `src/core/matcher/matcher.ts` — `matchDomains`, `computeSimilarity`, `findMatchingDomains`, `groupDomains`, `levenshteinDistance`.
- [x] 5.2 Property test: `levenshteinDistance(a,a) === 0`; triangle inequality.
## 6. Co-located `.md` + unit tests per module
## 7. Barrel / subpath exports / README
## 8. Benchmarks
- [x] 8.1 `normalize.bench.ts`, `matcher.bench.ts` on 10 k domain fixture.
## 9. Ship gate
- [x] 9.1 Verification sequence; fold spec + archive.

View File

@@ -0,0 +1,201 @@
# OpenSpec Change: enterprise-env
- **Change ID**: enterprise-env
- **Package**: `@suaveplan/env`
- **Category**: universal
- **Target path**: `packages/universal/env`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/env.md`
## Summary
Greenfield implementation of `@suaveplan/env` for the genesis branch. The existing main-branch env package provides the baseline public-API surface to preserve; this change delivers enterprise-grade type-safe environment variable loading with schema-first validation, a rich set of typed field builders (string, number, boolean, enum, URL, email, port, JSON, array), pluggable loaders (dotenv file, plain object), common presets for server apps, structured errors with `ENV.*` codes, Zod schemas, OTel instrumentation, property-based tests via fast-check, and benchmarks.
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
## Available workspace deps on genesis
| Package | Available |
|---------|-----------|
| `@suaveplan/error` | ✅ |
| `@suaveplan/types` | ✅ |
| `@suaveplan/logger` | ✅ |
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
External deps (via catalog):
- `zod` → `catalog:forms`
- `@opentelemetry/api` → `catalog:observability`
- `tslib` → `catalog:build`
Dev deps (via catalog):
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
- `fast-check` → `catalog:testing`
- `mitata` → `catalog:testing`
## Preserved public API surface (from main branch)
### Constants
- `DEFAULT_ARRAY_SEPARATOR` — default delimiter used by `ArrayField`
- `EMAIL_PATTERN` — regex for RFC-5322-compatible email validation
- `FALSY_VALUES` — string values coerced to `false` by `BooleanField`
- `INTERPOLATION_PATTERN` — regex matching `${VAR}` tokens in raw values
- `PORT_MAX` — upper bound for valid TCP port (65535)
- `PORT_MIN` — lower bound for valid TCP port (1)
- `TRUTHY_VALUES` — string values coerced to `true` by `BooleanField`
### Core
- `createEnv(schema, options?)` — loads env vars through configured loaders, validates all fields, collects or throws errors, returns typed record
- `CollectedErrors` — map of field key → array of error messages produced during a collect-mode run
- `EnvResult<S>` — the resolved typed record produced by `createEnv`
- `BaseField<T>` — abstract base class for all field builders; holds rules, default, sensitive flag, description
- `FieldConfig<T>` — runtime configuration snapshot extracted from any `BaseField`
- `RuleOptions` — options accepted by `BaseField.rules()` for adding a named validation rule
- `maskSensitive(values, schema)` — replaces sensitive field values with `***` in the result map
- `validate(input)` → `ValidationResult` — runs all rules for a single field, accumulating failures
- `ValidateInput` — `{ key, raw, field, coerced? }` fed into `validate`
- `ValidationResult` — `{ ok, errors }` where `errors` is an array of `{ rule, message }` pairs
### Types
- `AnyField<T, R>` — minimal interface every field builder satisfies; used for schema maps
- `CreateEnvOptions` — options accepted by `createEnv`: `loaders`, `errorMode`, `signal?`
- `EnvError` — `{ key, message, value? }` shape for a single validation failure
- `EnvSchema` — `Record<string, AnyField>` used as the schema argument to `createEnv`
- `ErrorMode` — `'throw' | 'collect' | 'warn'`
- `InferEnv<S>` — maps an `EnvSchema` to the resulting typed record type
- `InferFieldType<F>` — extracts the value type from a field builder
- `Loader` — `{ name: string; load(): LoaderResult }` interface for env sources
- `LoaderResult` — `{ values: Record<string, string>; source: string }`
- `OptionalKeys<S>` — keys whose fields may produce `undefined`
- `RequiredKeys<S>` — keys whose fields always produce a value
- `Simplify<T>` — intersection flattener utility type
- `ValidationRule<T>` — `{ name, message, validate(value): boolean }`
### Fields
- `StringField` / `str` — string field builder; rules: `minLength`, `maxLength`, `pattern`, `nonempty`
- `NumberField` / `num` — number field; rules: `min`, `max`, `integer`
- `BooleanField` / `bool` — boolean field; coerces TRUTHY_VALUES / FALSY_VALUES strings
- `EnumField<V>` / `enums` — enum field locked to a declared `EnumValues` tuple
- `EnumValues` — string-tuple type constraint for `EnumField`
- `UrlField` / `url` — URL field; validates via `URL` constructor, optional protocol restriction
- `EmailField` / `email` — email field; validates against `EMAIL_PATTERN`
- `PortField` / `port` — port field; integer in `[PORT_MIN, PORT_MAX]`
- `JsonField<T>` / `json` — JSON field; accepts a `JsonValidator<T>` for runtime shape checking
- `JsonValidator<T>` — `(parsed: unknown) => T` used with `JsonField`
- `ArrayField<T>` / `array` — array field; splits by `DEFAULT_ARRAY_SEPARATOR` (or custom), maps each item through an `ItemField`
- `ItemField<T>` — per-element field descriptor for `ArrayField`
### Loaders
- `dotenvLoader(options?)` — reads a `.env` file from disk and returns a `LoaderResult`
- `parseDotenv(content)` — pure function: parses a dotenv-format string into a `Record<string, string>`
- `DotenvLoaderOptions` — `{ path?, encoding?, override? }`
- `objectLoader(obj, options?)` — wraps an in-memory object as a loader
- `ObjectLoaderOptions` — `{ source? }` (source label for diagnostics)
### Presets — common
- `nodeEnvField` — `NODE_ENV` enum field locked to `'development' | 'test' | 'production' | 'staging'`
- `portField` — `PORT` with default 3000
- `hostField` — `HOST` with default `'0.0.0.0'`
- `logLevelField` — `LOG_LEVEL` enum locked to standard logger levels
- `debugField` — `DEBUG` boolean with default `false`
- `appNameField` — `APP_NAME` required string
- `appVersionField` — `APP_VERSION` string with default `'0.0.0'`
- `timezoneField` — `TZ` string with default `'UTC'`
- `workersField` — `WORKERS` integer number
- `commonFields` — composite preset: `nodeEnvField + portField + hostField + logLevelField + debugField`
### Presets — server
- `databaseUrlField` — `DATABASE_URL` sensitive required URL
- `redisUrlField` — `REDIS_URL` sensitive URL
- `sessionSecretField` — `SESSION_SECRET` sensitive required string with `minLength: 32`
- `corsOriginField` — `CORS_ORIGIN` string or array
- `rateLimitMaxField` — `RATE_LIMIT_MAX` integer number
- `rateLimitWindowField` — `RATE_LIMIT_WINDOW_MS` integer number
- `trustProxyField` — `TRUST_PROXY` boolean with default `false`
- `smtpHostField` — `SMTP_HOST` string
- `smtpPortField` — `SMTP_PORT` port field
- `smtpUserField` — `SMTP_USER` string
- `smtpPassField` — `SMTP_PASS` sensitive string
- `smtpFromField` — `SMTP_FROM` email field
- `serverFields` — composite: common server connection + SMTP fields
### Transforms (coerce utilities)
- `coerceString(raw)` — identity / trim
- `coerceNumber(raw)` — parses float, rejects NaN
- `coerceInteger(raw)` — parses integer, rejects non-integer
- `coerceBoolean(raw)` — maps truthy/falsy string sets
- `coerceArray(raw, separator?)` — splits and trims
- `coerceJson(raw)` — `JSON.parse` with structured error on failure
- `CoerceFn<T>` — `(raw: string) => T`
- `CoerceEntry<T>` — `{ coerce: CoerceFn<T> }` mixin used internally
### Utilities
- `EnvValidationError` extends `SuaveplanError` — thrown in `'throw'` error mode
- `MissingEnvError` extends `SuaveplanError` — thrown when a required key is absent
- `CoercionError` extends `SuaveplanError` — thrown when coercion fails for a raw value
- `DotenvParseError` extends `SuaveplanError` — thrown by `parseDotenv` on malformed input
- `createEnvError(key, message, value?)` — factory for `EnvError` value objects
- `interpolate(template, values)` — resolves `${VAR}` tokens in a string from a values map
- `hasInterpolation(value)` — returns `true` if the string contains any `${VAR}` tokens
- `extractVariables(template)` — returns the list of variable names referenced in a template string
## New genesis additions
### Structured errors
- `EnvError` base class extends `SuaveplanError` with codes under the `ENV.*` namespace registered via `defineCodes`
- Codes:
- `ENV.VALIDATION_FAILED` — one or more fields failed validation rules
- `ENV.MISSING_REQUIRED` — a required field has no value and no default
- `ENV.INVALID_FORMAT` — coercion of a raw string to the target type failed
- `ENV.PARSE_ERROR` — dotenv file parse failed or JSON parse failed
- `EnvValidationError`, `MissingEnvError`, `CoercionError`, `DotenvParseError` all extend `EnvError` using the appropriate code
- All errors carry `{ key, raw?, source? }` context for diagnostics
### Zod schemas
- `FieldConfigSchema` — validates the `FieldConfig<unknown>` shape at runtime
- `ValidationResultSchema` — validates `{ ok, errors }` produced by `validate`
- `EnvResultSchema` — validates the merged output record from `createEnv`
- `CreateEnvOptionsSchema` — validates options passed to `createEnv`
- `DotenvLoaderOptionsSchema`, `ObjectLoaderOptionsSchema` — validates loader options at the boundary
- All schemas exported from `*.schemas.ts` sibling files
### OTel instrumentation on createEnv
- Counter `env.validation.total` (labels: `outcome: 'success' | 'error'`) — incremented once per `createEnv` call
- Counter `env.validation.errors` (label: `field`) — incremented once per field that fails validation
- Histogram `env.load.duration_ms` — duration of the full `createEnv` call including all loaders
- All meters via `@opentelemetry/api`; no-op without exporter
### Property-based tests (fast-check)
- Field parsers round-trip: `coerce(raw)` produces a stable value for all valid `raw` strings
- `maskSensitive` never leaks values: for any schema with sensitive fields, the masked output never contains the original raw value at that key
- `validate` accumulates all errors: if N rules fail, `ValidationResult.errors.length === N`
- `parseDotenv` is pure: same input always yields same output
### Benchmarks (mitata)
- `createEnv` with a 20-field schema, object loader, no errors — hot-path throughput
- `validate` hot path — single field with 5 rules, all passing
## Impact
- Adds one new package: `packages/universal/env`
- Adds one new spec capability: `env`
- No existing package consumers on genesis yet

View File

@@ -0,0 +1,127 @@
# Capability Spec: Env
**Package**: `@suaveplan/env`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### ENV-01 — Schema-first type-safe loading
`createEnv(schema, options?)` MUST accept an `EnvSchema` (a `Record<string, AnyField>`) and an optional `CreateEnvOptions`. It MUST run all configured loaders in order, merge their `LoaderResult.values` maps (later loaders win by default), resolve `${VAR}` interpolation tokens, coerce each raw string through its field's `coerce()` method, run all registered validation rules, and return an `EnvResult<S>` whose type is fully inferred from the schema via `InferEnv<S>`. The returned object MUST be typed so that required fields are never `undefined` and optional fields may be `undefined`.
### ENV-02 — Error mode contract
`createEnv` MUST support three `ErrorMode` values:
- `'throw'` (default): throw `EnvValidationError` or `MissingEnvError` on the first field failure.
- `'collect'`: accumulate all field failures into `CollectedErrors` (a `Record<string, string[]>`) without throwing; return the partial result alongside the error map.
- `'warn'`: log each failure via `console.warn` and return the partial result; do not throw.
### ENV-03 — Loader contract
A `Loader` MUST implement `{ name: string; load(): LoaderResult }`. `LoaderResult` MUST be `{ values: Record<string, string>; source: string }`. `createEnv` MUST call each loader's `load()` in the order they appear in `options.loaders`. If no loaders are provided the raw values map is empty and only defaults and optional fields can produce output.
### ENV-04 — objectLoader
`objectLoader(obj, options?)` MUST wrap any `Record<string, string | undefined>` as a `Loader`. `ObjectLoaderOptions.source` (optional) sets the `LoaderResult.source` label; defaults to `'object'`. Values that are `undefined` MUST be omitted from `LoaderResult.values`.
### ENV-05 — dotenvLoader and parseDotenv
`parseDotenv(content)` MUST be a pure function that parses a dotenv-format string into a `Record<string, string>`. It MUST handle: quoted values (single and double), inline comments (`# …`), blank lines, and `=` characters inside values. Malformed lines that cannot be parsed MUST cause a `DotenvParseError` (code `ENV.PARSE_ERROR`). `dotenvLoader(options?)` MUST read the file at `options.path` (default `'.env'`), call `parseDotenv`, and return a `LoaderResult`. If the file does not exist the loader MUST return an empty `LoaderResult` (no error); this allows optional `.env` files. `DotenvLoaderOptions.override` (boolean, default `true`) controls whether dotenv values overwrite values already present from earlier loaders.
### ENV-06 — Interpolation
`interpolate(template, values)` MUST replace all `${VAR}` tokens in `template` with the corresponding entry from `values`, leaving unresolved tokens as-is. `hasInterpolation(value)` MUST return `true` if and only if the string contains at least one `${VAR}` token. `extractVariables(template)` MUST return all unique variable names referenced in the template. `createEnv` MUST expand interpolation on every raw value before coercion.
### ENV-07 — BaseField builder contract
`BaseField<T>` MUST provide the following fluent builder methods, each returning `this` for chaining:
- `optional()` — marks the field as not required; returns `undefined` when the key is absent and there is no default
- `default(v: T)` — sets a default value used when the key is absent
- `sensitive()` — marks the field so `maskSensitive` redacts its value in logs/output
- `describe(s: string)` — attaches a human-readable description
- `rules(opts: RuleOptions)` — appends a named custom validation rule
`BaseField` MUST declare `abstract coerce(raw: string): T`. Calling `coerce` with an uncoercible string MUST throw `CoercionError` (code `ENV.INVALID_FORMAT`).
### ENV-08 — Field type coercion and rules
Each field type MUST coerce its raw string through the corresponding transform and enforce its built-in rules before any custom rules run:
- `StringField` — identity coercion (trim); built-in rules: `minLength`, `maxLength`, `pattern`, `nonempty`
- `NumberField` — `parseFloat`; rejects `NaN`; built-in rules: `min`, `max`, `integer`
- `BooleanField` — maps `TRUTHY_VALUES` strings to `true`, `FALSY_VALUES` strings to `false`; any other value is a coercion error
- `EnumField<V>` — validates raw string is a member of the declared `EnumValues` tuple
- `UrlField` — validates via `new URL()`; optional `protocols` allowlist rule rejects disallowed schemes
- `EmailField` — validates against `EMAIL_PATTERN` regex
- `PortField` — coerces to integer; validates in `[PORT_MIN, PORT_MAX]`
- `JsonField<T>` — `JSON.parse`; applies optional `JsonValidator<T>` for shape checking
- `ArrayField<T>` — splits on `separator` (default `DEFAULT_ARRAY_SEPARATOR = ','`), trims each item, maps through `itemField.coerce`
### ENV-09 — validate accumulates all errors
`validate(input: ValidateInput)` MUST run all rules in `input.field._rules` and collect every failure. The returned `ValidationResult` MUST be `{ ok: boolean; errors: Array<{ rule: string; message: string }> }` where `errors` contains one entry per failing rule. `ok` MUST be `true` if and only if `errors` is empty.
### ENV-10 — maskSensitive never leaks
`maskSensitive(values, schema)` MUST return a shallow copy of `values` where every key whose corresponding `schema[key]._isSensitive` is `true` has its value replaced with the literal string `'***'`. The original `values` object MUST NOT be mutated.
### ENV-11 — Structured errors with ENV.* codes
All errors thrown by this package MUST extend the `EnvError` base (which extends `SuaveplanError`). Error codes MUST be registered via `defineCodes` in the `ENV.*` namespace:
| Code | Class | Trigger |
|---|---|---|
| `ENV.VALIDATION_FAILED` | `EnvValidationError` | One or more field rules failed |
| `ENV.MISSING_REQUIRED` | `MissingEnvError` | Required field has no value and no default |
| `ENV.INVALID_FORMAT` | `CoercionError` | Raw string cannot be coerced to target type |
| `ENV.PARSE_ERROR` | `DotenvParseError` | Dotenv file or JSON value cannot be parsed |
Every error instance MUST carry `{ key: string; raw?: string; source?: string }` in its structured context.
### ENV-12 — Zod schemas for runtime validation
The following Zod schemas MUST be exported from `*.schemas.ts` sibling files and used to validate inputs at public API boundaries:
- `FieldConfigSchema` — validates `FieldConfig<unknown>`
- `ValidationResultSchema` — validates `ValidationResult`
- `CreateEnvOptionsSchema` — validates `CreateEnvOptions`
- `DotenvLoaderOptionsSchema` — validates `DotenvLoaderOptions`
- `ObjectLoaderOptionsSchema` — validates `ObjectLoaderOptions`
Invalid options passed to `createEnv`, `dotenvLoader`, or `objectLoader` MUST throw `EnvValidationError` with code `ENV.VALIDATION_FAILED` and a context that includes the Zod parse error details.
### ENV-13 — Presets
The common preset fields (`nodeEnvField`, `portField`, `hostField`, `logLevelField`, `debugField`, `appNameField`, `appVersionField`, `timezoneField`, `workersField`) and the `commonFields` composite MUST be exported from `src/presets/common/`. The server preset fields (`databaseUrlField`, `redisUrlField`, `sessionSecretField`, `corsOriginField`, `rateLimitMaxField`, `rateLimitWindowField`, `trustProxyField`, `smtpHostField`, `smtpPortField`, `smtpUserField`, `smtpPassField`, `smtpFromField`) and the `serverFields` composite MUST be exported from `src/presets/server/`. All sensitive preset fields (database URL, Redis URL, session secret, SMTP password) MUST have `_isSensitive = true`.
### ENV-14 — OTel instrumentation
`createEnv` MUST emit the following metrics via `@opentelemetry/api` `getMeter`:
- Counter `env.validation.total` with label `outcome: 'success' | 'error'` — incremented once per `createEnv` invocation after all fields are processed
- Counter `env.validation.errors` with label `field: string` — incremented once per field that fails validation or coercion
- Histogram `env.load.duration_ms` — records the wall-clock duration of the full `createEnv` call
All instruments MUST be no-ops when no OTel SDK exporter is registered. The instrumentation MUST NOT throw or suppress errors from the calling code.
### ENV-15 — sideEffects: false + coverage 100%
`"sideEffects": false` MUST be set in `package.json` and enforced by a tree-shake guard test in `tests/`. Vitest coverage thresholds MUST be set to 100/100/100/100 (statement / branch / function / line). No `.skip` or `.only` markers are permitted in `src/`.
### ENV-16 — Property-based test contracts
The following fast-check properties MUST pass:
- For all valid raw strings `s`, `coerce(s)` produces the same stable value on repeated calls (field coerce functions are pure)
- For any schema with at least one sensitive field, `maskSensitive(values, schema)` MUST NOT contain the original raw value at any sensitive key
- For any N failing rules, `validate` MUST return `errors.length === N`
- `parseDotenv(content)` MUST be pure: same `content` input always produces an equal output map
### ENV-17 — No browser or Node-specific dependencies
The package MUST run in Bun, Node, and browser environments. It MUST NOT import `fs`, `path`, `process`, or any Node-only built-in in any code path that does not go through `dotenvLoader`. `dotenvLoader` itself MAY use file-system access; all other modules MUST be environment-agnostic.

View File

@@ -0,0 +1,128 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/config/env.
# Tasks: enterprise-env
## 1. Scaffold
- [x] 1.1 Create `packages/universal/env/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
## 2. Constants + types + errors
- [x] 2.1 Implement `src/constants/index.ts` — `DEFAULT_ARRAY_SEPARATOR`, `EMAIL_PATTERN`, `FALSY_VALUES`, `INTERPOLATION_PATTERN`, `PORT_MAX`, `PORT_MIN`, `TRUTHY_VALUES`
- [x] 2.2 Implement `src/types/common.ts` — `AnyField`, `CreateEnvOptions`, `EnvError`, `EnvSchema`, `ErrorMode`, `InferEnv`, `InferFieldType`, `Loader`, `LoaderResult`, `OptionalKeys`, `RequiredKeys`, `Simplify`, `ValidationRule`
- [x] 2.3 Implement `src/errors/env-error.ts` — `EnvError` base extends `SuaveplanError`; `EnvValidationError`, `MissingEnvError`, `CoercionError`, `DotenvParseError` extend `EnvError`; `defineCodes` for `ENV.VALIDATION_FAILED`, `ENV.MISSING_REQUIRED`, `ENV.INVALID_FORMAT`, `ENV.PARSE_ERROR`
- [x] 2.4 Implement `src/errors/error-codes.ts` and auto-register via side-effect import in `src/index.ts`
- [x] 2.5 Zod schemas: `FieldConfigSchema`, `ValidationResultSchema`, `CreateEnvOptionsSchema`, `DotenvLoaderOptionsSchema`, `ObjectLoaderOptionsSchema` — exported from `*.schemas.ts` siblings
- [x] 2.6 Tests: error construction, code narrowing, `{ key, raw?, source? }` context fields on each error subclass
## 3. Core validator (validate + maskSensitive)
- [x] 3.1 Implement `src/core/validator/validator.types.ts` — `ValidateInput`, `ValidationResult`
- [x] 3.2 Implement `src/core/validator/validator.ts` — `validate(input): ValidationResult` (runs all `field._rules`, accumulates failures); `maskSensitive(values, schema): Record<string, string>` (replaces sensitive values with `***`)
- [x] 3.3 Unit tests: all rules passing, partial failure accumulation, all rules failing, sensitive masking
- [x] 3.4 Property-based tests (fast-check): if N rules fail, `result.errors.length === N`; `maskSensitive` never leaks a sensitive raw value
- [x] 3.5 Co-located `src/core/validator/validator.md`
## 4. BaseField abstract class
- [x] 4.1 Implement `src/core/field/field.types.ts` — `FieldConfig<T>`, `RuleOptions`
- [x] 4.2 Implement `src/core/field/field.ts` — `BaseField<T>` abstract class with `_isRequired`, `_hasDefault`, `_defaultValue`, `_isSensitive`, `_description`, `_rules`; fluent builder methods: `optional()`, `default(v)`, `sensitive()`, `describe(s)`, `rules(opts)`; abstract `coerce(raw: string): T`
- [x] 4.3 Unit tests: builder method chaining, `FieldConfig` snapshot, abstract coerce enforcement
- [x] 4.4 Co-located `src/core/field/field.md`
## 5. Fields
- [x] 5.1 `src/fields/string-field/string-field.ts` — `StringField` extends `BaseField<string>`; rules: `minLength`, `maxLength`, `pattern`, `nonempty`; factory `str()`
- [x] 5.2 `src/fields/number-field/number-field.ts` — `NumberField` extends `BaseField<number>`; coerces via `coerceNumber`; rules: `min`, `max`, `integer`; factory `num()`
- [x] 5.3 `src/fields/boolean-field/boolean-field.ts` — `BooleanField` extends `BaseField<boolean>`; coerces via `coerceBoolean`; factory `bool()`
- [x] 5.4 `src/fields/enum-field/enum-field.types.ts` — `EnumValues`; `src/fields/enum-field/enum-field.ts` — `EnumField<V extends EnumValues>` extends `BaseField<V[number]>`; factory `enums(values)`
- [x] 5.5 `src/fields/url-field/url-field.ts` — `UrlField` extends `BaseField<string>`; validates via `new URL()`; optional `protocols` allowlist rule; factory `url()`
- [x] 5.6 `src/fields/email-field/email-field.ts` — `EmailField` extends `BaseField<string>`; validates against `EMAIL_PATTERN`; factory `email()`
- [x] 5.7 `src/fields/port-field/port-field.ts` — `PortField` extends `BaseField<number>`; validates integer in `[PORT_MIN, PORT_MAX]`; factory `port()`
- [x] 5.8 `src/fields/json-field/json-field.types.ts` — `JsonValidator<T>`; `src/fields/json-field/json-field.ts` — `JsonField<T>` extends `BaseField<T>`; coerces via `coerceJson`; accepts optional `JsonValidator<T>`; factory `json<T>(validator?)`
- [x] 5.9 `src/fields/array-field/array-field.types.ts` — `ItemField<T>`; `src/fields/array-field/array-field.ts` — `ArrayField<T>` extends `BaseField<T[]>`; splits on `separator` (default `DEFAULT_ARRAY_SEPARATOR`), maps each item through `itemField`; factory `array(itemField, separator?)`
- [x] 5.10 Unit tests for each field: valid raw, invalid raw → `CoercionError`, all custom rules, default / optional / sensitive combinations
- [x] 5.11 Co-located `.md` for each field file
## 6. Transforms (coerce utilities)
- [x] 6.1 Implement `src/transforms/coerce/coerce.types.ts` — `CoerceFn<T>`, `CoerceEntry<T>`
- [x] 6.2 Implement `src/transforms/coerce/coerce.ts` — `coerceString`, `coerceNumber`, `coerceInteger`, `coerceBoolean`, `coerceArray`, `coerceJson`; each throws `CoercionError` with `ENV.INVALID_FORMAT` on failure
- [x] 6.3 Unit tests: happy-path and all rejection branches for each coerce function
- [x] 6.4 Property-based tests (fast-check): `coerceNumber(raw)` round-trips for all finite float strings; `coerceBoolean` is total over TRUTHY_VALUES ∪ FALSY_VALUES
- [x] 6.5 Co-located `src/transforms/coerce/coerce.md`
## 7. Loaders
- [x] 7.1 `src/loaders/object/object.ts` — `objectLoader(obj, options?)` → `Loader`; `ObjectLoaderOptions`; source label defaults to `'object'`
- [x] 7.2 `src/loaders/dotenv/dotenv.ts` — `parseDotenv(content): Record<string, string>` (pure, throws `DotenvParseError` on malformed lines); `dotenvLoader(options?)` → `Loader` reads file from `options.path` (default `.env`), calls `parseDotenv`; `DotenvLoaderOptions`
- [x] 7.3 Unit tests for `objectLoader`: wraps object, respects source label
- [x] 7.4 Unit tests for `parseDotenv`: quoted values, comments, blank lines, `=` in value, malformed input
- [x] 7.5 Unit tests for `dotenvLoader`: reads real temp file, file-not-found returns empty gracefully (or throws — match main-branch behaviour), `override` flag
- [x] 7.6 Property-based tests (fast-check): `parseDotenv` is pure — same input always produces same output
- [x] 7.7 Co-located `.md` for each loader file
## 8. Utilities — interpolation + error factory
- [x] 8.1 `src/utils/interpolation.ts` — `interpolate(template, values)`, `hasInterpolation(value)`, `extractVariables(template)`; uses `INTERPOLATION_PATTERN`
- [x] 8.2 `src/utils/errors.ts` — `createEnvError(key, message, value?)` factory returning `EnvError` value object (not the class — the plain interface shape used by `CollectedErrors`)
- [x] 8.3 Unit tests for all interpolation functions and the error factory
- [x] 8.4 Co-located `.md` for each utility file
## 9. createEnv orchestrator
- [x] 9.1 Implement `src/core/create-env/create-env.types.ts` — `CollectedErrors`, `EnvResult<S>`
- [x] 9.2 Implement `src/core/create-env/create-env.ts` — `createEnv(schema, options?)`:
- Runs each `Loader` in `options.loaders` in order, merging `LoaderResult.values` (later loaders win unless `override: false`)
- Expands `${VAR}` interpolation tokens in raw values
- For each schema key: resolves raw string → calls `coerce` → runs `validate` → checks `isRequired`
- In `'throw'` mode: throws `EnvValidationError` or `MissingEnvError` on first failure
- In `'collect'` mode: accumulates all failures into `CollectedErrors`, returns partial result
- In `'warn'` mode: logs via `console.warn`, returns partial result
- Calls `maskSensitive` before returning
- Returns `EnvResult<S>` (typed record)
- [x] 9.3 Unit tests: full schema resolution, missing required, coercion error, collect mode accumulation, throw mode, warn mode, multi-loader merge order, interpolation expansion
- [x] 9.4 Co-located `src/core/create-env/create-env.md`
## 10. Presets
- [x] 10.1 `src/presets/common/common.ts` — `nodeEnvField`, `portField`, `hostField`, `logLevelField`, `debugField`, `appNameField`, `appVersionField`, `timezoneField`, `workersField`, `commonFields`
- [x] 10.2 `src/presets/server/server.ts` — `databaseUrlField`, `redisUrlField`, `sessionSecretField`, `corsOriginField`, `rateLimitMaxField`, `rateLimitWindowField`, `trustProxyField`, `smtpHostField`, `smtpPortField`, `smtpUserField`, `smtpPassField`, `smtpFromField`, `serverFields`
- [x] 10.3 Unit tests: each preset field resolves correctly from a matching env object; `commonFields` and `serverFields` composites contain all expected keys
- [x] 10.4 Co-located `.md` for each preset file
## 11. OTel instrumentation
- [x] 11.1 Add `@opentelemetry/api` to `dependencies` in `package.json`
- [x] 11.2 Wire counter `env.validation.total` (label `outcome: 'success' | 'error'`) in `createEnv` — incremented once per call
- [x] 11.3 Wire counter `env.validation.errors` (label `field`) in `createEnv` — incremented once per field that fails validation
- [x] 11.4 Wire histogram `env.load.duration_ms` — duration of the full `createEnv` call
- [x] 11.5 All meters obtained from `@opentelemetry/api` `getMeter`; no-op without exporter (verified in test with no SDK registered)
- [x] 11.6 Unit test: metrics instruments are created without error when no exporter is present
## 12. Property tests + benchmarks
- [x] 12.1 Property-based tests (fast-check) for field parsers round-trip (valid raw → coerce → stable value)
- [x] 12.2 Property test: `maskSensitive` never leaks raw values for sensitive fields
- [x] 12.3 Property test: `validate` accumulates all N failing rules
- [x] 12.4 Property test: `parseDotenv` is pure and idempotent
- [x] 12.5 `src/__benchmarks__/create-env.bench.ts` — `createEnv` with 20-field schema, `objectLoader`, no errors; throughput via mitata
- [x] 12.6 `src/__benchmarks__/validate.bench.ts` — `validate` hot path: single field with 5 passing rules; iterations via mitata
- [x] 12.7 Publish baseline numbers in README
## 13. Docs
- [x] 13.1 Co-located `.md` for every implementation file (≥200 words, simple + advanced examples, API reference, implementation notes)
- [x] 13.2 Rewrite `README.md` with public API table, loader usage, preset reference, error code table, OTel metric names, benchmark numbers, consumer quick-start
- [x] 13.3 `bun run docs:check --filter=@suaveplan/env` ≥ 90%
## 14. Verify and publish
- [x] 14.1 `bunx turbo lint typecheck test --filter=@suaveplan/env` green
- [x] 14.2 `bunx turbo test:coverage --filter=@suaveplan/env` at 100/100/100/100
- [x] 14.3 `bunx turbo build --filter=@suaveplan/env` green
- [x] 14.4 `bun scripts/publish.ts --package @suaveplan/env` to Verdaccio at 0.0.0
- [x] 14.5 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-env/`

View File

@@ -0,0 +1,179 @@
# Design: enterprise-error
## Module layout
```text
packages/universal/error/
├── package.json # deps: tslib, zod. devDeps: vitest, fast-check, testing
├── tsconfig.json / tsconfig.lib.json
├── vite.config.ts # library build (esm + dts)
├── vitest.config.ts # thresholds 100/100/100/100, istanbul provider
├── README.md
├── src/
│ ├── index.ts # barrel — public API
│ ├── constants/
│ │ └── index.ts # MAX_CAUSE_DEPTH, REDACTED_LABEL
│ ├── base/
│ │ ├── suaveplan-error.ts # SuaveplanError<TCode> abstract class
│ │ ├── suaveplan-error.md
│ │ ├── suaveplan-error.test.ts
│ │ └── suaveplan-error.types.ts # options, wire shape types
│ ├── codes/
│ │ ├── define-codes.ts # defineCodes<NS, R[]>() factory
│ │ ├── define-codes.md
│ │ └── define-codes.test.ts
│ ├── causes/
│ │ ├── walk-causes.ts # generator, cycle-safe, depth-bounded
│ │ ├── walk-causes.md
│ │ └── walk-causes.test.ts
│ ├── serialize/
│ │ ├── to-json.ts
│ │ ├── to-json.md
│ │ ├── from-json.ts
│ │ ├── from-json.md
│ │ ├── to-binary.ts # MessagePack-compatible encoder
│ │ ├── to-binary.md
│ │ ├── from-binary.ts
│ │ ├── from-binary.md
│ │ ├── wire.schemas.ts # Zod schemas (opt-in import)
│ │ └── serialize.test.ts # cross-cutting round-trip tests
│ ├── redact/
│ │ ├── sensitive.ts # markSensitive / unwrapSensitive
│ │ ├── sensitive.md
│ │ └── sensitive.test.ts
│ ├── otel/
│ │ ├── to-span-event.ts
│ │ ├── to-span-event.md
│ │ └── to-span-event.test.ts
│ ├── i18n/
│ │ ├── resolver.ts # setMessageResolver, resolveMessage
│ │ ├── resolver.md
│ │ └── resolver.test.ts
│ └── __benchmarks__/
│ └── serialize.bench.ts
├── tests/
│ ├── setup/polyfills.ts
│ ├── dep-graph.test.ts
│ ├── no-disallowed.test.ts # grep guard for node:/Buffer/window/etc.
│ └── tree-shake.test.ts
└── CHANGELOG.md
```
## Key design decisions
### Generic `code` parameter on `SuaveplanError`
```ts
export abstract class SuaveplanError<TCode extends string = string> extends Error {
public readonly code: TCode;
public readonly context?: Readonly<Record<string, unknown>>;
public readonly timestamp: number;
public readonly traceId?: string;
// ...
}
```
Subclasses narrow `TCode` so `switch (err.code)` gets exhaustiveness checking without manual casts. Default `string` keeps the base usable when a consumer doesn't care about discriminants.
### `defineCodes` returns a frozen literal-typed record
```ts
export function defineCodes<NS extends string, R extends readonly string[]>(
ns: NS,
reasons: R
): { readonly [K in R[number]]: `${NS}.${K}` } {
const out = Object.create(null);
for (const r of reasons) out[r] = `${ns}.${r}`;
return Object.freeze(out);
}
```
Return type is inferred structurally — no manual enum declarations, no drift between the type and the runtime value.
### Cause-chain traversal
`walkCauses` is a generator. The cycle guard uses a `WeakSet<object>` seeded with the root; non-object causes (strings, numbers) are allowed but not tracked. `maxDepth` is inclusive of the root and defaults to 32.
### Wire format
JSON shape:
```ts
{
__suaveplanError: 1, // format version
code: string,
message: string,
context?: Record<string, unknown>, // redactions applied
timestamp: number,
traceId?: string,
stack?: string,
cause?: WireError, // recursive
}
```
Binary format: a deterministic MessagePack-compatible encoding implemented in `to-binary.ts`. Only the subset needed for the wire shape is implemented (`nil`, `bool`, `int` up to 53 bits, `float64`, `str`, `bin`, `array`, `map`). No external msgpack dependency.
### Redaction
`markSensitive(value)` returns `{ __sensitive: true, value }`. At serialization time, `toJSON`/`toBinary` replace tagged values with the string `"[REDACTED]"`. `unwrapSensitive` returns the original value for in-memory access.
### Deterministic clock
Every constructor accepts `{ clock?: () => number }`. The default module-level reference is a thin wrapper — `const defaultClock = () => Date.now()` — that lives in one place and can be swapped with property mocking in tests. No other file reads `Date.now()` directly.
### i18n hook
Module-level state: `let resolver: MessageResolver | undefined`. `setMessageResolver(fn)` installs one; `resolveMessage(key, params?)` calls it or returns the key verbatim. No `@suaveplan/i18n` dependency — that package will register a resolver at app bootstrap.
## Coverage strategy
Thresholds locked at 100 for every metric. Testing approach:
1. **Enumeration tests** for every branch (normal + error paths)
2. **Property-based tests** via fast-check for round-trip invariants (JSON, binary, cause-chain)
3. **No `istanbul ignore` pragmas** — dead branches are refactored away (use `as` assertions with invariant comments when type-narrowing is the only motivation for a defensive check)
4. **Cross-cutting guards in `tests/`** for dep-graph, tree-shake, and no-disallowed-module-references
## Public exports (initial surface)
```ts
// base
export { SuaveplanError } from "./base/suaveplan-error.js";
export type { SuaveplanErrorOptions, ErrorContext } from "./base/suaveplan-error.types.js";
// codes
export { defineCodes } from "./codes/define-codes.js";
// causes
export { walkCauses } from "./causes/walk-causes.js";
// serialize
export { toJSON } from "./serialize/to-json.js";
export { fromJSON } from "./serialize/from-json.js";
export { toBinary } from "./serialize/to-binary.js";
export { fromBinary } from "./serialize/from-binary.js";
export type { WireError } from "./serialize/wire.schemas.js";
// redact
export { markSensitive, unwrapSensitive, isSensitive } from "./redact/sensitive.js";
// otel
export { toSpanEvent } from "./otel/to-span-event.js";
export type { SpanEvent } from "./otel/to-span-event.js";
// i18n
export { setMessageResolver, resolveMessage, clearMessageResolver } from "./i18n/resolver.js";
// package-local error subclass for this package's own failure modes
export { ErrorPackageError } from "./base/package-error.js";
export { ERROR_CODES } from "./codes/error-codes.js";
```
`ErrorPackageError` is the one concrete subclass the package ships for its own internal failures (e.g. `ERROR.WIRE.MALFORMED`, `ERROR.CAUSE.CYCLE_DETECTED`). Every other package will extend `SuaveplanError` directly with its own codes.
## Non-goals
- Not implementing a full MessagePack codec — only the subset needed for wire payloads
- Not shipping an i18n catalog — only the resolver hook
- Not shipping an OTel SDK — only the span-event adapter shape
- Not a logger — `toJSON` produces a shape that `@suaveplan/logger` can consume later

View File

@@ -0,0 +1,37 @@
# Change: enterprise-error
## Why
`@suaveplan/error` is the **foundation error substrate** — every other package in the monorepo extends `SuaveplanError` and uses its stable code registry. On the genesis branch it does not yet exist. It must land **before** any other Layer 1 package (events, crypto, bitpacker, timing, datetime, serialize, mime, env, opentelemetry) because each of them declares its own error subclass against this base.
Per the project rule for foundation packages (`memory: foundation-100-coverage`), this ships at **100%** coverage (statements / branches / functions / lines) — not the 95% floor. Anything less puts a long-term ceiling on every consumer.
## What Changes
### New package: `packages/universal/error`
Ships a self-contained, zero-dependency (no `@suaveplan/*`, no runtime browser/Node/React APIs) TypeScript library:
- **`SuaveplanError`** abstract base class with `{ code, message, cause?, context?, timestamp, traceId? }` shape
- **Stable code registry** — namespaced `PACKAGE.DOMAIN.REASON` strings, typed as a const record; each package adds its own leaf registry that extends this one without renumbering
- **Typed error discriminants** — generics propagate the code union through `instanceof` checks so consumers get exhaustive switch coverage without casts
- **Cause-chain traversal** — `walkCauses(err)` generator, cycle-safe, depth-bounded
- **Wire format** — Zod schema for JSON and MessagePack (MessagePack encoder ships as a pure function, no external `msgpack` dep; binary format is documented and versioned)
- **Redaction** — `@sensitive` tag on context fields, applied at the serialization boundary; redacted fields become `"[REDACTED]"` in the wire payload but remain accessible on the in-memory instance
- **Injectable clock** — `createError({ clock })` takes an optional clock so timestamps are deterministic under test; the module-level default uses `Date.now()` but is never touched at import time
- **OTel span event adapter** — `toSpanEvent(err)` returns the payload an OTel span recorder expects (`exception.type`, `exception.message`, `exception.stacktrace`, all attribute values serialized)
- **i18n-ready** — every user-visible string is keyed, never literal; the default locale is pass-through (returns the key)
### Why this stays foundation-pure
The enterprise brief calls for `@suaveplan/schema-registry`, `@suaveplan/logger`, `@suaveplan/i18n`, and `@suaveplan/opentelemetry` integration. Those are **adapters**, not dependencies — this package exposes the hooks (`toSpanEvent`, i18n-key strings, Zod schemas, logger-compatible `toJSON`) but does not import any of them. Wiring happens in the dependent packages.
### Coverage gate
Vitest thresholds set to 100/100/100/100 from day one. No `istanbul ignore` pragmas (empirically stripped by the esbuild transform). Dead defensive branches are either removed or upgraded to `as` assertions with invariant comments.
## Impact
- Unblocks every other Layer 1 package (events, crypto, bitpacker, timing, datetime, serialize, mime, env, opentelemetry) which extend `SuaveplanError`
- Adds one new spec capability: `error`
- No existing code paths affected (genesis branch has no consumers yet)

View File

@@ -0,0 +1,142 @@
## ADDED Requirements
### Requirement: Zero workspace and runtime dependencies
The `@suaveplan/error` package `dependencies`, `peerDependencies`, and `devDependencies` MUST NOT list any `@suaveplan/*` scoped package. Runtime dependencies MUST be limited to `tslib` and `zod`; all other external packages live in `devDependencies`.
#### Scenario: Dep-graph guard
- **WHEN** `bunx turbo ls --filter=@suaveplan/error...` is run
- **THEN** no `@suaveplan/*` package appears in the forward closure of `@suaveplan/error`
#### Scenario: No Node / browser built-ins
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:url`, `process`, `Buffer`, `document`, `window`, or any DOM / Node-only API
### Requirement: SuaveplanError base class
The package SHALL export an abstract `SuaveplanError` class extending the built-in `Error`, carrying `{ code, message, cause?, context?, timestamp, traceId? }`. `code` SHALL be a string of the form `PACKAGE.DOMAIN.REASON` (plain uppercase ASCII, dots as separators). The `code` field is generic so subclasses can narrow the code union.
#### Scenario: Subclass narrows code union
- **WHEN** a consumer defines `class MyError extends SuaveplanError<"MY.X.Y" | "MY.X.Z">`
- **THEN** TypeScript infers `err.code` as `"MY.X.Y" | "MY.X.Z"` (not `string`) inside `instanceof MyError` branches
#### Scenario: Stack trace preserved
- **WHEN** a `SuaveplanError` subclass is thrown
- **THEN** `err.stack` contains the subclass name (not `Error`) AND the originating call site
#### Scenario: Timestamp is injectable
- **WHEN** a subclass is constructed with `{ clock: () => 1234 }`
- **THEN** `err.timestamp === 1234`
#### Scenario: Default timestamp uses Date.now
- **WHEN** a subclass is constructed with no `clock` option
- **THEN** `err.timestamp` equals the `Date.now()` reading at construction time
### Requirement: Stable error-code registry helper
The package SHALL export a `defineCodes<TNamespace, TReasons>()` helper that returns a frozen `Record<TReasons, \`${TNamespace}.${TReasons}\`>` with full literal-type inference. Rebuilding the registry MUST preserve existing codes; removal requires a `@deprecated` marker that emits a TypeScript deprecation warning at call sites.
#### Scenario: Registry is frozen
- **WHEN** a consumer attempts `codes.REASON = "X.Y.Z"`
- **THEN** the assignment throws in strict mode (object is frozen)
#### Scenario: Code values are literal-typed
- **WHEN** a consumer writes `const codes = defineCodes("PKG", ["A", "B"] as const)`
- **THEN** `codes.A` has type `"PKG.A"` (not `string`)
### Requirement: Cause-chain traversal is cycle-safe and depth-bounded
The package SHALL export `walkCauses(err, { maxDepth?: number })` as a generator yielding each error in the chain. Default `maxDepth` is 32. Cycles MUST be detected via a `WeakSet` and terminate iteration without throwing.
#### Scenario: Linear chain fully traversed
- **WHEN** `a.cause = b; b.cause = c; c.cause = d`
- **THEN** `Array.from(walkCauses(a))` returns `[a, b, c, d]`
#### Scenario: Cycle terminates iteration
- **WHEN** `a.cause = b; b.cause = a` and `walkCauses(a)` is consumed
- **THEN** iteration yields `a` then `b` and ends (does not throw, does not loop forever)
#### Scenario: Depth cap enforced
- **WHEN** a chain of 100 errors is walked with `{ maxDepth: 10 }`
- **THEN** exactly 10 errors are yielded
### Requirement: Lossless wire serialization
The package SHALL export `toJSON(err)` and `fromJSON(value)` producing a Zod-validated wire payload. Round-tripping a `SuaveplanError` subclass through JSON MUST preserve `code`, `message`, `context` (redactions applied), `timestamp`, `traceId`, and the cause chain (up to `maxDepth`). The binary form (`toBinary` / `fromBinary`) is a deterministic UTF-8 encoding of the canonicalised JSON form (no external encoder dep); both halves of a round-trip produce structurally identical output.
#### Scenario: JSON round-trip preserves all fields
- **WHEN** an error with `{ code, message, context, cause }` is serialized via `toJSON` and parsed back via `fromJSON`
- **THEN** the reconstructed error is `instanceof SuaveplanError` AND every field is structurally equal to the original (redacted fields excepted)
#### Scenario: Binary round-trip matches JSON round-trip
- **WHEN** the same error is passed through `toBinary`/`fromBinary`
- **THEN** the resulting error is structurally equal to the JSON round-trip output
#### Scenario: Zod rejects malformed wire payload
- **WHEN** `fromJSON({ code: 123 })` is called (wrong type)
- **THEN** it throws a `SuaveplanError` subclass with `code === "ERROR.WIRE.MALFORMED"` AND `cause instanceof ZodError`
### Requirement: Redaction at the serialization boundary
The package SHALL export `markSensitive(value)` producing a tagged wrapper. During `toJSON` / `toBinary`, tagged values MUST be replaced with the literal string `"[REDACTED]"`. In-memory access to the original value via `err.context` is UNAFFECTED.
#### Scenario: Sensitive field redacted on the wire
- **WHEN** `err.context.token = markSensitive("abc")` and `toJSON(err)` runs
- **THEN** the serialized payload contains `context.token === "[REDACTED]"` (string)
#### Scenario: In-memory value remains accessible
- **WHEN** `err.context.token = markSensitive("abc")` (unserialized)
- **THEN** `unwrapSensitive(err.context.token) === "abc"`
### Requirement: OTel span event adapter
The package SHALL export `toSpanEvent(err)` returning `{ name: "exception", attributes: { "exception.type", "exception.message", "exception.stacktrace", "exception.code", "exception.traceId"? } }` with all values already string-serialized. No OpenTelemetry SDK is imported.
#### Scenario: Span event matches OTel spec
- **WHEN** `toSpanEvent(err)` is called
- **THEN** the returned `name === "exception"` AND `attributes["exception.type"] === err.constructor.name` AND `attributes["exception.message"] === err.message` AND `attributes["exception.stacktrace"] === err.stack ?? ""` AND `attributes["exception.code"] === err.code`
### Requirement: i18n-ready messages
No user-visible string literal SHALL be embedded in a `throw` site. Subclasses MUST instead reference an i18n key (of the form `error.<package>.<domain>.<reason>`) which the default implementation renders by returning the key itself. Consumers can plug in a translator via `setMessageResolver(fn)`.
#### Scenario: Default resolver returns the key
- **WHEN** no resolver has been set and a subclass formats `"error.pkg.domain.reason"`
- **THEN** the rendered message equals `"error.pkg.domain.reason"` verbatim
#### Scenario: Custom resolver applied
- **WHEN** `setMessageResolver((key, params) => translations[key](params))` has been set
- **THEN** subclass construction calls the resolver and stores the returned string on `err.message`
### Requirement: Test coverage at 100%
The package MUST maintain `statements: 100`, `branches: 100`, `functions: 100`, `lines: 100` thresholds in `vitest.config.ts`. No `istanbul ignore` pragmas are permitted (they do not survive the esbuild transform). Defensive code whose false branch is unreachable MUST be rewritten as an `as` assertion with an invariant comment rather than a runtime `if`.
#### Scenario: Coverage thresholds enforced
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/error` runs
- **THEN** it exits non-zero if any of statements / branches / functions / lines drops below 100
#### Scenario: Property-based round-trip
- **WHEN** fast-check generates arbitrary `SuaveplanError` subclass instances
- **THEN** every instance survives `toJSON` → `fromJSON` structurally equal (modulo redactions) AND every instance survives `toBinary` → `fromBinary` structurally equal

View File

@@ -0,0 +1,65 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/observability/error.
# Tasks: enterprise-error
## 1. Scaffold
- [x] 1.1 Create `packages/universal/error/` with package.json, tsconfig.json, tsconfig.lib.json, vite.config.ts, vitest.config.ts, README.md skeleton
- [x] 1.2 Register thresholds at 100/100/100/100 in vitest.config; coverage provider = istanbul
- [x] 1.3 Add the three cross-cutting guards in `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 1.4 Add package to `STRUCTURE.yml`, root `README.md` count, `CLAUDE.md` listing
## 2. Base class + codes
- [x] 2.1 Implement `SuaveplanError<TCode>` with clock injection, context, traceId, timestamp
- [x] 2.2 Implement `defineCodes(ns, reasons)` with literal-type inference and freeze
- [x] 2.3 Implement package-local `ERROR_CODES` registry and `ErrorPackageError` subclass
- [x] 2.4 Tests for base + codes including subclass code narrowing
## 3. Cause chain
- [x] 3.1 Implement `walkCauses` generator — cycle-safe (WeakSet), depth-bounded, non-object causes allowed
- [x] 3.2 Tests for linear chains, self-cycle, mutual-cycle, depth cap, non-Error causes
## 4. Serialization
- [x] 4.1 Implement `toJSON` — applies redaction, walks cause chain, emits `__suaveplanError: 1` version field
- [x] 4.2 Implement `fromJSON` — Zod-validated, reconstructs via `ErrorPackageError` fallback when the subclass can't be resolved
- [x] 4.3 Implement `toBinary` — in-file MessagePack encoder covering nil/bool/int/float64/str/bin/array/map
- [x] 4.4 Implement `fromBinary` — paired decoder
- [x] 4.5 Property-based tests (fast-check): JSON round-trip, binary round-trip, binary-bytes-match-JSON-structure, cycle-safe serialization
## 5. Redaction
- [x] 5.1 Implement `markSensitive`, `unwrapSensitive`, `isSensitive`
- [x] 5.2 Wire redaction application inside `toJSON` / `toBinary`
- [x] 5.3 Tests: in-memory access, wire redaction, nested sensitive values
## 6. OTel adapter
- [x] 6.1 Implement `toSpanEvent(err)` with OTel-compliant attribute shape
- [x] 6.2 Tests: attribute names, stack fallback, code propagation, traceId propagation
## 7. i18n resolver hook
- [x] 7.1 Implement `setMessageResolver`, `resolveMessage`, `clearMessageResolver`
- [x] 7.2 Tests: default pass-through, resolver wiring, params interpolation, clear resets to default
## 8. Docs
- [x] 8.1 Co-located `.md` for every implementation file (≥200 words, simple + advanced example, full API reference)
- [x] 8.2 Rewrite `README.md` with full public API overview, design rationale, and consumer guide
- [x] 8.3 Run `bun run docs:check --filter=@suaveplan/error` — verify ≥90% coverage
## 9. Benchmarks
- [x] 9.1 Add `src/__benchmarks__/serialize.bench.ts` (mitata): toJSON, fromJSON, toBinary, fromBinary throughput
- [x] 9.2 Publish baseline numbers in README
## 10. Verify and publish
- [x] 10.1 `bunx turbo lint typecheck test --filter=@suaveplan/error` green
- [x] 10.2 `bunx turbo test:coverage --filter=@suaveplan/error` at 100/100/100/100
- [x] 10.3 `bunx turbo build --filter=@suaveplan/error` green
- [x] 10.4 `bun scripts/publish.ts --package @suaveplan/error` to Verdaccio at 0.0.0
- [x] 10.5 Archive OpenSpec change folder to `openspec/archive/<yyyy-mm-dd>-enterprise-error/`

View File

@@ -0,0 +1,176 @@
# Design: enterprise-events
## Module layout
```text
packages/universal/events/
├── package.json # deps: @suaveplan/error, tslib, zod
├── tsconfig.lib.json
├── vite.config.ts
├── vitest.config.ts # thresholds 100/100/100/100
├── README.md
├── src/
│ ├── index.ts # barrel
│ ├── constants/index.ts
│ ├── errors/
│ │ ├── codes.ts
│ │ ├── events-error.ts
│ │ └── events-error.test.ts
│ ├── envelope/
│ │ ├── envelope.ts # wrap(type, payload, options)
│ │ ├── envelope.md
│ │ ├── envelope.test.ts
│ │ ├── envelope.types.ts
│ │ └── envelope.schemas.ts # Zod (opt-in)
│ ├── id/
│ │ ├── id-generator.ts
│ │ ├── id-generator.md
│ │ └── id-generator.test.ts
│ ├── clock/
│ │ ├── default-clock.ts
│ │ ├── default-clock.md
│ │ └── default-clock.test.ts
│ ├── bus/
│ │ ├── event-bus.ts # EventBus<TMap> class
│ │ ├── event-bus.md
│ │ ├── event-bus.test.ts
│ │ └── event-bus.types.ts
│ ├── interceptor/
│ │ ├── compose.ts # compose interceptors into a chain
│ │ ├── compose.md
│ │ └── compose.test.ts
│ ├── queue/
│ │ ├── bounded-queue.ts
│ │ ├── bounded-queue.md
│ │ └── bounded-queue.test.ts
│ ├── idempotent/
│ │ ├── idempotent.ts
│ │ ├── idempotent.md
│ │ └── idempotent.test.ts
│ ├── observability/
│ │ ├── with-observability.ts
│ │ ├── with-observability.md
│ │ └── with-observability.test.ts
│ └── __benchmarks__/
│ └── dispatch.bench.ts
├── tests/
│ ├── setup/polyfills.ts
│ ├── dep-graph.test.ts
│ ├── no-disallowed.test.ts
│ └── tree-shake.test.ts
└── CHANGELOG.md
```
## Key design decisions
### Typed `EventBus<TMap>`
```ts
type EventMap = Record<string, unknown>;
class EventBus<TMap extends EventMap> {
on<K extends keyof TMap>(type: K, listener: Listener<TMap[K]>): Unsubscribe;
once<K extends keyof TMap>(type: K, listener: Listener<TMap[K]>): Unsubscribe;
off<K extends keyof TMap>(type: K, listener: Listener<TMap[K]>): void;
emit<K extends keyof TMap>(type: K, payload: TMap[K]): EmitResult;
emitAsync<K extends keyof TMap>(
type: K,
payload: TMap[K],
options?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<EmitResult>;
use(interceptor: Interceptor<TMap>): Unsubscribe;
clear(): void;
}
```
`EmitResult`: `{ delivered: number; errors: readonly EventsError[] }`. Sync `emit` collects listener errors; async collects + surfaces as `errors[]` (never throws mid-dispatch; interceptors CAN throw to halt).
### Interceptor chain
Interceptors run in registration order; last-registered is innermost. Signature:
```ts
type Interceptor<TMap> = <K extends keyof TMap>(
envelope: EventEnvelope<TMap[K]>,
next: () => Promise<EmitResult>
) => Promise<EmitResult>;
```
The bus composes them with `compose(interceptors)` into a single function. Calling `next()` advances; not calling it drops the event (no listeners fire). Throwing aborts the emit and propagates the rejection.
### BoundedQueue
Independent of the bus — used internally when `emitAsync` is called with `{ queue: true }` and externally by consumers who want the same backpressure primitive.
```ts
interface BoundedQueueOptions {
capacity: number;
policy: "drop-oldest" | "drop-newest" | "block" | "throw";
}
class BoundedQueue<T> {
enqueue(item: T, signal?: AbortSignal): Promise<void>;
dequeue(): T | undefined;
drain(): T[];
get size(): number;
}
```
`block` waits for room (Promise resolves when dequeued). `drop-oldest` evicts the head. `drop-newest` rejects the new enqueue silently. `throw` throws `EventsError("EVENTS.QUEUE.OVERFLOW")`. `enqueue` with a signal that aborts rejects with `EventsError("EVENTS.QUEUE.ABORTED")`.
### `idempotent`
Wraps a listener so a given key only fires once. Default cache is a `Map<string, true>`; consumers can pass `{ cache: LRUCache }` to bound growth.
```ts
function idempotent<T>(
listener: Listener<T>,
options: { keyOf: (payload: T) => string; cache?: IdempotencyCache }
): Listener<T>;
```
### `withObservability`
Not an interceptor — a passive adapter that receives hooks. Counters/histograms/spans live in `events-metrics` / `events-otel`; this package exposes only the hook shape.
### Determinism
Two single-location module references:
```ts
// clock/default-clock.ts
export const defaultClock = (): number => Date.now();
// id/id-generator.ts
export const defaultIdGenerator = (): string => crypto.randomUUID();
```
Every other file accepts an injection. The two defaults are the only places `Date.now()` / `crypto.randomUUID()` appear in `src/`.
### Error codes
```ts
export const EVENTS_CODES = defineCodes("EVENTS", [
"LISTENER.THREW",
"INTERCEPTOR.THREW",
"QUEUE.OVERFLOW",
"QUEUE.ABORTED",
"QUEUE.INVALID_CAPACITY",
"SCHEMA.REJECTED",
"EMIT.TIMED_OUT",
"EMIT.ABORTED",
] as const);
```
## Coverage strategy
- Enumeration tests for every branch
- Property-based (fast-check) for: ordering invariants, interceptor composition (associativity), queue policy state machines, idempotency
- No `istanbul ignore` pragmas — defensive branches removed or rewritten as `as` assertions with invariant comments
- Module-level determinism enforced by `tests/no-disallowed.test.ts` (greps for `Math.random` / `Date.now` / `crypto.randomUUID` outside the two approved files)
## Non-goals
- Not shipping an OTel SDK — `withObservability` produces the hook shape; `events-otel` will implement the adapter
- Not shipping a transport — `events-transport` plugs in as an interceptor
- Not shipping metrics — `events-metrics` plugs in via `withObservability`
- Not shipping a schema registry — `events-schema` plugs in as an interceptor

View File

@@ -0,0 +1,59 @@
# Change: enterprise-events
## Why
`@suaveplan/events` is the foundation pub/sub substrate — typed event bus, deterministic ordering, backpressure-aware async dispatch, and a public companion/interceptor API that the sibling packages (`events-metrics`, `events-otel`, `events-schema`, `events-transport`) plug into without monkey-patching. Every domain-event-emitting package will depend on it.
Per the foundation-100-coverage rule (see memory `foundation-100-coverage`), this ships at **100%** coverage from day one.
## What Changes
### New package: `packages/universal/events`
Ships as a focused, zero-Node / zero-browser / zero-React TypeScript library. Runtime deps limited to `@suaveplan/error`, `tslib`, and `zod`.
#### Core surface
- **`EventBus<TMap>`** — typed bus parameterized by an event-name → payload map. Methods: `on`, `once`, `off`, `emit` (sync), `emitAsync` (async with AbortSignal), `use` (interceptor), `clear`
- **`EventEnvelope<T>`** — `{ id, type, payload, timestamp, traceId? }`, produced by `wrap(type, payload, options?)`
- **`createIdGenerator()`** — deterministic-under-injection id generator (default: `crypto.randomUUID()` via a single module-local reference that tests can swap)
- **`Interceptor<TMap>`** — `(envelope, next) => R | Promise<R>` chain-of-responsibility. Registration order defines execution order; `next()` advances the chain; interceptors can replace, drop (by not calling `next`), or observe
- **`BoundedQueue<T>`** — in-memory FIFO with `{ capacity, policy: "drop-oldest" | "drop-newest" | "block" | "throw" }`, used internally by `emitAsync` under backpressure and exposed for consumers
- **`idempotent(fn, { keyOf, cache? })`** — wraps a handler so a given `keyOf(envelope)` only fires once; the cache is a `Map` by default, pluggable
#### Error surface
- **`EventsError`** extends `SuaveplanError<EventsCode>` with codes like `EVENTS.LISTENER.THREW`, `EVENTS.QUEUE.OVERFLOW`, `EVENTS.QUEUE.ABORTED`, `EVENTS.SCHEMA.REJECTED`, `EVENTS.INTERCEPTOR.THREW`
- Errors capture `{ type, envelopeId, listenerIndex?, queueSize? }` in `context` and preserve the original `cause`
#### Schemas
- **`EventEnvelopeSchemas`** (`.schemas.ts`) — Zod schema for the envelope shape with generic payload; consumers opt in by importing `@suaveplan/events/envelope.schemas`
#### Observability hook
- **`withObservability(bus, adapter)`** — wraps an `EventBus` with a passive adapter implementing `{ onEmit?, onEmitError?, onEmitComplete?, onListenerThrew? }`; `events-metrics` / `events-otel` register here
#### Companion/interceptor API
- `bus.use(interceptor)` is the one public extension point; `events-schema` validates envelopes via an interceptor, `events-transport` forwards to an external sink via an interceptor, `events-metrics` times via an interceptor, `events-otel` creates spans via an interceptor. The core ships no direct coupling to any of those.
### Determinism
- No `Math.random()` / `Date.now()` at module scope
- `createBus({ clock, idGenerator })` accepts injections; defaults are the thin wrappers `defaultClock = () => Date.now()` and `defaultIdGenerator = () => crypto.randomUUID()`
### Coverage
Vitest thresholds locked at 100/100/100/100 in `vitest.config.ts`. Property-based tests (fast-check) cover:
- dispatch ordering (FIFO across listeners and across emits)
- interceptor composition (associativity, drop-propagation)
- backpressure policies (bounded queue invariants)
- idempotency (given key-collisions, handler called exactly once)
## Impact
- Unblocks `events-metrics`, `events-otel`, `events-schema`, `events-transport` (each a Layer 2 adapter on top of `@suaveplan/events`)
- Depends on `@suaveplan/error` (already published) for `SuaveplanError` and `defineCodes`
- No existing genesis consumers affected

View File

@@ -0,0 +1,146 @@
## ADDED Requirements
### Requirement: Zero-framework runtime dependencies
The `@suaveplan/events` package's runtime `dependencies` MUST be limited to `@suaveplan/error`, `tslib`, and `zod`. No `peerDependencies`; no other `@suaveplan/*` packages at runtime.
#### Scenario: Dep-graph guard
- **WHEN** `dependencies` is read from `package.json`
- **THEN** the set equals `{ @suaveplan/error, tslib, zod }`
#### Scenario: No Node / browser built-ins leak
- **WHEN** `src/` is scanned for `node:*`, `process`, `Buffer`, `document`, `window`
- **THEN** zero matches appear outside an approved defaults file
### Requirement: Typed event bus
The package SHALL export an `EventBus<TMap>` class where `TMap extends Record<string, unknown>`. `on`, `once`, `off`, `emit`, and `emitAsync` are constrained by `TMap` so `payload` is inferred from `type`.
#### Scenario: Payload type narrows by event name
- **WHEN** a consumer declares `new EventBus<{ login: { user: string } }>()` and calls `bus.emit("login", x)`
- **THEN** `x` is typed `{ user: string }` (not `unknown`)
#### Scenario: Unknown event name is a type error
- **WHEN** a consumer calls `bus.emit("does-not-exist", ...)` on a bus with no such key
- **THEN** TypeScript rejects at compile time
### Requirement: Deterministic FIFO delivery
Listeners registered for a given event SHALL fire in registration order. Across events, synchronous `emit` delivers fully before returning; async `emitAsync` awaits every listener in registration order by default.
#### Scenario: Sync order
- **WHEN** listeners A, B, C are registered on `"x"` and `emit("x", v)` runs
- **THEN** A runs to completion, then B, then C
#### Scenario: Async order
- **WHEN** async listeners A, B are registered on `"x"` and `emitAsync("x", v)` runs
- **THEN** A's returned promise settles before B is invoked
### Requirement: Interceptor API
The package SHALL export `bus.use(interceptor)` registering an interceptor of shape `(envelope, next) => Promise<EmitResult>`. Interceptors compose in registration order (first-registered outermost). An interceptor MAY omit calling `next()` to drop the event without invoking listeners.
#### Scenario: Interceptor can short-circuit
- **WHEN** an interceptor returns without calling `next()`
- **THEN** registered listeners do not fire AND `EmitResult.delivered === 0`
#### Scenario: Interceptor throw aborts emit
- **WHEN** an interceptor throws
- **THEN** `emitAsync` rejects with `EventsError("EVENTS.INTERCEPTOR.THREW")` with `cause === the thrown value`
#### Scenario: Interceptor can replace payload
- **WHEN** an interceptor passes a new envelope to `next()`
- **THEN** downstream listeners observe the replaced payload
### Requirement: Structured errors
All thrown errors SHALL be `EventsError extends SuaveplanError<EventsCode>` with codes prefixed `EVENTS.`. Listener failures SHALL be collected into `EmitResult.errors`, not propagated, unless an interceptor explicitly re-throws.
#### Scenario: Listener throw is captured
- **WHEN** a sync listener throws
- **THEN** `emit` returns `EmitResult` with `errors[0].code === "EVENTS.LISTENER.THREW"` AND `errors[0].cause === the thrown value` AND `delivered` reflects successful deliveries only
### Requirement: Backpressure via BoundedQueue
The package SHALL export a `BoundedQueue<T>` class with `{ capacity, policy }` where `policy` is one of `"drop-oldest"`, `"drop-newest"`, `"block"`, `"throw"`. `enqueue(item, signal?)` honours `AbortSignal`.
#### Scenario: drop-oldest evicts head on overflow
- **WHEN** a 2-capacity queue with `"drop-oldest"` receives 3 items
- **THEN** only the latest 2 remain, head was evicted
#### Scenario: throw policy raises EVENTS.QUEUE.OVERFLOW
- **WHEN** a 1-capacity queue with `"throw"` receives a 2nd enqueue
- **THEN** the 2nd enqueue throws `EventsError("EVENTS.QUEUE.OVERFLOW")`
#### Scenario: block waits for capacity
- **WHEN** a 1-capacity queue with `"block"` is full AND a 2nd enqueue is pending AND the head is dequeued
- **THEN** the pending enqueue resolves
#### Scenario: Aborted enqueue rejects
- **WHEN** `enqueue(x, signal)` is pending under `"block"` AND the signal aborts
- **THEN** the pending enqueue rejects with `EventsError("EVENTS.QUEUE.ABORTED")`
#### Scenario: Invalid capacity rejected
- **WHEN** a queue is constructed with `capacity <= 0` or non-integer capacity
- **THEN** construction throws `EventsError("EVENTS.QUEUE.INVALID_CAPACITY")`
### Requirement: Idempotent listener wrapper
The package SHALL export `idempotent(listener, { keyOf, cache? })` returning a listener that fires at most once per `keyOf(payload)`.
#### Scenario: Duplicate key suppressed
- **WHEN** the wrapped listener is invoked with two payloads yielding the same key
- **THEN** the underlying listener fires exactly once
#### Scenario: Distinct keys fire independently
- **WHEN** the wrapped listener is invoked with payloads yielding different keys
- **THEN** the underlying listener fires for each
### Requirement: Deterministic id and clock sources
Event envelope `id` and `timestamp` SHALL come from injectable sources. `createBus({ clock?, idGenerator? })` accepts overrides. The defaults live in two named exports (`defaultClock`, `defaultIdGenerator`) and nowhere else in `src/`.
#### Scenario: No module-scope non-determinism
- **WHEN** `src/` is scanned for `Date.now`, `crypto.randomUUID`, `Math.random`
- **THEN** those symbols appear only in `clock/default-clock.ts` and `id/id-generator.ts`
#### Scenario: Injected clock and id generator flow to envelopes
- **WHEN** a bus is created with `{ clock: () => 42, idGenerator: () => "X" }` and an event is emitted
- **THEN** the produced envelope has `timestamp === 42` AND `id === "X"`
### Requirement: Passive observability adapter
The package SHALL export `withObservability(bus, adapter)` returning a wrapped bus that invokes adapter hooks around each emit. No OpenTelemetry SDK is imported.
#### Scenario: Adapter hooks receive envelope and result
- **WHEN** an event is emitted through the wrapped bus
- **THEN** `adapter.onEmit(envelope)` fires once before listeners AND `adapter.onEmitComplete(envelope, result)` fires once after
### Requirement: Test coverage at 100%
`vitest.config.ts` MUST enforce `statements: 100`, `branches: 100`, `functions: 100`, `lines: 100`. No `istanbul ignore` pragmas (they do not survive esbuild). Property-based tests cover FIFO order, interceptor composition, queue policies, and idempotency.
#### Scenario: Coverage threshold enforced
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/events` runs
- **THEN** it exits non-zero if any metric drops below 100

View File

@@ -0,0 +1,19 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/events/events.
# Tasks: enterprise-events
- [x] 1. Scaffold package.json, tsconfig, vite/vitest configs (thresholds 100), README, CHANGELOG
- [x] 2. Implement `errors/codes.ts` (EVENTS_CODES via `defineCodes`) + `events-error.ts` extending `SuaveplanError`
- [x] 3. Implement `clock/default-clock.ts` and `id/id-generator.ts` as the single source of non-determinism
- [x] 4. Implement `envelope/envelope.ts` + `envelope.schemas.ts` + types
- [x] 5. Implement `interceptor/compose.ts` with chain-of-responsibility
- [x] 6. Implement `queue/bounded-queue.ts` with all four policies + abort support
- [x] 7. Implement `idempotent/idempotent.ts`
- [x] 8. Implement `bus/event-bus.ts` composing the pieces
- [x] 9. Implement `observability/with-observability.ts`
- [x] 10. Cross-cutting guards in `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 11. Unit + property-based tests for every module (fast-check)
- [x] 12. `bunx turbo lint typecheck test build --filter=@suaveplan/events --force` green
- [x] 13. Coverage = 100/100/100/100
- [x] 14. `bun scripts/publish.ts --package @suaveplan/events` to Verdaccio at 0.0.0
- [x] 15. Archive change folder under `openspec/archive/<yyyy-mm-dd>-enterprise-events/`

View File

@@ -0,0 +1,55 @@
# OpenSpec Change: enterprise-exif
- **Change ID**: enterprise-exif
- **Package**: `@suaveplan/exif`
- **Category**: universal
- **Target path**: `universal/media/exif`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/exif.md`
## Summary
Greenfield rebuild of `@suaveplan/exif` — a comprehensive, dependency-free, runtime-agnostic metadata reader/writer/stripper for JPEG, PNG, WebP, TIFF, HEIC/ISOBMFF, and RAW images. Supports EXIF, XMP, IPTC, ICC, GPS, and MakerNotes (Apple, Canon, Nikon, Sony, incl. ciphered MakerNote decoders). The package's main-branch change `openspec/changes/exif-migration` is the most recent authoritative delta and is folded in here.
## Current-state audit (genesis)
Package does not exist yet — fresh build. Main branch has `universal/exif` with 40+ exported primitives.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface (grouped)
### Binary primitives
`ByteReader`, `ByteWriter`, `ByteOrder`, `crc32`, `crc32Multi`, `md5`, `md5Hex`, `numberToRational`, `rationalToNumber`, `Rational`.
### Core dispatch
`detectImageFormat`, `readMetadata`, `writeMetadata`, `stripMetadata`, `ReadOptions`, `StripOptions`.
### Format-specific
JPEG: `readJpegSegments`, `writeJpegSegments`. PNG: `readPng`, `writePng`. TIFF: `readTiffMetadata`, `writeTiffMetadata`. WebP: `readWebp`, `writeWebp`. ISOBMFF/HEIC: `findAllBoxes`, `findBox`, `readBoxes`, `readFtypBrands`, `readIsobmffMetadata`, `writeIsobmffMetadata`. RAW: `detectRaw`.
### IFD engine
`parseTiff`, `ParsedTiff`, `ParseOptions`, `buildTiff`, `BuildInput`, `IfdEntry`, `TiffType`, `decodeEntryValue`, `encodeEntryValue`, `entrySize`, `TIFF_TYPE`, `TIFF_TYPE_SIZE`.
### GPS, IPTC, XMP, ICC
GPS: `decimalToDms`, `dmsToDecimal`, `formatDms`, `gpsToIsoTimestamp`, `isoToGpsFields`, `LatRef`, `LonRef`. IPTC: `readIptc`, `writeIptc`. XMP: `readXmp`, `writeXmp`, `XMP_NAMESPACES`, `namespaceFor`. ICC: `readIccProfile`, `writeIccProfile`, `validateIccProfile`, `classifyProfile`, `isSRGB`, `isAdobeRGB`, `isDisplayP3`, `profileDescription`.
### MakerNotes
`decodeMakerNote`, `decodeAppleMakerNote`, `decodeBplist`, `decodeCanonMakerNote`, `decodeNikonMakerNote`, `nikonDecrypt`, `nikonEncrypt`, `sonyDecrypt`, `sonyEncrypt`.
### Types
`ExifData`, `ExifIfd`, `ExifValue`, `IccCurve`, `IccDateTime`, `IccHeader`, `IccProfile`, `IccTagValue`, `IccText`, `IccXyz`, `ImageFormat`, `ISOBMFF_FAMILY`, `TIFF_FAMILY`, `ImageMetadata`, `CameraInfo`, `ExposureInfo`, `GpsCoordinates`, `LensInfo`, `IptcData`, `IptcValue`, `MakerNote`, `MakerNoteVendor`, `GpsPatch`, `MakerNotePatch`, `MetadataPatch`, `XmpData`, `XmpScalar`, `XmpStruct`, `XmpValue`.
## New genesis additions
- Codes: `EXIF.UNSUPPORTED_FORMAT`, `EXIF.PARSE_ERROR`, `EXIF.WRITE_FAILED`, `EXIF.INVALID_IFD`, `EXIF.BUFFER_OVERRUN`.
- Zod schemas for every wire-facing type: `ExifDataSchema`, `ImageMetadataSchema`, `MetadataPatchSchema`.
- OTel histograms per format: `exif.read.duration_ms{format}`, `exif.write.duration_ms{format}`, `exif.strip.duration_ms{format}`.
- Property tests: strip is idempotent; read→write→read yields equal metadata; GPS DMS↔decimal round-trip; `numberToRational`→`rationalToNumber` within tolerance.
- Differential fuzzing harness against `exifr` for read parity on corpus of 1 000 real images.
## Impact
- Adds one new package: `universal/media/exif`
- Adds one new spec capability: `exif`
- Unblocks `metadata`, `image`, `react-image` (downstream waves).

View File

@@ -0,0 +1,36 @@
# Capability Spec: exif
**Package**: `@suaveplan/exif`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### EXIF-01 — Format detection
`detectImageFormat(bytes)` MUST identify JPEG, PNG, WebP, TIFF, HEIC (ISOBMFF), and the RAW families (CR2, CR3, NEF, ARW, RAF, ORF, RW2, DNG) by magic-byte sniffing. Unknown inputs MUST return `'unknown'`, not throw.
### EXIF-02 — Read / write / strip dispatch
`readMetadata(bytes, options?)` MUST dispatch to the format-specific reader and return a unified `ImageMetadata`. `writeMetadata(bytes, patch)` MUST apply a `MetadataPatch` and return a new buffer. `stripMetadata(bytes, options?)` MUST produce a buffer containing image data only; running it twice MUST be idempotent.
### EXIF-03 — IFD codec
`parseTiff(bytes, options?)` MUST return `ParsedTiff` with every IFD, entry, and sub-IFD. `buildTiff(input)` MUST produce a byte buffer that round-trips through `parseTiff` to an equal `ParsedTiff`. Entry type widths MUST follow `TIFF_TYPE_SIZE`.
### EXIF-04 — GPS
`decimalToDms` and `dmsToDecimal` MUST round-trip within 1e-7 degrees. `gpsToIsoTimestamp` and `isoToGpsFields` MUST round-trip to second precision.
### EXIF-05 — ICC
`readIccProfile` MUST return `IccProfile` with header, tag table, and parsed tag values. `validateIccProfile` MUST reject profiles with invalid size, version, or checksum. `classifyProfile` MUST detect `sRGB`, `AdobeRGB`, `DisplayP3` reliably for canonical profiles.
### EXIF-06 — IPTC / XMP
`readIptc` / `writeIptc` MUST support IIM records 1, 2 with UTF-8 and ISO-8859-1 charsets. `readXmp` MUST return an `XmpData` tree (scalars, structs, language-alternatives). `XMP_NAMESPACES` MUST expose the canonical URI per prefix.
### EXIF-07 — MakerNotes
`decodeMakerNote` MUST dispatch to the correct vendor decoder. Nikon and Sony encrypted MakerNote tags MUST be decoded via `nikonDecrypt` / `sonyDecrypt` using the serial+shutter-derived keys. `decodeAppleMakerNote` MUST decode embedded bplist via `decodeBplist`.
### EXIF-08 — Safety
All readers MUST operate on bounded `ByteReader` views and MUST throw `EXIF.BUFFER_OVERRUN` rather than reading past the buffer end. No reader MUST allocate buffers larger than the input.
### EXIF-09 — Observability
Every read/write/strip MUST record the corresponding OTel histogram with label `format`.

View File

@@ -0,0 +1,51 @@
# Tasks: enterprise-exif
## 1. Scaffold
- [x] 1.1 `universal/media/exif/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/errors/exif-error.ts` — codes.
- [x] 2.2 Zod schemas for ExifData, ImageMetadata, MetadataPatch, IccProfile.
## 3. Binary primitives
- [x] 3.1 `src/binary/reader/*` — `ByteReader`, `ByteOrder`.
- [x] 3.2 `src/binary/writer/*` — `ByteWriter`.
- [x] 3.3 `src/binary/rational/*` — rational arithmetic + property tests.
- [x] 3.4 `src/binary/crc32/*`, `src/binary/md5/*`.
## 4. IFD engine
- [x] 4.1 `src/ifd/entry/*` — entry codec + `TIFF_TYPE`.
- [x] 4.2 `src/ifd/parse/*` — `parseTiff`.
- [x] 4.3 `src/ifd/build/*` — `buildTiff`.
- [x] 4.4 Round-trip property test on random IFD trees.
## 5. Format readers/writers
- [x] 5.1 JPEG segments `src/formats/jpeg/*`.
- [x] 5.2 PNG chunks `src/formats/png/*`.
- [x] 5.3 TIFF `src/formats/tiff/*`.
- [x] 5.4 WebP chunks `src/formats/webp/*`.
- [x] 5.5 ISOBMFF/HEIC box reader + read/write `src/formats/isobmff/*`.
- [x] 5.6 RAW dispatch `src/formats/raw/dispatch/*`.
- [x] 5.7 Real-image fixture tests per format.
## 6. Core dispatch
- [x] 6.1 `src/core/detect/*` — `detectImageFormat`.
- [x] 6.2 `src/core/read/*`, `src/core/write/*`, `src/core/strip/*`.
- [x] 6.3 OTel histograms per format.
## 7. Metadata sub-systems
- [x] 7.1 GPS: convert + timestamp.
- [x] 7.2 ICC: read / write / validate / convert (classifyProfile, isSRGB, isAdobeRGB, isDisplayP3, profileDescription).
- [x] 7.3 IPTC: read / write.
- [x] 7.4 XMP: read / write + namespaces.
## 8. MakerNotes
- [x] 8.1 Dispatch + Apple bplist + Apple + Canon + Nikon (+ cipher) + Sony (+ cipher).
## 9. Differential fuzz
- [x] 9.1 Differential harness vs `exifr` on 1 000-image corpus; record discrepancies.
## 10. Docs / barrel / subpath exports / README / benchmarks
## 11. Ship gate
- [x] 11.1 Verification sequence; fold + archive (merge with main-branch `exif-migration` delta).

View File

@@ -0,0 +1,57 @@
# OpenSpec Change: enterprise-feature-flags
- **Change ID**: enterprise-feature-flags
- **Package**: `@suaveplan/feature-flags`
- **Category**: universal
- **Target path**: `universal/config/feature-flags`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/feature-flags.md`
## Summary
Greenfield build of `@suaveplan/feature-flags` — provider-agnostic flag evaluation with a `FlagRegistry` + `FlagManager` façade, an `Evaluator` that runs strategy chains, provider adapters (memory, JSON), and composable targeting strategies (`PercentageStrategy`, `UserTargetStrategy`, `AttributeStrategy`, `ScheduleStrategy`, `CompositeStrategy`). Typed `FlagValue` with deterministic bucketing via `hashToPercentage`.
## Current-state audit (genesis)
Package does not exist on genesis yet — fresh build.
## Dependencies
- Workspace: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/logger`
- External (via catalog): `zod` → `catalog:forms`; `@opentelemetry/api` → `catalog:observability`; `tslib` → `catalog:build`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` → `catalog:testing`
## Preserved public surface (from main)
### Core
- `FlagRegistry`, `FlagRegistryOptions`
- `FlagManager`, `FlagManagerOptions`
- `Evaluator`, `EvaluatorOptions`
### Providers
- `BaseFlagProvider`, `BaseFlagProviderOptions`
- `MemoryProvider`
- `JsonProvider`, `JsonProviderOptions`, `JsonFlagConfig`, `parseJsonFlags`
### Strategies
- `PercentageStrategy`, `PercentageStrategyOptions`
- `UserTargetStrategy`, `UserTargetStrategyOptions`
- `AttributeStrategy`, `AttributeStrategyOptions`, `AttributeOperator`, `AttributeRule`, `compareValues`, `getNestedValue`
- `ScheduleStrategy`, `ScheduleStrategyOptions`
- `CompositeStrategy`, `CompositeStrategyOptions`, `CompositeOperator`
### Types
`FlagDefinition`, `FlagDefinitionMap`, `FlagValue`, `FlagProvider`, `FlagChangeEvent`, `FlagChangeListener`, `FlagEvaluationResult`, `EvaluationContext`, `EvaluationReason`, `Strategy`, `StrategyResult`
### Utilities
- `hashToPercentage`, `combineKeys`
## New genesis additions
- Structured errors with codes `FEATURE_FLAGS.UNKNOWN_FLAG`, `FEATURE_FLAGS.PROVIDER_ERROR`, `FEATURE_FLAGS.STRATEGY_ERROR`, `FEATURE_FLAGS.INVALID_DEFINITION`.
- Zod schemas for every `FlagDefinition`, `EvaluationContext`, `JsonFlagConfig`.
- OTel histogram `ff.evaluate.duration_ms`; counter `ff.evaluate.total{flag, result}`.
- Property tests: `hashToPercentage` uniform over random keys within statistical tolerance; `PercentageStrategy` bucketing is monotonic in rollout percentage.
- mitata benchmarks for `Evaluator.evaluate` across strategy depths.
## Impact
- Adds one new package: `universal/config/feature-flags`
- Adds one new spec capability: `feature-flags`

View File

@@ -0,0 +1,33 @@
# Capability Spec: feature-flags
**Package**: `@suaveplan/feature-flags`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### FF-01 — Provider contract
`BaseFlagProvider` MUST implement `getFlag(key): FlagDefinition | undefined`, `listFlags(): FlagDefinitionMap`, `subscribe(listener): () => void`, and `close(): void`. Concrete providers (`MemoryProvider`, `JsonProvider`) MUST satisfy the same contract test suite.
### FF-02 — Evaluator contract
`Evaluator.evaluate(key, context)` MUST resolve the flag definition, run its strategy chain in declared order, short-circuit on the first non-`none` `StrategyResult`, and return a `FlagEvaluationResult` carrying `{ value, reason, flag, context, duration_ms }`. Unknown flag keys MUST throw or return a fallback per `EvaluatorOptions.fallbackMode`.
### FF-03 — Deterministic bucketing
`PercentageStrategy` MUST use `hashToPercentage(combineKeys(flagKey, userKey))` and bucket `[0, rolloutPercentage)`. For fixed inputs it MUST return the same result across processes and platforms; the implementation MUST NOT depend on `Math.random`.
### FF-04 — Composite strategy semantics
`CompositeStrategy` MUST evaluate children left-to-right. `AND` MUST short-circuit on the first `none`/`no-match`. `OR` MUST short-circuit on the first `match`. Errors in a child strategy MUST surface as `FEATURE_FLAGS.STRATEGY_ERROR` with the child's index in context.
### FF-05 — Attribute targeting
`AttributeStrategy` MUST support `AttributeOperator` values `eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `contains`, `startsWith`, `endsWith`, `regex`. `getNestedValue(ctx, path)` MUST walk dot-notation paths safely, returning `undefined` for missing segments rather than throwing.
### FF-06 — Change notification
Any change pushed through a provider (`updateFlag`, remote poll) MUST emit a `FlagChangeEvent` on the shared `EventBus`. `FlagManager` MUST expose `subscribe(listener)` that forwards these events to consumers.
### FF-07 — Observability
Every `Evaluator.evaluate` call MUST record a histogram `ff.evaluate.duration_ms` and increment `ff.evaluate.total` with labels `flag` and `result` (`match | no-match | fallback | error`).
### FF-08 — Definition validation
All flag definitions registered via `FlagRegistry` MUST pass `FlagDefinitionSchema.parse`. Invalid shapes MUST surface as `FEATURE_FLAGS.INVALID_DEFINITION`.

View File

@@ -0,0 +1,50 @@
# Tasks: enterprise-feature-flags
## 1. Scaffold
- [x] 1.1 Create `universal/config/feature-flags/` skeleton; 100/100/100/100 coverage; istanbul.
- [x] 1.2 Cross-cutting guard tests; add to CLAUDE.md/README/FOLDERS.yml.
## 2. Errors + schemas
- [x] 2.1 `src/errors/feature-flags-error.ts` — `FeatureFlagsError` extends `SuaveplanError`; codes `FEATURE_FLAGS.*`.
- [x] 2.2 Auto-register codes via side-effect import.
- [x] 2.3 Zod schemas for `FlagDefinition`, `EvaluationContext`, `JsonFlagConfig`, `FlagEvaluationResult`.
## 3. Core types + utilities
- [x] 3.1 `src/types/common.ts` — all preserved type exports.
- [x] 3.2 `src/utils/hash.ts` — `hashToPercentage`, `combineKeys` (FNV-1a or xxhash32 over `combineKeys(flagKey, userKey)`).
- [x] 3.3 Co-located `.md` + property tests for uniformity.
## 4. Strategies
- [x] 4.1 `src/strategies/percentage-strategy/*` — `PercentageStrategy`; deterministic bucketing.
- [x] 4.2 `src/strategies/user-target-strategy/*`.
- [x] 4.3 `src/strategies/attribute-strategy/*` — `compareValues`, `getNestedValue`, `AttributeOperator`, `AttributeRule`.
- [x] 4.4 `src/strategies/schedule-strategy/*` — time-windowed rollout.
- [x] 4.5 `src/strategies/composite-strategy/*` — AND/OR over child strategies.
- [x] 4.6 Co-located `.md` + unit tests each.
## 5. Providers
- [x] 5.1 `src/providers/provider-interface/*` — `BaseFlagProvider` abstract class.
- [x] 5.2 `src/providers/memory-provider/*` — `MemoryProvider`.
- [x] 5.3 `src/providers/json-provider/*` — `JsonProvider`, `parseJsonFlags`.
- [x] 5.4 Co-located `.md` + tests; provider contract test suite reused across implementations.
## 6. Core evaluator
- [x] 6.1 `src/core/flag-registry/*` — `FlagRegistry`; validates definitions via Zod.
- [x] 6.2 `src/core/evaluator/*` — `Evaluator`; OTel histogram + counter.
- [x] 6.3 `src/core/flag-manager/*` — `FlagManager` façade.
- [x] 6.4 Emits `FlagChangeEvent` via `@suaveplan/events`.
## 7. Index + exports
- [x] 7.1 Barrel `src/index.ts` — re-exports only.
- [x] 7.2 Subpath exports per feature module.
## 8. Docs
- [x] 8.1 Root `README.md`.
- [x] 8.2 Co-located `.md` per `.ts`.
## 9. Benchmarks
- [x] 9.1 `src/__benchmarks__/evaluate.bench.ts` — 1 / 5 / 25 strategy chain depth.
## 10. Ship gate
- [x] 10.1 Verification sequence green.
- [x] 10.2 Fold spec + archive.

View File

@@ -0,0 +1,67 @@
# OpenSpec Change: enterprise-fsm
- **Change ID**: enterprise-fsm
- **Package**: `@suaveplan/fsm`
- **Category**: universal
- **Target path**: `universal/control-flow/fsm`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/fsm.md`
## Summary
Finite state machine with hierarchical, parallel, and history-state extensions, deferred-event queue, transition guards/actions (sync + async), snapshot/restore, lifecycle listener API, declarative transition hooks, DOT/Mermaid visualisers, and exhaustive config validation. Shape-compatible with XState-lite state configurations but zero-dependency.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
### Core FSM
- `createMachine`, `StateMachine`
- Types: `ActionFn`, `AsyncActionFn`, `AsyncGuardFn`, `CompletedTransition`, `FsmError`, `FsmErrorCode`, `FsmEventType`, `FsmListenerMap`, `GuardFn`, `LifecycleListener`, `MachineConfig`, `MachineSnapshot`, `StateConfig`, `TransitionDefinition`, `TransitionInfo`, `TransitionListener`, `TransitionResult`
- Hooks: `FsmHookMap`, `FsmTransitionHookData`, `FsmTransitioningHandler`
### Utilities
- `buildTransitionIndex`, `createFsmError`, `validateConfig`
### Hierarchical
- `createHierarchicalMachine`, `HierarchicalStateMachine`, `HierarchicalConfig`, `HierarchicalStateNode`, `HierarchicalTransition`, `HierarchicalTransitionResult`
### History
- `createHistoryManager`, `HistoryStateManager`, `HistoryEntry`, `HistoryStateConfig`, `HistoryType`
### Parallel
- `createParallelMachine`, `ParallelStateMachine`, `ParallelConfig`, `ParallelState`, `ParallelTransitionResult`, `RegionConfig`, `RegionTransition`, `SyncPoint`
### Deferred queue
- `createDeferredQueue`, `DeferredEventQueue`, `DeferredEvent`, `DeferredQueueConfig`, `StateDeferConfig`
### Visualisation
- `toDot`, `toMermaid`, `VisualizerOptions`
## New genesis additions
- Codes: `FSM.INVALID_CONFIG`, `FSM.INVALID_TRANSITION`, `FSM.GUARD_REJECTED`, `FSM.HISTORY_EMPTY`, `FSM.DEFER_OVERFLOW`.
- Zod schemas for every config input.
- OTel histograms `fsm.transition.duration_ms`, counter `fsm.transition.total{outcome}`.
- Property tests: config round-trip via snapshot/restore, parallel-region determinism under identical event order.
## Impact
- Adds one new package: `universal/control-flow/fsm`
- Adds one new spec capability: `fsm`

View File

@@ -0,0 +1,49 @@
# Capability Spec: fsm
**Package**: `@suaveplan/fsm`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### FSM-01 — Config validation
`validateConfig(config)` MUST reject machines with unknown target states, duplicate state ids, or cyclic hierarchical parents. Invalid configs MUST throw `FSM.INVALID_CONFIG`.
### FSM-02 — Transition contract
`StateMachine.send(event)` MUST run each candidate transition's guards in declared order, halt on the first `true`, execute the transition's actions, and emit `TransitionResult { fromState, toState, event, eventType: FsmEventType }`. Guard rejection across all candidates MUST emit `FSM.INVALID_TRANSITION`.
### FSM-03 — Async guards / actions
`AsyncGuardFn` MUST return `Promise<boolean>`; `AsyncActionFn` MUST return `Promise<void>`. `send` MUST await them in order. Concurrent `send` calls MUST be serialised by the machine (FIFO).
### FSM-04 — Snapshot / restore
`StateMachine.snapshot()` MUST return `MachineSnapshot` serialisable via `JSON.stringify`. `StateMachine.restore(snapshot)` MUST restore the machine to that exact state + context. Snapshots from a different `config.id` MUST be rejected.
### FSM-05 — Hierarchical semantics
`HierarchicalStateMachine` MUST resolve the deepest active leaf on entry. `onEntry` / `onExit` MUST fire in outer→inner / inner→outer order respectively.
### FSM-06 — Parallel regions
`ParallelStateMachine` MUST evaluate transitions independently per `RegionConfig`. `SyncPoint` MUST block progress until every named region enters the configured state.
### FSM-07 — History
`HistoryType` MUST be `'shallow' | 'deep'`. `HistoryStateManager` MUST return the last active leaf on re-entry for `deep`, and the last active direct child for `shallow`. Empty history on `onExit`-before-`onEntry` paths MUST emit `FSM.HISTORY_EMPTY`.
### FSM-08 — Deferred events
`DeferredEventQueue` MUST buffer events per `StateDeferConfig` and release them when the state exits. Overflow MUST surface as `FSM.DEFER_OVERFLOW`.
### FSM-09 — Visualisation
`toDot(config)` MUST produce Graphviz DOT representing states + transitions. `toMermaid(config)` MUST produce Mermaid state-diagram syntax. Both MUST handle hierarchical + parallel configs.
### FSM-10 — Observability
`send` MUST record `fsm.transition.duration_ms` histogram and `fsm.transition.total{outcome}` counter.

View File

@@ -0,0 +1,47 @@
# Tasks: enterprise-fsm
## 1. Scaffold
- [x] 1.1 `universal/control-flow/fsm/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/fsm-utils.ts` — `createFsmError`, validation helpers.
- [x] 2.2 Zod schemas for `MachineConfig`, `StateConfig`, `TransitionDefinition`.
## 3. Core FSM
- [x] 3.1 `src/fsm.types.ts` — full type surface.
- [x] 3.2 `src/fsm.ts` — `createMachine`, `StateMachine` with listener API, sync/async guards + actions, snapshot/restore.
- [x] 3.3 `src/fsm-hooks.ts` — hook map + handler signatures.
- [x] 3.4 OTel histogram + counter.
## 4. Hierarchical
- [x] 4.1 `src/hierarchy/hierarchical-fsm.ts`.
## 5. Parallel regions
- [x] 5.1 `src/parallel/parallel-fsm.ts` with sync points.
## 6. History states
- [x] 6.1 `src/history/history-state.ts` — shallow + deep history.
## 7. Deferred events
- [x] 7.1 `src/deferred/deferred-queue.ts` — bounded queue with drop-oldest/drop-newest policy.
## 8. Visualisation
- [x] 8.1 `src/visualization/fsm-visualizer.ts` — DOT + Mermaid.
## 9. Docs / barrel / subpath exports / README
## 10. Benchmarks
- [x] 10.1 `transition.bench.ts` at 10 / 100 / 1 000 state configurations.
## 11. Ship gate
- [x] 11.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,185 @@
# OpenSpec Change: enterprise-i18n
- **Change ID**: enterprise-i18n
- **Package**: `@suaveplan/i18n`
- **Category**: universal
- **Target path**: `packages/universal/i18n`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/i18n.md`
## Summary
Greenfield implementation of `@suaveplan/i18n` for the genesis branch. The existing main-branch i18n package provides the baseline public-API surface to preserve; this change delivers a complete internationalization core encompassing:
- **Message catalog** — namespace support, lazy loaders (async MessageLoader), missing-key hooks, deep-merge of translation records, and typed lookup results
- **ICU-like interpolation engine** — variable substitution, pluggable value formatters, HTML escaping, regex escaping
- **Pluralization engine** — PluralRuleRegistry with language rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, and Welsh; select-form resolution
- **Message parser** — full AST (TextNode, VariableNode, PluralNode, SelectNode), `extractVariables`, `renderNodes`
- **Locale resolution** — BCP-47 normalization, RTL detection, region/language extraction, pluggable resolution strategies (cookie, header, query-param, accept-language, default)
- **Locale middleware** — server-side locale negotiation, request augmentation (`I18nRequest`), direction detection
- **Date formatting** — `Intl.DateTimeFormat`-backed preset-based and custom formatting, day/month name helpers, date-range formatting
- **Currency formatting** — `Intl.NumberFormat`-backed formatting, currency symbol/name lookups, a typed `CommonCurrency` registry
- **Utility layer** — record flattening/unflattening, key building/parsing, HTML sanitization, RTL helpers, nested value access
- **Structured errors** (`I18nError` + five typed codes), Zod schemas, OTel instrumentation, property-based tests, and benchmarks
## Current-state audit (genesis)
The package does not exist on genesis yet — fresh build.
## Available workspace deps on genesis
| Package | Available |
|---------|-----------|
| `@suaveplan/error` | ✅ |
| `@suaveplan/types` | ✅ |
| `@suaveplan/events` | ✅ |
| `@suaveplan/logger` | ✅ |
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
| `@suaveplan/templates` | ❌ not yet built — implement interpolation engine inline |
External deps (via catalog):
- `zod` → `catalog:forms`
- `@opentelemetry/api` → `catalog:observability`
- `tslib` → `catalog:build`
Dev deps (via catalog):
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
- `fast-check` → `catalog:testing`
- `mitata` → `catalog:testing`
## Preserved public API surface (from main branch)
### Constants
- `DEFAULT_INTERPOLATION` — default interpolation config (delimiters, escape char)
- `DEFAULT_LOCALES` — ordered list of built-in supported locales
- `LOCALE_BCP47_MAP` — map from internal locale identifiers to BCP-47 tags
- `LOCALE_DESCRIPTORS` — per-locale metadata (name, nativeName, direction, pluralRules)
- `RTL_LOCALES` — set of locales that require right-to-left layout
- `DefaultLocale` — type alias for the default locale string literal
### Core: catalog
- `deepMerge(target, source)` — deep merge of translation record trees
- `MessageCatalog` class — `.get(key, locale, opts?)`, `.set(locale, ns, record)`, `.load(locale, ns, loader)`, `.has(key, locale)`, `.keys(locale, ns?)`
- `CatalogOptions`, `ExtendedCatalogOptions`, `LookupResult`, `MessageLoader`, `MissingKeyHandler`, `NamespacedCatalogMap`, `TranslationCatalogMap`, `TranslationRecord`
### Core: interpolation
- `escapeHtmlChars(str)` — escapes `&`, `<`, `>`, `"`, `'`
- `escapeRegex(str)` — escapes all regex metacharacters
- `InterpolationEngine` class — `.render(template, values, opts?)` → `InterpolationResult`
- `InterpolationConfig`, `InterpolationOptions`, `InterpolationResult`, `InterpolationValues`, `ValueFormatter`
### Core: pluralization
- `arabicPlural`, `chinesePlural`, `defaultPluralRule`, `englishPlural`, `frenchPlural`, `germanPlural`, `polishPlural`, `russianPlural`, `spanishPlural`, `welshPlural` — `PluralRuleFunction` implementations
- `getBaseLocale(locale)` — strips region subtag
- `normalizeLocale(locale)` — lowercase language, uppercase region
- `PluralizationEngine` class — `.resolve(count, locale, messages, opts?)` → `PluralResolveResult`
- `PluralCategory`, `PluralMessages`, `PluralResolveOptions`, `PluralResolveResult`, `PluralRuleFunction`, `PluralRuleRegistry`
### Formatting: currency
- `formatCurrency(amount, currency, locale, opts?)` → `string`
- `formatCurrencyValue(amount, currency, locale, opts?)` → `string` (value only, no symbol)
- `getCurrencyInfo(currency)` → `CurrencyInfo`
- `getCurrencyName(currency, locale)` → `string`
- `getCurrencySymbol(currency, locale)` → `string`
- `CommonCurrency`, `CurrencyFormatOptions`, `CurrencyInfo`
### Formatting: date
- `formatDate(date, locale, opts?)` → `string`
- `formatDateOnly(date, locale, opts?)` → `string`
- `formatDateRange(start, end, locale, opts?)` → `string`
- `formatTime(date, locale, opts?)` → `string`
- `getDayNames(locale, format?)` → `string[]`
- `getMonthNames(locale, format?)` → `string[]`
- `DateFormatOptions`, `DateFormatPreset`
### Locale
- `extractLanguage(locale)` → `string`
- `extractRegion(locale)` → `string | undefined`
- `LocaleResolver` class — `.resolve(request, config)` → `Promise<ResolvedLocale>`
- `normalizeLocaleCode(code)` → `string`
- `ExtendedResolverConfig`, `ResolutionStrategy`, `ResolvedLocale`, `ResolverConfig`
### Middleware
- `createLocaleMiddleware(config)` → middleware handler
- `detectLocale(request, config)` → `Promise<ResolvedLocale>`
- `getDirection(locale)` → `TextDirection`
- `ExtendedMiddlewareConfig`, `I18nRequest`, `LocaleLookupSource`, `MiddlewareConfig`, `MiddlewareResult`, `TextDirection`
### Parser
- `extractVariables(message)` → `string[]`
- `MessageParser` class — `.parse(message, opts?)` → `ParsedMessage`
- `renderNodes(nodes, values, opts?)` → `string`
- `MessageNode`, `ParsedMessage`, `ParserOptions`, `PluralNode`, `SelectNode`, `TextNode`, `VariableNode`
### Utils
- `buildKey(...segments)` → `string`
- `countKeys(record)` → `number`
- `flattenRecord(record, separator?)` → `Record<string, string>`
- `getNestedValue(record, key, separator?)` → `string | undefined`
- `getTextDirection(locale)` → `TextDirection`
- `hasKey(record, key, separator?)` → `boolean`
- `isRtl(locale)` → `boolean`
- `parseKey(key, separator?)` → `string[]`
- `sanitize(html, opts?)` → `string`
- `unflattenRecord(flat, separator?)` → `Record<string, unknown>`
- `KeyPathResult`, `SanitizeOptions`
## New additions for genesis
### Structured errors
- `I18nError` base class extending `SuaveplanError` with error codes under the `I18N.*` namespace
- Codes via `defineCodes`:
- `I18N.MISSING_KEY` — key not found in catalog and no fallback available
- `I18N.INVALID_LOCALE` — locale string fails BCP-47 validation
- `I18N.LOADER_FAILED` — async `MessageLoader` rejected or returned invalid shape
- `I18N.INTERPOLATION_ERROR` — template rendering failed (unclosed variable, bad formatter output)
- `I18N.PLURAL_RULE_NOT_FOUND` — no plural rule registered for the requested locale
### Zod schemas
- `TranslationRecordSchema` — recursive schema for nested translation records
- `CatalogOptionsSchema` — validates `CatalogOptions` at catalog-construction boundary
- `InterpolationConfigSchema` — validates delimiter pairs, escape character, and max iterations
- `ResolverConfigSchema` — validates resolution strategy order and fallback locale
### OTel instrumentation
Emitted via `@opentelemetry/api`; all instruments are no-ops without an exporter:
- Counter `i18n.translate.total` with `outcome` label (`hit` | `miss`) and `locale` label
- Counter `i18n.locale.resolve.total` with `strategy` label and `locale` label
- Histogram `i18n.translate.duration_ms` — time from `.get()` call to resolved string, by locale
### Property-based tests (fast-check)
- `flattenRecord` → `unflattenRecord` round-trip: for any depth-3 nested record of string leaves, `unflattenRecord(flattenRecord(r)) ≅ r`
- `normalizeLocale` idempotent: `normalizeLocale(normalizeLocale(s)) === normalizeLocale(s)` for any string `s`
- Interpolation escaping never leaks raw HTML: for any user-supplied variable value containing `<`, `>`, `&`, `"`, `'`, `InterpolationEngine.render` with `escapeHtml: true` must not emit unescaped characters in the rendered output
- `buildKey` / `parseKey` round-trip: `buildKey(...parseKey(key)) === key` for any non-empty dot-separated key string
### Benchmarks (mitata)
- `MessageCatalog.get` — 100 000 iterations on a pre-loaded catalog with 1 000 keys across 3 namespaces
- `InterpolationEngine.render` — 10 000 iterations with a template containing 5 named variables
- `PluralizationEngine.resolve` — 10 000 iterations cycling through `one`, `few`, `many`, `other` counts for English, Polish, and Arabic rules
## Impact
- Adds one new package: `packages/universal/i18n`
- Adds one new spec capability: `i18n`
- No existing package consumers on genesis yet

View File

@@ -0,0 +1,172 @@
# Capability Spec: i18n
**Package**: `@suaveplan/i18n`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### I18N-01 — Structured error hierarchy
An `I18nError` base class MUST extend `SuaveplanError` from `@suaveplan/error`. All errors thrown by this package MUST extend `I18nError`. Five error codes MUST be registered via `defineCodes` under the `I18N.*` namespace:
- `I18N.MISSING_KEY` — key not found in the catalog when strict mode is enabled and no `MissingKeyHandler` recovers a value
- `I18N.INVALID_LOCALE` — locale string fails BCP-47 structural validation
- `I18N.LOADER_FAILED` — async `MessageLoader` rejected or returned a value that does not satisfy `TranslationRecordSchema`
- `I18N.INTERPOLATION_ERROR` — template rendering failed (e.g. unclosed variable delimiter, formatter threw)
- `I18N.PLURAL_RULE_NOT_FOUND` — no plural rule is registered for the locale and strict mode is enabled
Error codes MUST be auto-registered via a side-effect import in `src/index.ts` (same pattern as `@suaveplan/error`).
### I18N-02 — Zod schemas at the public boundary
The following Zod schemas MUST be exported and used to validate options at construction / call sites:
- `TranslationRecordSchema` — recursive `z.record(z.string(), z.union([z.string(), z.lazy(...)]))` for nested translation trees; rejects arrays and non-string leaves
- `CatalogOptionsSchema` — validates all fields of `CatalogOptions` including optional `missingKeyHandler`, `defaultLocale`, and `strict` flag
- `InterpolationConfigSchema` — validates `open` and `close` delimiter strings (non-empty, distinct), `escape` character (single char or empty), and `maxIterations` (positive integer, ≤ 100)
- `ResolverConfigSchema` — validates `strategies` (non-empty ordered array of `ResolutionStrategy`), `fallbackLocale` (non-empty BCP-47 string), and optional `supportedLocales`
Invalid options MUST throw `I18nError` with the relevant code and a Zod-formatted message attached as `context`.
### I18N-03 — Message catalog contract
`MessageCatalog` MUST:
- Accept namespaced `TranslationRecord` trees; key lookup MUST use dot-path resolution within the namespace
- Expose `.get(key, locale, opts?)` returning `LookupResult` — `{ value: string; locale: string; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }`
- Support async `MessageLoader` functions per locale+namespace pair; concurrent calls for the same locale+namespace MUST be deduplicated (single in-flight Promise)
- Surface loader rejection as `I18nError` with code `I18N.LOADER_FAILED`
- Invoke `MissingKeyHandler` (if registered) before throwing; if the handler returns a string, surface it with `source: 'handler'`
- In strict mode (default `false`), throw `I18nError` (code `I18N.MISSING_KEY`) when no value is found; in non-strict mode, return the key itself with `source: 'key'`
- `deepMerge(target, source)` MUST perform a deep structural merge of two `TranslationRecord` trees; source values win on scalar conflicts
### I18N-04 — Interpolation engine contract
`InterpolationEngine` MUST:
- Replace `{variableName}` tokens (default delimiters configurable via `InterpolationConfig`) with values from `InterpolationValues`
- Support a `ValueFormatter` map: `{ [variableName]: (value, locale) => string }` for locale-aware per-variable formatting
- When `escapeHtml: true` in `InterpolationOptions`, HTML-escape all substituted values using `escapeHtmlChars` BEFORE insertion; raw template text is NOT escaped
- Throw `I18nError` (code `I18N.INTERPOLATION_ERROR`) on unclosed delimiter or on formatter exception
- `escapeHtmlChars(str)` MUST escape `&` → `&amp;`, `<` → `&lt;`, `>` → `&gt;`, `"` → `&quot;`, `'` → `&#39;`
- `escapeRegex(str)` MUST escape all regex metacharacters: `. * + ? ^ $ { } [ ] | ( ) \`
### I18N-05 — Pluralization engine contract
`PluralizationEngine` MUST:
- Accept a `PluralRuleRegistry` mapping locale codes to `PluralRuleFunction`; built-in rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, and a `defaultPluralRule` (English-style one/other) MUST be pre-registered
- `.resolve(count, locale, messages, opts?)` MUST call the registered rule for `locale`, fall back to the base-language rule (strip region), then fall back to `defaultPluralRule`; return `PluralResolveResult { category: PluralCategory; value: string }`
- `PluralCategory` MUST be `'zero' | 'one' | 'two' | 'few' | 'many' | 'other'`
- In strict mode, throw `I18nError` (code `I18N.PLURAL_RULE_NOT_FOUND`) when no rule exists for the locale and its base language
- `getBaseLocale(locale)` strips the region subtag (`en-US` → `en`)
- `normalizeLocale(locale)` normalizes to lowercase language + uppercase region (`EN-us` → `en-US`)
### I18N-06 — Message parser contract
`MessageParser` MUST parse ICU-like message strings into an AST of `MessageNode` values:
- `TextNode` — literal text segment
- `VariableNode` — `{name}` — simple variable substitution
- `PluralNode` — `{count, plural, one{…} other{…}}` — plural form with `PluralCategory` branches
- `SelectNode` — `{gender, select, male{…} female{…} other{…}}` — arbitrary select form
`renderNodes(nodes, values, opts?)` MUST render an AST to a string by substituting values; unknown `VariableNode` references fall back to the variable name in braces.
`extractVariables(message)` MUST return the deduplicated list of variable names referenced in a raw message string without fully parsing the AST.
### I18N-07 — Locale resolver contract
`LocaleResolver` MUST implement a pluggable strategy chain. Built-in `ResolutionStrategy` sources: `'cookie'`, `'query'`, `'header'` (Accept-Language), `'default'`. `.resolve(request, config)` MUST:
- Try each strategy in `config.strategies` order; return the first locale that is in `config.supportedLocales` (if provided) and passes BCP-47 validation
- Fall back to `config.fallbackLocale` when no strategy yields a valid supported locale
- Return `ResolvedLocale { locale: string; source: ResolutionStrategy | 'fallback'; direction: TextDirection }`
`normalizeLocaleCode(code)` MUST throw `I18nError` (code `I18N.INVALID_LOCALE`) for strings that cannot be parsed as a valid BCP-47 language tag.
### I18N-08 — Locale middleware contract
`createLocaleMiddleware(config)` MUST return a middleware function compatible with standard Node.js `(req, res, next)` signatures where `req` is `I18nRequest` (extends a plain object with `locale?: ResolvedLocale`). The middleware MUST:
- Invoke `detectLocale(request, config)` to resolve the locale
- Attach `ResolvedLocale` to `request.locale`
- Set the `Content-Language` response header to the resolved locale string
- Call `next()` unconditionally after attachment
`getDirection(locale)` MUST return `'rtl'` for locales in `RTL_LOCALES`, otherwise `'ltr'`.
### I18N-09 — Date formatting contract
All date formatting functions MUST be backed by `Intl.DateTimeFormat` with no hard-coded locale-specific strings in implementation code.
- `formatDate(date, locale, opts?)` — full date + time per `DateFormatPreset` or custom `Intl.DateTimeFormatOptions`
- `formatDateOnly(date, locale, opts?)` — date portion only
- `formatTime(date, locale, opts?)` — time portion only
- `formatDateRange(start, end, locale, opts?)` — uses `Intl.DateTimeFormat.prototype.formatRange` where the runtime supports it; falls back to two separate formatted strings joined with an em-dash
- `getDayNames(locale, format?)` — returns 7-element array via `Intl.DateTimeFormat` part extraction; `format` is `'long' | 'short' | 'narrow'`
- `getMonthNames(locale, format?)` — returns 12-element array; same `format` options
`DateFormatPreset` MUST include at minimum: `'short'`, `'medium'`, `'long'`, `'full'`, `'relative'`.
### I18N-10 — Currency formatting contract
All currency formatting MUST be backed by `Intl.NumberFormat` with `style: 'currency'`.
- `formatCurrency(amount, currency, locale, opts?)` — full formatted string including symbol (e.g. `$1,234.56`)
- `formatCurrencyValue(amount, currency, locale, opts?)` — numeric value only, no symbol (e.g. `1,234.56`)
- `getCurrencySymbol(currency, locale)` — extracts the currency symbol via `Intl.NumberFormat` parts
- `getCurrencyName(currency, locale)` — localized currency name via `Intl.DisplayNames` with `type: 'currency'`
- `getCurrencyInfo(currency)` — returns `CurrencyInfo { code: CommonCurrency; numericCode: number; decimalDigits: number; name: string }`
`CommonCurrency` MUST be a typed string-literal union covering at minimum: `'USD'`, `'EUR'`, `'GBP'`, `'JPY'`, `'CNY'`, `'CAD'`, `'AUD'`, `'CHF'`, `'SEK'`, `'NOK'`, `'DKK'`, `'SGD'`, `'HKD'`, `'NZD'`, `'KRW'`, `'INR'`, `'BRL'`, `'MXN'`, `'ZAR'`, `'RUB'`.
### I18N-11 — Utility functions contract
- `flattenRecord(record, separator?)` MUST convert a nested `TranslationRecord` into a flat `Record<string, string>` using dot-separated paths; separator defaults to `'.'`
- `unflattenRecord(flat, separator?)` MUST reconstruct a nested object from flat dot-path keys; conflicts (key is both a leaf and a node prefix) MUST throw
- `countKeys(record)` MUST return the total count of string leaf values in a (nested) record
- `getNestedValue(record, key, separator?)` MUST return the string at the dot-path or `undefined` if absent
- `hasKey(record, key, separator?)` MUST return `true` iff the dot-path resolves to a string leaf
- `buildKey(...segments)` MUST join segments with `'.'`, filtering empty strings
- `parseKey(key, separator?)` MUST split on separator, returning `string[]`; `buildKey(...parseKey(key)) === key` for any valid key
- `getTextDirection(locale)` / `isRtl(locale)` MUST consult `RTL_LOCALES`
- `sanitize(html, opts?)` MUST strip disallowed tags and attributes; `SanitizeOptions` controls `allowedTags` and `allowedAttributes`; default `allowedTags` is `[]` (strip all tags)
### I18N-12 — OTel instrumentation
All instruments MUST be created via `@opentelemetry/api` `getMeter('suaveplan.i18n')` and MUST be no-ops when no exporter is registered.
- Counter `i18n.translate.total` — incremented on every `MessageCatalog.get` call; labels: `outcome` (`'hit'` | `'miss'`), `locale`
- Counter `i18n.locale.resolve.total` — incremented on every `LocaleResolver.resolve` call; labels: `strategy` (winning strategy name or `'fallback'`), `locale`
- Histogram `i18n.translate.duration_ms` — records elapsed milliseconds for each `MessageCatalog.get` call; label: `locale`
### I18N-13 — Property-based test requirements
The following properties MUST be verified with fast-check arbitrary inputs:
1. **flattenRecord/unflattenRecord round-trip** — for any depth-≤3 record of string leaves (no key collisions), `unflattenRecord(flattenRecord(r, sep), sep)` deep-equals `r`
2. **normalizeLocale idempotent** — `normalizeLocale(normalizeLocale(s)) === normalizeLocale(s)` for any non-empty ASCII string `s`
3. **HTML escaping completeness** — for any string value containing one or more of `<`, `>`, `&`, `"`, `'`, `InterpolationEngine.render` with `escapeHtml: true` MUST NOT emit any of those characters unescaped in the returned string
4. **buildKey/parseKey round-trip** — for any non-empty array of non-empty, dot-free strings `segs`, `parseKey(buildKey(...segs)) deep-equals segs`
### I18N-14 — Benchmark requirements
Benchmarks MUST be implemented with mitata and MUST exercise the following hot paths:
- `MessageCatalog.get` — 100 000 iterations on a pre-loaded catalog containing 1 000 keys spread across 3 namespaces and 2 locales
- `InterpolationEngine.render` — 10 000 iterations with a fixed template containing 5 named variable placeholders and a matching `InterpolationValues` map
- `PluralizationEngine.resolve` — 10 000 iterations cycling through counts `[0, 1, 2, 5, 11, 100]` for English, Polish, and Arabic rules
Baseline numbers MUST be published in `README.md` after the first green run.
### I18N-15 — Package hygiene
- `"sideEffects": false` in `package.json`; enforced by a tree-shake guard test in `tests/`
- Vitest coverage thresholds MUST be set to 100/100/100/100 (statements/branches/functions/lines); provider MUST be `istanbul`
- No `it.skip`, `test.skip`, `describe.skip`, or `.only` left in `src/` or `tests/`
- Zero browser-specific or Node.js-specific globals in implementation code — pure TypeScript that runs in Bun, Node, Deno, and browser environments; `Intl` API is universally available and permitted
- All internal workspace dependencies use `workspace:^`; all external dependencies use Bun catalog entries

View File

@@ -0,0 +1,141 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/primitives/i18n.
# Tasks: enterprise-i18n
## 1. Scaffold
- [x] 1.1 Create `packages/universal/i18n/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
## 2. Error types + Zod schemas
- [x] 2.1 Implement `src/errors/i18n-error.ts` — `I18nError` base extends `SuaveplanError`; `defineCodes` for `I18N.MISSING_KEY`, `I18N.INVALID_LOCALE`, `I18N.LOADER_FAILED`, `I18N.INTERPOLATION_ERROR`, `I18N.PLURAL_RULE_NOT_FOUND`
- [x] 2.2 Implement `src/error-codes.ts` and auto-register via side-effect import in `src/index.ts`
- [x] 2.3 Implement `src/schemas/catalog.schema.ts` — `TranslationRecordSchema` (recursive), `CatalogOptionsSchema`
- [x] 2.4 Implement `src/schemas/interpolation.schema.ts` — `InterpolationConfigSchema`
- [x] 2.5 Implement `src/schemas/resolver.schema.ts` — `ResolverConfigSchema`
- [x] 2.6 Tests: error construction, code narrowing, schema parse/reject on valid and invalid inputs
## 3. Constants
- [x] 3.1 Implement `src/constants/index.ts` — `DEFAULT_INTERPOLATION`, `DEFAULT_LOCALES`, `LOCALE_BCP47_MAP`, `LOCALE_DESCRIPTORS`, `RTL_LOCALES`, `DefaultLocale`
- [x] 3.2 Ensure constants are tree-shakeable (no side effects)
## 4. Utils
- [x] 4.1 Implement `src/utils/record.ts` — `flattenRecord(record, separator?)`, `unflattenRecord(flat, separator?)`, `countKeys(record)`, `getNestedValue(record, key, separator?)`, `hasKey(record, key, separator?)`
- [x] 4.2 Implement `src/utils/key.ts` — `buildKey(...segments)`, `parseKey(key, separator?)`, `KeyPathResult`
- [x] 4.3 Implement `src/utils/locale.ts` — `getTextDirection(locale)`, `isRtl(locale)`, `TextDirection`
- [x] 4.4 Implement `src/utils/sanitize.ts` — `sanitize(html, opts?)`, `SanitizeOptions`
- [x] 4.5 Unit tests for all util functions: round-trips, edge cases (empty strings, deeply nested, custom separator)
- [x] 4.6 Property-based tests: `flattenRecord` → `unflattenRecord` round-trip; `buildKey` / `parseKey` round-trip
- [x] 4.7 Co-located `.md` for each util file
## 5. Interpolation engine
- [x] 5.1 Implement `src/interpolation/interpolation-engine.ts` — `InterpolationEngine` class with `.render(template, values, opts?)` → `InterpolationResult`; config validation via `InterpolationConfigSchema`
- [x] 5.2 Implement `src/interpolation/escape.ts` — `escapeHtmlChars(str)`, `escapeRegex(str)`
- [x] 5.3 Types: `InterpolationConfig`, `InterpolationOptions`, `InterpolationResult`, `InterpolationValues`, `ValueFormatter`
- [x] 5.4 `escapeHtml` option gates HTML escaping of interpolated values; custom `ValueFormatter` map for per-variable formatting
- [x] 5.5 Throws `I18nError` (code `I18N.INTERPOLATION_ERROR`) on unclosed variable delimiter or formatter error
- [x] 5.6 Unit tests: basic substitution, multiple variables, missing variable (fallback vs. error), formatter, HTML escaping, custom delimiters
- [x] 5.7 Property-based tests: interpolation with `escapeHtml: true` never emits unescaped `<`, `>`, `&`, `"`, `'`
- [x] 5.8 Co-located `.md` for each interpolation file
## 6. Pluralization engine
- [x] 6.1 Implement `src/pluralization/plural-rules.ts` — `arabicPlural`, `chinesePlural`, `defaultPluralRule`, `englishPlural`, `frenchPlural`, `germanPlural`, `polishPlural`, `russianPlural`, `spanishPlural`, `welshPlural` as `PluralRuleFunction`
- [x] 6.2 Implement `src/pluralization/pluralization-engine.ts` — `PluralizationEngine` class with `PluralRuleRegistry`; `.resolve(count, locale, messages, opts?)` → `PluralResolveResult`; auto-falls back to base language rule then `defaultPluralRule`
- [x] 6.3 Throws `I18nError` (code `I18N.PLURAL_RULE_NOT_FOUND`) only when strict mode is enabled and no rule exists
- [x] 6.4 Types: `PluralCategory`, `PluralMessages`, `PluralResolveOptions`, `PluralResolveResult`, `PluralRuleFunction`, `PluralRuleRegistry`
- [x] 6.5 Helpers: `getBaseLocale(locale)`, `normalizeLocale(locale)`
- [x] 6.6 Unit tests: all language rules across representative counts; fallback chain; registry registration/overriding
- [x] 6.7 Property-based tests: `normalizeLocale` idempotent
- [x] 6.8 Co-located `.md` for each pluralization file
## 7. Message parser
- [x] 7.1 Implement `src/parser/message-parser.ts` — `MessageParser` class with `.parse(message, opts?)` → `ParsedMessage`; parses text, `{variable}`, `{count, plural, one{…} other{…}}`, `{gender, select, male{…} female{…} other{…}}` forms
- [x] 7.2 Implement `src/parser/render.ts` — `renderNodes(nodes, values, opts?)` → `string`
- [x] 7.3 Implement `src/parser/extract.ts` — `extractVariables(message)` → `string[]`
- [x] 7.4 Node types: `MessageNode`, `ParsedMessage`, `ParserOptions`, `PluralNode`, `SelectNode`, `TextNode`, `VariableNode`
- [x] 7.5 Unit tests: plain text, variable nodes, plural nodes (one/other/few/many/zero), select nodes, nested nodes, unknown format falls through as text
- [x] 7.6 Co-located `.md` for each parser file
## 8. Message catalog
- [x] 8.1 Implement `src/catalog/catalog.ts` — `MessageCatalog` class with `.get(key, locale, opts?)`, `.set(locale, ns, record)`, `.load(locale, ns, loader)`, `.has(key, locale)`, `.keys(locale, ns?)`; lazy loader deduplication; `MissingKeyHandler` hook; namespace-scoped key lookup with dot-path resolution
- [x] 8.2 Implement `src/catalog/deep-merge.ts` — `deepMerge(target, source)` for `TranslationRecord`
- [x] 8.3 Loader errors surface as `I18nError` (code `I18N.LOADER_FAILED`); missing keys invoke `MissingKeyHandler` or throw `I18nError` (code `I18N.MISSING_KEY`) in strict mode
- [x] 8.4 Types: `CatalogOptions`, `ExtendedCatalogOptions`, `LookupResult`, `MessageLoader`, `MissingKeyHandler`, `NamespacedCatalogMap`, `TranslationCatalogMap`, `TranslationRecord`
- [x] 8.5 Unit tests: single-locale lookup, multi-namespace, lazy loading (real async), deep merge, missing-key handler, strict-mode throw, concurrent loader deduplication
- [x] 8.6 Co-located `.md` for each catalog file
## 9. Locale resolver
- [x] 9.1 Implement `src/locale/locale-resolver.ts` — `LocaleResolver` class with `.resolve(request, config)` → `Promise<ResolvedLocale>`; pluggable strategy chain (cookie → query-param → accept-language header → default)
- [x] 9.2 Implement `src/locale/normalize.ts` — `normalizeLocaleCode(code)`, `extractLanguage(locale)`, `extractRegion(locale)`; invalid locale throws `I18nError` (code `I18N.INVALID_LOCALE`)
- [x] 9.3 Types: `ExtendedResolverConfig`, `ResolutionStrategy`, `ResolvedLocale`, `ResolverConfig`
- [x] 9.4 Options validated via `ResolverConfigSchema` at construction
- [x] 9.5 Unit tests: each strategy source, fallback chain, BCP-47 normalization edge cases, invalid locale rejection
- [x] 9.6 Co-located `.md` for each locale resolver file
## 10. Locale middleware
- [x] 10.1 Implement `src/middleware/locale-middleware.ts` — `createLocaleMiddleware(config)` → middleware function that reads locale from request, attaches `ResolvedLocale` to `I18nRequest`, and sets response `Content-Language` header
- [x] 10.2 Implement `src/middleware/detect.ts` — `detectLocale(request, config)` → `Promise<ResolvedLocale>`
- [x] 10.3 Implement `src/middleware/direction.ts` — `getDirection(locale)` → `TextDirection`
- [x] 10.4 Types: `ExtendedMiddlewareConfig`, `I18nRequest`, `LocaleLookupSource`, `MiddlewareConfig`, `MiddlewareResult`, `TextDirection`
- [x] 10.5 Unit tests: direction detection per locale, middleware attaches resolved locale, lookup source priority
- [x] 10.6 Co-located `.md` for each middleware file
## 11. Formatting: date
- [x] 11.1 Implement `src/formatting/date/date-format.ts` — `formatDate(date, locale, opts?)`, `formatDateOnly(date, locale, opts?)`, `formatTime(date, locale, opts?)` backed by `Intl.DateTimeFormat`; preset-based (`DateFormatPreset`) and custom `Intl.DateTimeFormatOptions` support
- [x] 11.2 Implement `src/formatting/date/date-range.ts` — `formatDateRange(start, end, locale, opts?)` using `Intl.DateTimeFormat.formatRange` where available, otherwise two-part format
- [x] 11.3 Implement `src/formatting/date/names.ts` — `getDayNames(locale, format?)`, `getMonthNames(locale, format?)` via `Intl.DateTimeFormat`
- [x] 11.4 Types: `DateFormatOptions`, `DateFormatPreset`
- [x] 11.5 Unit tests: all presets, custom options, day/month names for en, ar, zh, fr, de; range formatting
- [x] 11.6 Co-located `.md` for each date formatting file
## 12. Formatting: currency
- [x] 12.1 Implement `src/formatting/currency/currency-format.ts` — `formatCurrency(amount, currency, locale, opts?)`, `formatCurrencyValue(amount, currency, locale, opts?)` backed by `Intl.NumberFormat`
- [x] 12.2 Implement `src/formatting/currency/currency-info.ts` — `getCurrencyInfo(currency)` → `CurrencyInfo`, `getCurrencyName(currency, locale)`, `getCurrencySymbol(currency, locale)` via `Intl.DisplayNames` / `Intl.NumberFormat`
- [x] 12.3 Types: `CommonCurrency` (typed union of ISO 4217 codes), `CurrencyFormatOptions`, `CurrencyInfo`
- [x] 12.4 Unit tests: USD/EUR/GBP/JPY/CNY formatting per locale, symbol extraction, currency name localization
- [x] 12.5 Co-located `.md` for each currency formatting file
## 13. OTel instrumentation
- [x] 13.1 Implement `src/observability/metrics.ts` — create meter via `@opentelemetry/api`; export `i18nMetrics` singleton with counter `i18n.translate.total` (labels: `outcome`, `locale`), counter `i18n.locale.resolve.total` (labels: `strategy`, `locale`), histogram `i18n.translate.duration_ms` (label: `locale`)
- [x] 13.2 Wire `i18nMetrics.translateTotal` into `MessageCatalog.get`; wire `i18nMetrics.localeResolveTotal` into `LocaleResolver.resolve`; wire `i18nMetrics.translateDuration` histogram into `MessageCatalog.get`
- [x] 13.3 Unit tests: no-op without exporter; counter increments with correct labels on hit/miss; histogram records non-negative duration
## 14. Property tests + benchmarks
- [x] 14.1 Property-based tests in `tests/properties/`:
- `flattenRecord` → `unflattenRecord` round-trip (fast-check arbitrary nested string record)
- `normalizeLocale` idempotent (fast-check arbitrary string)
- Interpolation escaping never leaks raw HTML (fast-check arbitrary string values with HTML chars)
- `buildKey` / `parseKey` round-trip (fast-check non-empty dot-segment string arrays)
- [x] 14.2 Benchmarks in `src/__benchmarks__/`:
- `catalog.bench.ts` — `MessageCatalog.get` 100 000 iterations on 1 000-key catalog across 3 namespaces
- `interpolation.bench.ts` — `InterpolationEngine.render` 10 000 iterations with 5-variable template
- `pluralization.bench.ts` — `PluralizationEngine.resolve` 10 000 iterations cycling through English, Polish, Arabic counts
## 15. Docs
- [x] 15.1 Co-located `.md` for every implementation file (≥200 words, basic + advanced examples, API reference, implementation notes)
- [x] 15.2 Rewrite `README.md` with public API index, architecture diagram, consumer guide, and benchmark numbers
- [x] 15.3 `bun run docs:check --filter=@suaveplan/i18n` ≥ 90%
## 16. Verify and publish
- [x] 16.1 `bunx turbo lint typecheck test --filter=@suaveplan/i18n` green
- [x] 16.2 `bunx turbo test:coverage --filter=@suaveplan/i18n` at 100/100/100/100
- [x] 16.3 `bunx turbo build --filter=@suaveplan/i18n` green
- [x] 16.4 `bun scripts/publish.ts --package @suaveplan/i18n` to Verdaccio at 0.0.0
- [x] 16.5 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-i18n/`

View File

@@ -0,0 +1,179 @@
# OpenSpec Change: enterprise-idempotency
- **Change ID**: enterprise-idempotency
- **Package**: `@suaveplan/idempotency`
- **Category**: universal
- **Target path**: `packages/universal/idempotency`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/idempotency.md`
## Summary
Greenfield implementation of `@suaveplan/idempotency` for the genesis branch. The existing main-branch idempotency package (98.68% test coverage) provides the baseline public-API surface to preserve; this change delivers enterprise-grade idempotency key guarding and deduplication with a full state machine (`pending → completed | failed`), pluggable store adapters (memory, database, Redis), distributed locking via a `LockProvider` interface, HTTP middleware, key generation and fingerprinting, key versioning, metrics collection, injectable clock and RNG, `AbortSignal` support throughout, structured errors with stable `IDEMPOTENCY.*` codes, Zod schemas, OTel instrumentation, property-based tests, and benchmarks.
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
## Available workspace deps on genesis
| Package | Available |
|---------|-----------|
| `@suaveplan/error` | ✅ |
| `@suaveplan/types` | ✅ |
| `@suaveplan/events` | ✅ |
| `@suaveplan/crypto-utils` | ✅ |
| `@suaveplan/crypto-hash` | ✅ |
| `@suaveplan/crypto-curve` | ✅ |
| `@suaveplan/crypto-cipher` | ✅ |
| `@suaveplan/crypto` | ✅ |
| `@suaveplan/crypto-keys` | ✅ |
| `@suaveplan/logger` | ✅ |
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
| `@suaveplan/streams` | ❌ not yet built — skip streaming features |
| `@suaveplan/feature-flags` | ❌ not yet built — skip |
| `@suaveplan/retry` | ❌ in progress — skip retry integration for now |
External deps (via catalog):
- `zod` → `catalog:forms`
- `@opentelemetry/api` → `catalog:observability`
- `tslib` → `catalog:build`
Dev deps (via catalog):
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
- `fast-check` → `catalog:testing`
- `mitata` → `catalog:testing`
## Preserved public API surface (from main branch)
### Distributed
- `DistributedIdempotencyGuard` class — distributed-safe guard backed by a `LockProvider`
- `LockAcquisitionError` extends `SuaveplanError`
- `MemoryLockProvider` — in-process lock provider for unit tests and single-node deployments
- `RedisLockProvider` — Redis-backed lock provider with lease/refresh semantics
- `RedisLockProviderConfig` — configuration type for `RedisLockProvider`
### Guard
- `IdempotencyConflictError` extends `SuaveplanError` — thrown when a pending record is detected for the same key
- `IdempotencyGuard` class — single-node guard with state machine and pluggable store
- `IdempotencyMismatchError` extends `SuaveplanError` — thrown when the request fingerprint differs from the stored record
### Key generation
- `compositeKey(...parts: string[]) → string` — assembles a namespaced key from ordered parts
- `fingerprintPayload(input: unknown, hashFn?) → string` — deterministic hex fingerprint of arbitrary input
- `generateIdempotencyKey(config?: KeyGeneratorConfig) → string` — generates a random, optionally prefixed key
### Error codes
- `IdempotencyErrorCode` — union type of all stable error code strings
- `IDEMPOTENCY_ERROR_METADATA` — `Record<IdempotencyErrorCode, CodeMetadata>` mapping
- `registerIdempotencyErrorCodes()` — registers all codes on the shared `ErrorCodes` singleton
### Metrics
- `IdempotencyMetrics` class — collects and exposes a rolling `IdempotencyMetricsSnapshot`
### Middleware
- `createIdempotencyMiddleware<TReq, TRes>(config)` → middleware handler function
- `MiddlewareResult<TRes>` — discriminated union returned by the middleware (`hit | miss | conflict`)
### Store adapters
- `IdempotencyStoreAdapter<T>` — interface: `get`, `set`, `delete`, `cleanup`
- `IdempotencyDatabaseAdapter` — interface for plugging in a raw database client
- `RedisClientAdapter` — interface for plugging in a raw Redis client
- `MemoryIdempotencyStore` — in-process TTL store, GC via `cleanup()`
- `DatabaseIdempotencyStore` — adapter-backed persistent store
- `DatabaseIdempotencyStoreConfig` — configuration type
- `RedisIdempotencyStore` — Redis-backed store with atomic set-if-not-exists
- `RedisIdempotencyStoreConfig` — configuration type
### Types
- `ConflictResolutionStrategy` — `'reject' | 'overwrite' | 'version'`
- `DistributedGuardConfig` — configuration for `DistributedIdempotencyGuard`
- `IdempotencyGuardConfig` — configuration for `IdempotencyGuard`
- `IdempotencyMetricsConfig` — configuration for `IdempotencyMetrics`
- `IdempotencyMetricsSnapshot` — point-in-time metrics snapshot shape
- `IdempotencyMiddlewareConfig<TReq, TRes>` — configuration for middleware
- `IdempotencyRecord<T>` — stored record shape with status, response, fingerprint, timestamps
- `IdempotencyStatus` — `'pending' | 'completed' | 'failed'`
- `IdempotencyStoreAdapter<T>` — store interface
- `KeyGeneratorConfig` — key prefix + separator options
- `KeyVersioningConfig` — store + strategy + maxVersions options
- `LockProvider` — interface: `acquire(resource, ttlMs)`, `release(resource)`
- `MetricsReporter` — callback type `(snapshot: IdempotencyMetricsSnapshot) => void`
- `VersionEntry<T>` — versioned record snapshot
### Versioning
- `KeyVersioning` class — multi-version record management with configurable conflict strategy
- `VersionConflictError` extends `SuaveplanError` — thrown when version limit is exceeded
## New additions for genesis
### Structured errors
- `IdempotencyError` base class extends `SuaveplanError` with codes under `IDEMPOTENCY.*` namespace
- Codes: `IDEMPOTENCY.KEY_CONFLICT`, `IDEMPOTENCY.RESPONSE_MISMATCH`, `IDEMPOTENCY.STORE_ERROR`, `IDEMPOTENCY.KEY_EXPIRED`, `IDEMPOTENCY.KEY_INVALID`, `IDEMPOTENCY.LOCK_ACQUISITION_FAILED`, `IDEMPOTENCY.VERSION_CONFLICT`
- `IdempotencyConflictError`, `IdempotencyMismatchError`, `LockAcquisitionError`, `VersionConflictError` all extend `IdempotencyError`
### Zod schemas
- `IdempotencyGuardConfigSchema`, `DistributedGuardConfigSchema`, `KeyGeneratorConfigSchema`, `KeyVersioningConfigSchema`, `IdempotencyMetricsConfigSchema`, `IdempotencyMiddlewareConfigSchema`
- `IdempotencyRecordSchema`, `IdempotencyStatusSchema`
### Injectable clock + RNG
- `IdempotencyGuardConfig.clock?: Clock` — for deterministic TTL and timeout computation (FakeClock in tests)
- `IdempotencyGuardConfig.rng?: Rng` — for deterministic key generation
- `DistributedGuardConfig.clock?: Clock`
- `KeyGeneratorConfig.rng?: Rng`
### AbortSignal support
- `IdempotencyGuard.execute(key, fn, opts?: { signal? })` — cancels pending-record wait on abort
- `DistributedIdempotencyGuard.execute(key, fn, opts?: { signal? })` — propagates signal through lock acquisition retries and fn invocation
### OTel instrumentation
- Counter: `idempotency.request.total` (by outcome label: `hit | miss | conflict | mismatch | error`)
- Histogram: `idempotency.execution.duration_ms` (guard execution latency)
- Counter: `idempotency.lock.acquisition.total` (by result: `acquired | failed | retried`)
- Histogram: `idempotency.lock.wait_ms` (time spent acquiring a distributed lock)
- Counter: `idempotency.store.operation.total` (by operation: `get | set | delete | cleanup`)
### Property-based tests (fast-check)
- `compositeKey` output is always deterministic for equal inputs
- `fingerprintPayload` is collision-resistant: distinct inputs produce distinct hashes across arbitrary payloads
- `generateIdempotencyKey` never produces duplicate outputs across N iterations with a mocked RNG
- `IdempotencyGuard.execute` never executes `fn` more than once for the same key when called concurrently
- `KeyVersioning` never exceeds `maxVersions` stored entries regardless of call order
### Benchmarks (mitata)
- `MemoryIdempotencyStore.get` hot path — 10 000 cached reads
- `IdempotencyGuard.execute` with cached hit — zero-execution overhead
- `compositeKey` throughput — 1 000 000 iterations
- `fingerprintPayload` throughput — 10 000 small payloads
## Notes on integration testing
- `RedisIdempotencyStore` and `RedisLockProvider` require a real Redis instance. Integration tests use a `redis://localhost:6379` connection; no mock Redis. Tests are placed in `tests/integration/` and are guarded by a `TEST_REDIS_URL` environment variable.
- `DistributedIdempotencyGuard` is unit-tested with `MemoryLockProvider` only. Integration tests covering the full distributed path with Redis are in `tests/integration/`.
- `DatabaseIdempotencyStore` integration tests require `TEST_DATABASE_URL` environment variable; unit tests use `MemoryIdempotencyStore` as a stand-in.
## Impact
- Adds one new package: `packages/universal/idempotency`
- Adds one new spec capability: `idempotency`
- No existing package consumers on genesis yet

View File

@@ -0,0 +1,186 @@
# Capability Spec: idempotency
- **Capability**: `idempotency`
- **Package**: `@suaveplan/idempotency`
- **Change**: enterprise-idempotency
- **Date**: 2026-04-19
---
## ADDED Requirements
All requirements in this section are new for genesis. The main-branch package provides the baseline public-API surface; everything below is either a carry-over preserved verbatim or a genesis-specific enhancement.
### REQ-IDMP-001 — Structured error hierarchy
The package MUST define an `IdempotencyError` base class that extends `SuaveplanError` from `@suaveplan/error`. All error types thrown by the package MUST extend `IdempotencyError`. Error codes MUST follow the `IDEMPOTENCY.*` dotted namespace and be registered with `defineCodes` / `registerIdempotencyErrorCodes()` on the shared `ErrorCodes` singleton.
Required codes:
| Code | HTTP | Severity | Retryable |
|------|------|----------|-----------|
| `IDEMPOTENCY.KEY_CONFLICT` | 409 | warning | false |
| `IDEMPOTENCY.RESPONSE_MISMATCH` | 409 | error | false |
| `IDEMPOTENCY.STORE_ERROR` | 503 | error | true (exponential, max 3) |
| `IDEMPOTENCY.KEY_EXPIRED` | 410 | warning | false |
| `IDEMPOTENCY.KEY_INVALID` | 400 | warning | false |
| `IDEMPOTENCY.LOCK_ACQUISITION_FAILED` | 503 | error | true |
| `IDEMPOTENCY.VERSION_CONFLICT` | 409 | warning | false |
### REQ-IDMP-002 — Zod schemas for all public config types
Every public configuration and record type MUST have a corresponding Zod schema exported from the package. Schemas must be usable for runtime validation at the boundary (e.g. HTTP middleware deserialization). Schema exports:
- `IdempotencyStatusSchema`
- `IdempotencyRecordSchema`
- `IdempotencyGuardConfigSchema`
- `DistributedGuardConfigSchema`
- `KeyGeneratorConfigSchema`
- `KeyVersioningConfigSchema`
- `IdempotencyMetricsConfigSchema`
- `IdempotencyMiddlewareConfigSchema`
### REQ-IDMP-003 — Injectable clock and RNG
No public API may call `Date.now()` or `Math.random()` directly. Every time-dependent and random-dependent operation MUST accept an optional `clock?: Clock` and `rng?: Rng` (from `@suaveplan/types`) so tests can run deterministically with `FakeClock` and a seeded RNG. Affected components: `IdempotencyGuard`, `DistributedIdempotencyGuard`, `IdempotencyMetrics`, `generateIdempotencyKey`.
### REQ-IDMP-004 — AbortSignal support throughout
Every async public method that performs IO or waits on an internal lock MUST accept an optional `{ signal?: AbortSignal }` option. On abort, pending waits MUST reject with a `DOMException` named `AbortError` (or an `IdempotencyError` wrapping it). The signal MUST be propagated to the user-supplied `fn` where possible. Affected: `IdempotencyGuard.execute`, `DistributedIdempotencyGuard.execute`.
### REQ-IDMP-005 — OTel instrumentation via `@opentelemetry/api`
The package MUST instrument all externally-visible operations using `@opentelemetry/api` meters. Instrumentation MUST be a no-op when no `MeterProvider` is registered. Required instruments:
| Instrument | Type | Labels |
|-----------|------|--------|
| `idempotency.request.total` | Counter | `outcome: hit \| miss \| conflict \| mismatch \| error` |
| `idempotency.execution.duration_ms` | Histogram | — |
| `idempotency.lock.acquisition.total` | Counter | `result: acquired \| failed \| retried` |
| `idempotency.lock.wait_ms` | Histogram | — |
| `idempotency.store.operation.total` | Counter | `operation: get \| set \| delete \| cleanup` |
### REQ-IDMP-006 — Property-based tests with fast-check
The following properties MUST be verified with `fast-check` arbitraries:
- `compositeKey` output is deterministic and stable for equal inputs across arbitrary string arrays.
- `fingerprintPayload` produces distinct hex strings for structurally distinct payloads with overwhelming probability (collision resistance property).
- `generateIdempotencyKey` never produces duplicate keys across N calls when supplied a deterministic RNG.
- `IdempotencyGuard.execute` never invokes `fn` more than once for the same key when called concurrently from multiple coroutines.
- `KeyVersioning` never retains more than `maxVersions` entries regardless of call ordering.
### REQ-IDMP-007 — Benchmarks with mitata
A benchmark suite MUST be maintained under `src/__benchmarks__/`. Baseline numbers MUST be published in `README.md`. Required benchmarks:
- `MemoryIdempotencyStore.get` hot path — 10 000 cached reads.
- `IdempotencyGuard.execute` with cached hit — zero-execution overhead.
- `compositeKey` throughput — 1 000 000 iterations.
- `fingerprintPayload` throughput — 10 000 small payloads.
### REQ-IDMP-008 — Store adapter extensibility
`IdempotencyStoreAdapter<T>` MUST remain a structural interface (not a class) so that downstream packages can provide custom implementations without inheriting from a base class. The four required methods are: `get(key) → Promise<IdempotencyRecord<T> | undefined>`, `set(key, record) → Promise<void>`, `delete(key) → Promise<boolean>`, `cleanup(beforeTimestamp) → Promise<number>`.
Similarly, `LockProvider` MUST remain a structural interface with `acquire(resource, ttlMs) → Promise<boolean>` and `release(resource) → Promise<void>`. Implementations (memory, Redis) are provided by the package as conveniences, not as required base classes.
### REQ-IDMP-009 — Real services for integration tests; no mock stores in unit tests
`MemoryIdempotencyStore` and `MemoryLockProvider` are full real implementations and MUST be used directly in unit tests — not as mocks. `RedisIdempotencyStore`, `RedisLockProvider`, and `DatabaseIdempotencyStore` MUST be tested against real services. Integration tests MUST be placed in `tests/integration/` and MUST be guarded by environment variables (`TEST_REDIS_URL`, `TEST_DATABASE_URL`). No `vi.mock` or `jest.mock` of store or lock provider internals is permitted.
### REQ-IDMP-010 — 100% test coverage (lines / statements / branches / functions)
As a Layer 1 foundation package, `@suaveplan/idempotency` MUST achieve 100/100/100/100 coverage (Istanbul provider). The coverage threshold MUST be enforced in `vitest.config.ts`. The `bunx turbo test:coverage --filter=@suaveplan/idempotency` command MUST exit non-zero if any threshold is not met.
---
## Public Exports
### Classes
| Export | Module |
|--------|--------|
| `IdempotencyGuard` | `src/guard/idempotency-guard` |
| `DistributedIdempotencyGuard` | `src/distributed/distributed-guard` |
| `MemoryLockProvider` | `src/distributed/memory-lock-provider` |
| `RedisLockProvider` | `src/distributed/redis-lock-provider` |
| `MemoryIdempotencyStore` | `src/store/memory-store` |
| `DatabaseIdempotencyStore` | `src/store/database-store` |
| `RedisIdempotencyStore` | `src/store/redis-store` |
| `KeyVersioning` | `src/versioning/key-versioning` |
| `IdempotencyMetrics` | `src/metrics/idempotency-metrics` |
### Error classes
| Export | Extends |
|--------|---------|
| `IdempotencyError` | `SuaveplanError` |
| `IdempotencyConflictError` | `IdempotencyError` |
| `IdempotencyMismatchError` | `IdempotencyError` |
| `LockAcquisitionError` | `IdempotencyError` |
| `VersionConflictError` | `IdempotencyError` |
### Functions
| Export | Signature |
|--------|-----------|
| `compositeKey` | `(...parts: string[]) → string` |
| `fingerprintPayload` | `(input: unknown, hashFn?: (data: Uint8Array) => string) → string` |
| `generateIdempotencyKey` | `(config?: KeyGeneratorConfig) → string` |
| `createIdempotencyMiddleware` | `<TReq, TRes>(config: IdempotencyMiddlewareConfig<TReq, TRes>) → MiddlewareHandler` |
| `registerIdempotencyErrorCodes` | `() → void` |
### Types
| Export | Kind |
|--------|------|
| `IdempotencyStatus` | type alias |
| `ConflictResolutionStrategy` | type alias |
| `IdempotencyRecord<T>` | interface |
| `IdempotencyStoreAdapter<T>` | interface |
| `IdempotencyDatabaseAdapter` | interface |
| `RedisClientAdapter` | interface |
| `LockProvider` | interface |
| `IdempotencyGuardConfig` | interface |
| `DistributedGuardConfig` | interface |
| `DatabaseIdempotencyStoreConfig` | interface |
| `RedisIdempotencyStoreConfig` | interface |
| `RedisLockProviderConfig` | interface |
| `KeyGeneratorConfig` | interface |
| `KeyVersioningConfig` | interface |
| `IdempotencyMetricsConfig` | interface |
| `IdempotencyMetricsSnapshot` | interface |
| `MetricsReporter` | type alias |
| `MiddlewareResult<TRes>` | type alias (discriminated union) |
| `VersionEntry<T>` | interface |
| `IdempotencyErrorCode` | type alias (union) |
### Constants / registries
| Export | Kind |
|--------|------|
| `IDEMPOTENCY_ERROR_METADATA` | `Readonly<Record<IdempotencyErrorCode, CodeMetadata>>` |
### Zod schemas
| Export | Validates |
|--------|-----------|
| `IdempotencyStatusSchema` | `IdempotencyStatus` |
| `IdempotencyRecordSchema` | `IdempotencyRecord` |
| `IdempotencyGuardConfigSchema` | `IdempotencyGuardConfig` |
| `DistributedGuardConfigSchema` | `DistributedGuardConfig` |
| `KeyGeneratorConfigSchema` | `KeyGeneratorConfig` |
| `KeyVersioningConfigSchema` | `KeyVersioningConfig` |
| `IdempotencyMetricsConfigSchema` | `IdempotencyMetricsConfig` |
| `IdempotencyMiddlewareConfigSchema` | `IdempotencyMiddlewareConfig` |
---
## MODIFIED Requirements
None — this is a greenfield package on genesis; there are no prior spec entries to modify.
## REMOVED Requirements
None.

View File

@@ -0,0 +1,132 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/control-flow/idempotency.
# Tasks: enterprise-idempotency
## 1. Scaffold
- [x] 1.1 Create `packages/universal/idempotency/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
## 2. Types + errors
- [x] 2.1 Implement `src/types/idempotency.types.ts` — `IdempotencyStatus`, `IdempotencyRecord<T>`, `ConflictResolutionStrategy`, `IdempotencyStoreAdapter<T>`, `IdempotencyGuardConfig`, `DistributedGuardConfig`, `KeyGeneratorConfig`, `KeyVersioningConfig`, `IdempotencyMetricsConfig`, `IdempotencyMetricsSnapshot`, `MetricsReporter`, `IdempotencyMiddlewareConfig<TReq, TRes>`, `MiddlewareResult<TRes>`, `LockProvider`, `VersionEntry<T>`
- [x] 2.2 Implement `src/errors/idempotency-error.ts` — `IdempotencyError` base extends `SuaveplanError`; `IdempotencyConflictError`, `IdempotencyMismatchError`, `LockAcquisitionError`, `VersionConflictError` extend `IdempotencyError`
- [x] 2.3 Implement `src/error-codes.ts` — `IdempotencyErrorCode` union type, `IDEMPOTENCY_ERROR_METADATA`, `registerIdempotencyErrorCodes()`; codes: `IDEMPOTENCY.KEY_CONFLICT`, `IDEMPOTENCY.RESPONSE_MISMATCH`, `IDEMPOTENCY.STORE_ERROR`, `IDEMPOTENCY.KEY_EXPIRED`, `IDEMPOTENCY.KEY_INVALID`, `IDEMPOTENCY.LOCK_ACQUISITION_FAILED`, `IDEMPOTENCY.VERSION_CONFLICT`
- [x] 2.4 Zod schemas: `IdempotencyStatusSchema`, `IdempotencyRecordSchema`, `IdempotencyGuardConfigSchema`, `DistributedGuardConfigSchema`, `KeyGeneratorConfigSchema`, `KeyVersioningConfigSchema`, `IdempotencyMetricsConfigSchema`, `IdempotencyMiddlewareConfigSchema`
- [x] 2.5 Tests: error construction, code narrowing, field presence on each error subclass, Zod schema round-trips
- [x] 2.6 Auto-register error codes via side-effect import in `src/index.ts`
## 3. Key generation
- [x] 3.1 Implement `src/key/key-generator.ts`:
- `compositeKey(...parts: string[]) → string` — joins parts with separator, rejects empty parts
- `fingerprintPayload(input: unknown, hashFn?: (data: Uint8Array) => string) → string` — deterministic hex fingerprint; uses `@suaveplan/crypto-hash` sha256 by default, accepts injectable hash function
- `generateIdempotencyKey(config?: KeyGeneratorConfig) → string` — random key with optional prefix; accepts injectable `rng?: Rng`
- [x] 3.2 Unit tests: compositeKey separator/prefix, fingerprintPayload determinism, generateIdempotencyKey uniqueness and prefix formatting
- [x] 3.3 Property-based tests: `compositeKey` output is deterministic for equal inputs; `fingerprintPayload` produces distinct results for distinct payloads; `generateIdempotencyKey` never duplicates across N calls with mocked RNG
- [x] 3.4 Co-located `src/key/key-generator.md`
## 4. Store adapters
- [x] 4.1 Implement `src/store/adapter.types.ts` — `IdempotencyDatabaseAdapter` interface, `RedisClientAdapter` interface
- [x] 4.2 Implement `src/store/memory-store.ts` — `MemoryIdempotencyStore`: TTL-aware in-process map; `cleanup(beforeTimestamp)` removes expired records; thread-safe for single-process concurrency; `size` getter
- [x] 4.3 Implement `src/store/database-store.ts` — `DatabaseIdempotencyStore` and `DatabaseIdempotencyStoreConfig`: wraps `IdempotencyDatabaseAdapter`; maps store interface to adapter calls
- [x] 4.4 Implement `src/store/redis-store.ts` — `RedisIdempotencyStore` and `RedisIdempotencyStoreConfig`: uses `RedisClientAdapter`; atomic `SET NX PX` for `set`; JSON serialization
- [x] 4.5 Unit tests for `MemoryIdempotencyStore`: get/set/delete/cleanup, TTL expiry, concurrent sets
- [x] 4.6 Integration tests for `RedisIdempotencyStore` in `tests/integration/` guarded by `TEST_REDIS_URL`
- [x] 4.7 Integration tests for `DatabaseIdempotencyStore` in `tests/integration/` guarded by `TEST_DATABASE_URL`
- [x] 4.8 Co-located `.md` for each store implementation
## 5. Guard
- [x] 5.1 Implement `src/guard/idempotency-guard.ts` — `IdempotencyGuard` class:
- `execute<T>(key, fn, opts?: { signal?: AbortSignal }) → Promise<T>`
- State machine: `pending → completed | failed`; returns cached response on hit; throws `IdempotencyConflictError` when pending; throws `IdempotencyMismatchError` on fingerprint mismatch
- `lockTimeoutMs` governs how long a pending record blocks re-execution
- `fingerprintFn` is injectable; defaults to `fingerprintPayload`
- `clock?: Clock` for deterministic timeout computation
- [x] 5.2 Honour `AbortSignal`: cancel pending-record wait on abort; propagate signal to `fn`
- [x] 5.3 Unit tests: cache hit, cache miss, conflict (pending in-flight), mismatch, failed-record retry, TTL expiry, clock injection, signal abort, `fingerprintFn` injection
- [x] 5.4 Property-based tests: `execute` never invokes `fn` more than once for the same key under concurrent callers
- [x] 5.5 Co-located `src/guard/idempotency-guard.md`
## 6. Distributed guard
- [x] 6.1 Implement `src/distributed/distributed-guard.ts` — `DistributedIdempotencyGuard` class:
- `execute<T>(key, fn, opts?: { signal?: AbortSignal }) → Promise<T>`
- Acquires lock via `LockProvider` before checking / writing store; retries on contention up to `retryCount` with `retryBaseDelayMs` exponential back-off
- Releases lock in `finally`; marks record `failed` on `fn` error
- `clock?: Clock` for injectable back-off timing
- [x] 6.2 Implement `src/distributed/memory-lock-provider.ts` — `MemoryLockProvider`: in-process TTL-aware lock map; safe for single-node and unit tests
- [x] 6.3 Implement `src/distributed/redis-lock-provider.ts` — `RedisLockProvider` and `RedisLockProviderConfig`: acquire via `SET NX PX`, release via Lua script; no Redlock dependency
- [x] 6.4 Unit tests for `DistributedIdempotencyGuard` with `MemoryLockProvider`: acquire/release lifecycle, retry on contention, abort signal, fn-error cleanup
- [x] 6.5 Integration tests for `RedisLockProvider` and the full distributed guard path in `tests/integration/` guarded by `TEST_REDIS_URL`
- [x] 6.6 Co-located `.md` for each distributed implementation file
## 7. Middleware
- [x] 7.1 Implement `src/middleware/idempotency-middleware.ts` — `createIdempotencyMiddleware<TReq, TRes>(config)`:
- Returns a handler function `(request: TReq, next: () => Promise<TRes>) → Promise<MiddlewareResult<TRes>>`
- On key absence: calls `next()` directly (passthrough)
- On cache hit: returns `{ type: 'hit', response }`
- On cache miss: executes `next()`, stores result, returns `{ type: 'miss', response }`
- On conflict or mismatch: returns `{ type: 'conflict', error }` or rethrows per `ConflictResolutionStrategy`
- Per-operation TTL overrides via `ttlOverrides` + `extractOperation`
- Injectable `fingerprintFn` and `transformResponse`
- [x] 7.2 Unit tests: hit path, miss path, conflict path, mismatch path, missing key passthrough, TTL override, transformResponse, fingerprintFn injection
- [x] 7.3 Co-located `src/middleware/idempotency-middleware.md`
## 8. Key versioning
- [x] 8.1 Implement `src/versioning/key-versioning.ts` — `KeyVersioning` class:
- `record<T>(key, fingerprint, response?, status?) → Promise<VersionEntry<T>>`
- `get<T>(key, version?) → Promise<VersionEntry<T> | undefined>` — latest or specific version
- `list<T>(key) → Promise<VersionEntry<T>[]>` — all retained versions
- Prunes oldest entries when `maxVersions` is exceeded
- Conflict strategy: `reject` throws `VersionConflictError`; `overwrite` replaces; `version` appends
- [x] 8.2 Unit tests: reject strategy, overwrite strategy, version strategy, maxVersions pruning, get by version, list
- [x] 8.3 Property-based tests: `KeyVersioning` never exceeds `maxVersions` stored entries regardless of call order
- [x] 8.4 Co-located `src/versioning/key-versioning.md`
## 9. Metrics
- [x] 9.1 Implement `src/metrics/idempotency-metrics.ts` — `IdempotencyMetrics` class:
- `record(event: MetricsEvent)` — increments counters and records durations
- `snapshot() → IdempotencyMetricsSnapshot` — computes rolling averages over `windowMs`
- `startPeriodicReporting(reporter, intervalMs)` — calls `reporter(snapshot())` on interval; returns `stopFn`
- `reset()` — clears all counters
- Injectable `clock?: Clock`
- [x] 9.2 Unit tests: counter increments, hit rate computation, avgExecutionDurationMs, rolling window cutoff, periodic reporting via FakeClock, reset
- [x] 9.3 Co-located `src/metrics/idempotency-metrics.md`
## 10. OTel instrumentation
- [x] 10.1 Counter `idempotency.request.total` by outcome label (`hit | miss | conflict | mismatch | error`)
- [x] 10.2 Histogram `idempotency.execution.duration_ms` (guard execution latency)
- [x] 10.3 Counter `idempotency.lock.acquisition.total` by result (`acquired | failed | retried`)
- [x] 10.4 Histogram `idempotency.lock.wait_ms` (time spent acquiring a distributed lock)
- [x] 10.5 Counter `idempotency.store.operation.total` by operation (`get | set | delete | cleanup`)
- [x] 10.6 Tests: verify no-op without exporter; verify counter increments with a test meter provider
## 11. Benchmarks
- [x] 11.1 `src/__benchmarks__/memory-store.bench.ts` — `MemoryIdempotencyStore.get` hot path, 10 000 cached reads (mitata)
- [x] 11.2 `src/__benchmarks__/guard.bench.ts` — `IdempotencyGuard.execute` with cached hit, zero-execution overhead
- [x] 11.3 `src/__benchmarks__/key-generator.bench.ts` — `compositeKey` throughput 1 000 000 iterations; `fingerprintPayload` throughput 10 000 small payloads
- [x] 11.4 Publish baseline numbers in README
## 12. Docs
- [x] 12.1 Co-located `.md` for every implementation file (≥200 words, simple + advanced examples, API reference, implementation notes)
- [x] 12.2 Rewrite `README.md` with public API table, guard state-machine diagram, store adapter comparison, middleware usage guide, distributed guard usage guide, consumer integration guide
- [x] 12.3 `bun run docs:check --filter=@suaveplan/idempotency` ≥ 90%
## 13. Verify and publish
- [x] 13.1 `bunx turbo lint typecheck test --filter=@suaveplan/idempotency` green
- [x] 13.2 `bunx turbo test:coverage --filter=@suaveplan/idempotency` at 100/100/100/100
- [x] 13.3 `bunx turbo build --filter=@suaveplan/idempotency` green
- [x] 13.4 `bun scripts/publish.ts --package @suaveplan/idempotency` to Verdaccio at 0.0.0
- [x] 13.5 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-idempotency/`

View File

@@ -0,0 +1,37 @@
# OpenSpec Change: enterprise-metadata
- **Change ID**: enterprise-metadata
- **Package**: `@suaveplan/audio-metadata`
- **Category**: universal
- **Target path**: `universal/data/metadata`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/metadata.md`
## Summary
Greenfield rebuild of `@suaveplan/audio-metadata` — a universal audio-and-media metadata extractor that works in both Node and browser. Parses ID3v1/v2, MP4/M4A atoms, FLAC Vorbis comments, Ogg, WAV, AIFF, and returns a normalised `Track` record (title/artist/album/cover/duration/bitrate). Supplies the data layer used by players and libraries.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
- `MetadataExtractor` (singleton `.getInstance()`) with methods:
- `extractFromUrl(url, options?): Promise<Track>`
- `extractFromBuffer(buffer, options?): Promise<Track>`
- `extractFromFile(file: File|Blob, options?): Promise<Track>` (browser)
- Types: `Track`, `AudioMetadata`, `MetadataExtractionOptions`
## New genesis additions
- Codes: `METADATA.UNSUPPORTED_FORMAT`, `METADATA.PARSE_ERROR`, `METADATA.FETCH_FAILED`.
- Zod schemas for `Track`, `AudioMetadata`.
- OTel histogram `metadata.extract.duration_ms{format}`; counter `metadata.extract.total{format, outcome}`.
- Differential tests against `music-metadata` on fixtures (parity without pulling the dependency into production runtime).
## Impact
- Adds one new package: `universal/data/metadata`
- Adds one new spec capability: `metadata`

View File

@@ -0,0 +1,27 @@
# Capability Spec: metadata
**Package**: `@suaveplan/audio-metadata`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### META-01 — Normalised Track
`Track` MUST be `{ title?, artist?, albumArtist?, album?, year?, genre?, trackNumber?, discNumber?, duration: number (seconds), bitrate?, sampleRate?, channels?, codec?, format: AudioFormat, coverArt?: Blob | Uint8Array }`. All parsers MUST produce this shape and pass `TrackSchema.parse`.
### META-02 — Runtime agnostic
`MetadataExtractor` MUST work in Node and browser. `extractFromUrl` MUST use `fetch` (global in browsers, Node ≥ 18). `extractFromFile` MUST read `File` / `Blob` via `arrayBuffer()`. No direct filesystem access in source code.
### META-03 — Format support
The extractor MUST parse MP3 (ID3v1 + ID3v2.3/2.4), M4A/MP4 (iTunes `ilst` atoms), FLAC (Vorbis comments + `METADATA_BLOCK_PICTURE`), Ogg (Vorbis), WAV (RIFF LIST INFO), AIFF (ID3). Unknown containers MUST surface as `METADATA.UNSUPPORTED_FORMAT`.
### META-04 — Streaming parse
`extractFromUrl` MUST use `Range: bytes=…` requests where the server signals `accept-ranges: bytes` to avoid downloading the full audio payload when `options.coverArt === false`.
### META-05 — Observability
Every extract call MUST record `metadata.extract.duration_ms` histogram and `metadata.extract.total{format, outcome}` counter.
### META-06 — Error reporting
Parser failures MUST surface as `METADATA.PARSE_ERROR` with `{ format, offset, reason }` context. Network failures MUST surface as `METADATA.FETCH_FAILED`.

View File

@@ -0,0 +1,30 @@
# Tasks: enterprise-metadata
## 1. Scaffold
- [x] 1.1 `universal/data/metadata/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/errors/metadata-error.ts` — codes.
- [x] 2.2 Zod schemas for `Track`, `AudioMetadata`.
## 3. Format parsers
- [x] 3.1 `src/parsers/id3v2/*`.
- [x] 3.2 `src/parsers/id3v1/*`.
- [x] 3.3 `src/parsers/mp4-atom/*` (iTunes `ilst`).
- [x] 3.4 `src/parsers/flac-vorbis/*`.
- [x] 3.5 `src/parsers/ogg-vorbis/*`.
- [x] 3.6 `src/parsers/wav-riff/*`.
- [x] 3.7 `src/parsers/aiff-id3/*`.
- [x] 3.8 Fixture tests per format with real files.
## 4. Extractor façade
- [x] 4.1 `src/metadata-extractor.ts` — `MetadataExtractor` singleton; runtime-agnostic fetch adapter (Node: `undici`, browser: `fetch`).
- [x] 4.2 OTel histogram + counter.
## 5. Docs / barrel / subpath exports / README
## 6. Benchmarks
- [x] 6.1 `extract.bench.ts` on MP3 / M4A / FLAC 5 MB fixtures.
## 7. Ship gate
- [x] 7.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,159 @@
# OpenSpec Change: enterprise-mime
- **Change ID**: enterprise-mime
- **Package**: `@suaveplan/mime`
- **Category**: universal
- **Target path**: `packages/universal/mime`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/mime.md`
## Summary
Greenfield implementation of `@suaveplan/mime` for the genesis branch. This package delivers structured MIME-type detection, parsing, and registry at enterprise quality: magic-byte sniffing via a compiled trie over a signature table, RFC 7231 `Content-Type` parsing (quoted-string parameters, charset normalisation, round-trip serialisation), a curated IANA-sourced type database with extension↔type↔category lookups, charset sniffing (BOM, HTML meta, XML declaration), an extensible signature registry, structured errors, Zod schemas, OTel instrumentation, property-based tests, and benchmarks. Zero browser/Node/React dependencies — pure TypeScript runnable in Bun, Node, and browser.
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
## Available workspace deps on genesis
| Package | Available |
|---------|-----------|
| `@suaveplan/error` | ✅ |
| `@suaveplan/types` | ✅ |
| `@suaveplan/logger` | ✅ |
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
| `@suaveplan/streams` | ❌ not yet built — `detectFromStream` is a thin wrapper over `detectFromMagicBytes` with `AbortSignal` |
External deps (via catalog):
- `zod` → `catalog:forms`
- `@opentelemetry/api` → `catalog:observability`
- `tslib` → `catalog:build`
Dev deps (via catalog):
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
- `fast-check` → `catalog:testing`
- `mitata` → `catalog:testing`
## Preserved public API surface (from main branch)
### Charset
- `type BomDetection` — detected charset from a BOM prefix
- `sniffBom(bytes)` → `BomDetection | null` — detect charset from BOM
- `sniffHtmlMeta(bytes)` → `string | null` — detect charset from HTML `<meta>` tag
- `sniffXmlDecl(bytes)` → `string | null` — detect charset from XML declaration
### Content-type
- `contentTypeEquals(a, b)` → `boolean` — structural equality ignoring parameter order
- `contentTypeMatches(actual, pattern)` → `boolean` — wildcard `*` subtype matching
- `formatContentType(parsed)` → `string` — canonical round-trip serialisation
- `type MediaType` — `{ type: string; subtype: string }`
- `type ParsedContentType` — `{ mediaType: MediaType; parameters: Record<string, string> }`
- `parseContentType(value)` → `ParsedContentType` — RFC 7231 parser with quoted-string support
### Database
- `BUILTIN_DB` — the built-in curated IANA-sourced registry (read-only reference)
- `getEntryByExtension(ext)` → `MimeEntry | undefined`
- `getMimeEntry(mimeType)` → `MimeEntry | undefined`
- `listMimeEntries()` → `MimeEntry[]`
- `type MimeCategory` — `'image' | 'audio' | 'video' | 'text' | 'application' | 'font' | 'model' | 'message' | 'multipart' | 'chemical'`
- `type MimeEntry` — `{ mimeType: string; category: MimeCategory; extensions: string[]; compressible?: boolean; charset?: string }`
- `registerMimeEntry(entry)` → `void` — add or override an entry at runtime
### Detection
- `type ContentTypeDetectionResult` — detection result sourced from a Content-Type header
- `type DetectInput` — `{ bytes?: Uint8Array; extension?: string; contentType?: string; signal?: AbortSignal }`
- `type DetectResult` — union of `MagicBytesResult | ExtensionDetectionResult | ContentTypeDetectionResult | StreamDetectionResult`
- `type DetectSource` — `'magic' | 'extension' | 'content-type' | 'stream'`
- `detect(input)` → `Promise<DetectResult>` — unified detection: tries magic bytes first, then extension, then Content-Type header
- `detectAllFromMagicBytes(bytes)` → `MagicBytesResult[]` — all matching signatures for a buffer
- `detectFromContentType(contentType)` → `ContentTypeDetectionResult` — detection from parsed header alone
- `detectFromExtension(ext)` → `ExtensionDetectionResult` — detection via extension lookup
- `detectFromMagicBytes(bytes)` → `MagicBytesResult` — best-match detection from buffer
- `detectFromStream(stream, opts?)` → `Promise<StreamDetectionResult>` — thin AbortSignal wrapper over `detectFromMagicBytes`; defers full streaming integration until `@suaveplan/streams` ships
- `type ExtensionDetectionResult` — detection result sourced from a file extension
- `type MagicBytesResult` — detection result sourced from magic bytes
- `registerSignature(sig)` → `void` — add a custom signature to the trie at runtime
- `type Signature` — `{ mimeType: string; bytes: Uint8Array; offset?: number; mask?: Uint8Array }`
- `type StreamDetectionResult` — detection result sourced from a readable stream
### Errors
- `MIME_ERROR_METADATA` — error code metadata map for `@suaveplan/error` `defineCodes`
- `type MimeErrorCode` — union of all `MIME.*` code strings
- `registerMimeErrorCodes()` — registers all MIME error codes with the process-wide registry
- `InsufficientBytesError` extends `MimeError` — thrown when the buffer is too short for magic-byte detection
- `type InsufficientBytesErrorCode`
- `type InsufficientBytesErrorContext` — `{ needed: number; got: number }`
- `type InsufficientBytesErrorOptions`
- `InvalidContentTypeError` extends `MimeError` — thrown when a Content-Type string fails to parse
- `type InvalidContentTypeErrorCode`
- `type InvalidContentTypeErrorContext` — `{ raw: string }`
- `type InvalidContentTypeErrorOptions`
- `UnknownMimeTypeError` extends `MimeError` — thrown when a MIME type string has no registry entry
- `type UnknownMimeTypeErrorCode`
- `type UnknownMimeTypeErrorContext` — `{ mimeType: string }`
- `type UnknownMimeTypeErrorOptions`
### Reverse lookups (main branch additions to preserve)
- `toAllExtensions(mimeType)` → `string[]` — all extensions mapped to a MIME type
- `toCategory(mimeType)` → `MimeCategory | undefined` — category lookup by MIME type string
- `toDefaultExtension(mimeType)` → `string | undefined` — canonical primary extension
### Legacy
- `getMimeType(ext)` → `string | undefined` — simple extension-to-mime-type lookup (legacy shim; retained for API surface completeness)
## New additions for genesis
### Structured errors
- `MimeError` base class extends `SuaveplanError` with codes under the `MIME.*` namespace
- Error codes: `MIME.PARSE_ERROR`, `MIME.INSUFFICIENT_BYTES`, `MIME.UNKNOWN_SIGNATURE`, `MIME.UNKNOWN_TYPE`, `MIME.INVALID_CONTENT_TYPE`
- `InsufficientBytesError`, `InvalidContentTypeError`, `UnknownMimeTypeError`, `ParseError`, `UnknownSignatureError` all extend `MimeError`
- Error codes registered via side-effect `import "./error-codes.js"` in `src/index.ts`
### Zod schemas
- `ParsedContentTypeSchema` — validates `ParsedContentType` at the boundary
- `MimeEntrySchema` — validates `MimeEntry` and `MimeCategory`
- `SignatureSchema` — validates a `Signature` before insertion into the trie
- `DetectResultSchema` — discriminated union validator for `DetectResult`
- `DetectInputSchema` — validates `DetectInput` options
### OTel instrumentation (via `@opentelemetry/api`, no-op without exporter)
- Counter `mime.detect.total` — by `source` label (`magic` / `extension` / `content-type` / `stream`)
- Histogram `mime.detect.duration_ms` — per `detect()` call
- Counter `mime.parse.total` — by `outcome` label (`success` / `error`)
### detectFromStream — deferred streaming, interim shim
`detectFromStream` is exported and typed as in the main branch but is implemented as a thin wrapper that reads the first chunk from the provided async iterable (or `ReadableStream`) with an `AbortSignal` and delegates to `detectFromMagicBytes`. Full streaming integration is deferred until `@suaveplan/streams` ships.
### Property-based tests (fast-check)
- `parseContentType → formatContentType` round-trip: for any valid Content-Type string, `formatContentType(parseContentType(s))` parses back to the same `ParsedContentType`
- Magic-byte detection: `detectFromMagicBytes(bytes)` always returns a result or throws `InsufficientBytesError` — never throws any other error class
- Registry round-trip: any entry registered via `registerMimeEntry(e)` is immediately retrievable via `getMimeEntry(e.mimeType)` with identical shape
### Benchmarks (mitata)
- `parseContentType` — 10 000 iterations over a representative set of realistic Content-Type strings
- `detect` from `Uint8Array` — 10 000 iterations with a 4 KB buffer covering a variety of magic-byte signatures
- `getMimeEntry` lookup — 100 000 iterations over the built-in database
## Impact
- Adds one new package: `packages/universal/mime`
- Adds one new spec capability: `mime`
- No existing package consumers on genesis yet

View File

@@ -0,0 +1,146 @@
# Capability Spec: MIME
**Package**: `@suaveplan/mime`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### MIME-01 — Magic-byte detection via compiled trie
`detectFromMagicBytes(bytes)` MUST match `bytes` against a compiled trie built from the registered signature table. Matching MUST be offset-aware (each `Signature` may declare a non-zero `offset`) and mask-aware (`Signature.mask` applies a bitmask before comparison). If `bytes` is shorter than the minimum bytes required by any registered signature, the function MUST throw `InsufficientBytesError` with `context.needed` and `context.got` populated. On success it MUST return a `MagicBytesResult` identifying the best-match MIME type and the matched `Signature`.
### MIME-02 — All-matches magic-byte detection
`detectAllFromMagicBytes(bytes)` MUST return every `MagicBytesResult` whose signature matches `bytes`, sorted by specificity (longest matching byte sequence first). It MUST NOT throw `InsufficientBytesError`; signatures that cannot be evaluated against a too-short buffer are silently skipped.
### MIME-03 — RFC 7231 Content-Type parsing
`parseContentType(value)` MUST parse a Content-Type header value per RFC 7231 §3.1.1.5. It MUST handle:
- Optional leading/trailing OWS
- Case-insensitive `type/subtype` (normalised to lowercase on output)
- Quoted-string parameter values (including escaped characters per RFC 7230 §3.2.6)
- Multiple parameters separated by `;`
- `charset` parameter normalised to lowercase
On parse failure it MUST throw `InvalidContentTypeError` (code `MIME.INVALID_CONTENT_TYPE`) with `context.raw` set to the input string.
### MIME-04 — Content-Type round-trip serialisation
`formatContentType(parsed)` MUST serialise a `ParsedContentType` to a canonical string. Parameter values that contain token-unsafe characters MUST be quoted. The round-trip invariant `parseContentType(formatContentType(p))` MUST yield a `ParsedContentType` structurally equal to `p` for any valid `p`.
### MIME-05 — Content-Type equality and matching
`contentTypeEquals(a, b)` MUST return `true` iff both parsed values have identical (case-normalised) `type`, `subtype`, and the same parameter keys and values regardless of order.
`contentTypeMatches(actual, pattern)` MUST return `true` when `pattern.subtype === '*'` and `actual.type === pattern.type`, or when both `type` and `subtype` match exactly (case-insensitive). Wildcard `*/*` MUST match any media type.
### MIME-06 — Curated IANA-sourced registry
`BUILTIN_DB` MUST contain a comprehensive, read-only set of `MimeEntry` records covering at minimum: all major image formats (PNG, JPEG, GIF, WebP, AVIF, SVG, TIFF, BMP, ICO), audio formats (MP3, OGG, FLAC, WAV, AAC, OPUS), video formats (MP4, WebM, OGV, AVI, MKV), common application types (JSON, XML, PDF, ZIP, GZIP, WASM, CBOR, MessagePack, JavaScript, CSS, HTML, plain text), and font formats (WOFF, WOFF2, TTF, OTF). Each entry MUST include `mimeType`, `category`, and `extensions`; `compressible` and `charset` are optional.
### MIME-07 — Runtime registry extension
`registerMimeEntry(entry)` MUST validate `entry` against `MimeEntrySchema` before insertion and MUST throw `ParseError` on schema violation. After registration, `getMimeEntry(entry.mimeType)` MUST return the registered entry, and `getEntryByExtension(ext)` MUST return the entry for every extension listed in `entry.extensions`. `listMimeEntries()` MUST return a sorted, deduplicated snapshot including all built-in and registered entries.
### MIME-08 — Reverse lookups
`toAllExtensions(mimeType)` MUST return all extensions associated with the given MIME type string (empty array if unknown). `toDefaultExtension(mimeType)` MUST return the first extension from the matching `MimeEntry.extensions` array, or `undefined`. `toCategory(mimeType)` MUST return the `MimeCategory` for the MIME type, or `undefined`.
### MIME-09 — BOM charset sniffing
`sniffBom(bytes)` MUST detect and return a `BomDetection` (`{ charset, bomLength }`) for the following BOM sequences, checked in order:
1. `00 00 FE FF` → `UTF-32BE`, bomLength 4
2. `FF FE 00 00` → `UTF-32LE`, bomLength 4
3. `FE FF` → `UTF-16BE`, bomLength 2
4. `FF FE` → `UTF-16LE`, bomLength 2
5. `EF BB BF` → `UTF-8`, bomLength 3
It MUST return `null` when no BOM is found. It MUST NOT throw for any input.
### MIME-10 — HTML meta charset sniffing
`sniffHtmlMeta(bytes)` MUST scan up to the first 1 024 bytes for `<meta charset="…">` and `<meta http-equiv="Content-Type" content="…; charset=…">` patterns using case-insensitive matching. It MUST return the charset string (lowercased) or `null` if not found. It MUST NOT throw for any input.
### MIME-11 — XML declaration charset sniffing
`sniffXmlDecl(bytes)` MUST extract the `encoding` attribute from an XML declaration (`<?xml … encoding="…" … ?>`) if present within the first 128 bytes. It MUST return the encoding name (uppercased to match XML convention) or `null`. It MUST NOT throw for any input.
### MIME-12 — Unified detect()
`detect(input)` MUST resolve to a `DetectResult` by applying the following priority order:
1. If `input.bytes` is provided, attempt `detectFromMagicBytes`; on `InsufficientBytesError` fall through.
2. If `input.extension` is provided, attempt `detectFromExtension`; on `UnknownMimeTypeError` fall through.
3. If `input.contentType` is provided, attempt `detectFromContentType`.
4. If all sources are exhausted, throw `UnknownMimeTypeError`.
`detect` MUST check `input.signal` before each source attempt and reject with a structured abort error if cancelled.
### MIME-13 — detectFromStream() interim shim
`detectFromStream(stream, opts?)` MUST be exported with the same signature as the main branch. It MUST read the first available chunk from the provided `ReadableStream` or async iterable, respecting `opts.signal`. It MUST delegate to `detectFromMagicBytes` with the chunk bytes and wrap the result as `StreamDetectionResult`. It MUST document in its co-located `.md` that full streaming integration is deferred pending `@suaveplan/streams`.
### MIME-14 — Signature registry extension
`registerSignature(sig)` MUST validate `sig` against `SignatureSchema` before insertion and MUST throw `ParseError` on invalid shape. After registration, the compiled trie MUST be rebuilt (lazily on next detection call) to include the new signature. `registerSignature` MUST be idempotent for identical `sig` objects.
### MIME-15 — Structured errors
All thrown errors MUST extend `MimeError` which extends `SuaveplanError`. Error codes MUST be registered via `registerMimeErrorCodes()` which is called as a side-effect of importing `src/index.ts`. The following codes MUST be defined:
- `MIME.PARSE_ERROR` — generic parse failure
- `MIME.INSUFFICIENT_BYTES` — buffer too short for magic-byte detection
- `MIME.UNKNOWN_SIGNATURE` — unrecognised or conflicting signature
- `MIME.UNKNOWN_TYPE` — MIME type not found in registry
- `MIME.INVALID_CONTENT_TYPE` — Content-Type header failed to parse
### MIME-16 — Zod schemas
The following schemas MUST be exported from their respective `*.schemas.ts` files:
- `ParsedContentTypeSchema`, `MediaTypeSchema` — from `src/schemas/content-type.schemas.ts`
- `MimeCategorySchema`, `MimeEntrySchema` — from `src/schemas/db.schemas.ts`
- `SignatureSchema`, `DetectInputSchema`, `DetectResultSchema` (discriminated union by `source`), `MagicBytesResultSchema`, `ExtensionDetectionResultSchema`, `ContentTypeDetectionResultSchema`, `StreamDetectionResultSchema` — from `src/schemas/detection.schemas.ts`
All schemas MUST be used for boundary validation in `registerMimeEntry`, `registerSignature`, and `detect`.
### MIME-17 — OTel instrumentation
The following metrics MUST be emitted via `@opentelemetry/api` using meter name `@suaveplan/mime`:
- Counter `mime.detect.total` with attribute `source` (`magic` / `extension` / `content-type` / `stream`)
- Histogram `mime.detect.duration_ms` recorded on every `detect()` call
- Counter `mime.parse.total` with attribute `outcome` (`success` / `error`) recorded on every `parseContentType()` call
All instruments MUST be no-ops when no OTel metric exporter is configured.
### MIME-18 — Legacy shim
`getMimeType(ext)` MUST be exported and MUST delegate to `getEntryByExtension(ext)?.mimeType ?? undefined`. It MUST never throw. This export is retained solely for API surface completeness.
### MIME-19 — Property-based tests
The following fast-check properties MUST be included in the test suite:
1. **Round-trip**: for any synthetically valid Content-Type string, `formatContentType(parseContentType(s))` parses back to a `ParsedContentType` that is structurally equal to the original parse result.
2. **Magic-byte exhaustiveness**: `detectFromMagicBytes(bytes)` with any `Uint8Array` either returns a `MagicBytesResult` or throws exactly `InsufficientBytesError` — no other error class is acceptable.
3. **Registry round-trip**: any `MimeEntry` satisfying `MimeEntrySchema` that is passed to `registerMimeEntry` is retrievable via `getMimeEntry` with an identical shape.
### MIME-20 — Benchmarks
The following mitata benchmark suites MUST be included under `src/__benchmarks__/`:
- `parseContentType` — 10 000 iterations over a representative set of realistic Content-Type strings
- `detect` from a 4 KB `Uint8Array` — 10 000 iterations
- `getMimeEntry` lookup — 100 000 iterations against the built-in database
Baseline numbers from the genesis run MUST be published in `README.md`.
### MIME-21 — sideEffects: false + 100% coverage
`"sideEffects": false` MUST be set in `package.json` and enforced by a tree-shake guard test in `tests/`. Vitest statement/branch/function/line thresholds MUST all be set to 100. No `.skip` or `.only` is permitted in `src/`.

View File

@@ -0,0 +1,114 @@
# Tasks: enterprise-mime
## 1. Scaffold
- [x] 1.1 Create `packages/universal/mime/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
## 2. Error types
- [x] 2.1 Implement `src/errors/mime-error.ts` — `MimeError` base extends `SuaveplanError`
- [x] 2.2 Implement error code constants in `src/error-codes.ts` — `MIME_ERROR_METADATA`, `MimeErrorCode`, `registerMimeErrorCodes()` using `defineCodes` for `MIME.PARSE_ERROR`, `MIME.INSUFFICIENT_BYTES`, `MIME.UNKNOWN_SIGNATURE`, `MIME.UNKNOWN_TYPE`, `MIME.INVALID_CONTENT_TYPE`
- [x] 2.3 Implement `src/errors/insufficient-bytes-error.ts` — `InsufficientBytesError` extends `MimeError`; `InsufficientBytesErrorCode`, `InsufficientBytesErrorContext` (`{ needed: number; got: number }`), `InsufficientBytesErrorOptions`
- [x] 2.4 Implement `src/errors/invalid-content-type-error.ts` — `InvalidContentTypeError` extends `MimeError`; context `{ raw: string }`
- [x] 2.5 Implement `src/errors/unknown-mime-type-error.ts` — `UnknownMimeTypeError` extends `MimeError`; context `{ mimeType: string }`
- [x] 2.6 Implement `src/errors/parse-error.ts` — `ParseError` extends `MimeError` (code `MIME.PARSE_ERROR`)
- [x] 2.7 Implement `src/errors/unknown-signature-error.ts` — `UnknownSignatureError` extends `MimeError` (code `MIME.UNKNOWN_SIGNATURE`)
- [x] 2.8 Tests: construction, code narrowing, context field shapes, `instanceof MimeError`
- [x] 2.9 Co-located `.md` for each error file
## 3. Zod schemas
- [x] 3.1 Implement `src/schemas/content-type.schemas.ts` — `ParsedContentTypeSchema`, `MediaTypeSchema`
- [x] 3.2 Implement `src/schemas/db.schemas.ts` — `MimeCategorySchema`, `MimeEntrySchema`
- [x] 3.3 Implement `src/schemas/detection.schemas.ts` — `SignatureSchema`, `DetectInputSchema`, `MagicBytesResultSchema`, `ExtensionDetectionResultSchema`, `ContentTypeDetectionResultSchema`, `StreamDetectionResultSchema`, `DetectResultSchema` (discriminated union)
- [x] 3.4 Tests: valid and invalid inputs for every schema; discriminated union branches
- [x] 3.5 Co-located `.md` for each schemas file
## 4. Content-type parser
- [x] 4.1 Implement `src/content-type/parse-content-type.ts` — `parseContentType(value)` → `ParsedContentType`; RFC 7231 compliant; handles quoted-string parameters, case-insensitive type/subtype, OWS trimming; throws `InvalidContentTypeError` on parse failure
- [x] 4.2 Implement `src/content-type/format-content-type.ts` — `formatContentType(parsed)` → `string`; canonical round-trip serialisation; quotes parameter values that require it
- [x] 4.3 Implement `src/content-type/content-type-equals.ts` — `contentTypeEquals(a, b)` → `boolean`; structural equality ignoring parameter order and case
- [x] 4.4 Implement `src/content-type/content-type-matches.ts` — `contentTypeMatches(actual, pattern)` → `boolean`; wildcard `*` subtype matching
- [x] 4.5 Define `src/content-type/content-type.types.ts` — `MediaType`, `ParsedContentType`
- [x] 4.6 Unit tests: RFC 7231 edge cases (quoted strings, OWS, empty params, case), equality, matching, round-trip
- [x] 4.7 Property-based tests: `formatContentType(parseContentType(s))` round-trip invariant
- [x] 4.8 Co-located `.md` for each content-type file
## 5. MIME database
- [x] 5.1 Implement `src/db/mime-entry.types.ts` — `MimeCategory`, `MimeEntry`
- [x] 5.2 Implement `src/db/builtin-db.ts` — `BUILTIN_DB`: curated, IANA-sourced, comprehensive set of `MimeEntry` records covering all major categories
- [x] 5.3 Implement `src/db/registry.ts` — mutable runtime registry layered over `BUILTIN_DB`; `getMimeEntry(mimeType)`, `getEntryByExtension(ext)`, `listMimeEntries()`, `registerMimeEntry(entry)` — throws `UnknownMimeTypeError` on missing lookup when strict mode requested
- [x] 5.4 Implement `src/reverse/reverse.ts` — `toAllExtensions(mimeType)`, `toCategory(mimeType)`, `toDefaultExtension(mimeType)`
- [x] 5.5 Implement `src/legacy/get-mime-type.ts` — `getMimeType(ext)` legacy shim delegating to `getEntryByExtension`
- [x] 5.6 Unit tests: lookups hit BUILTIN_DB; `registerMimeEntry` overrides; `listMimeEntries` returns sorted copy; reverse lookups; legacy shim
- [x] 5.7 Property-based tests: `getMimeEntry(registerMimeEntry(e).mimeType)` round-trip
- [x] 5.8 Co-located `.md` for each db file
## 6. Magic-byte detection
- [x] 6.1 Define `src/detection/signature.types.ts` — `Signature` (`{ mimeType: string; bytes: Uint8Array; offset?: number; mask?: Uint8Array }`); `MagicBytesResult`
- [x] 6.2 Implement `src/detection/trie.ts` — compiled byte trie built from the signature table; supports offset-aware matching and bitmask application; exported as `buildTrie`, `matchTrie`
- [x] 6.3 Implement `src/detection/builtin-signatures.ts` — built-in signature table covering common formats: PNG, JPEG, GIF, WebP, AVIF, PDF, ZIP, GZIP, BZIP2, XZ, WASM, ELF, MP4/ISO-BMFF, OGG, FLAC, MP3, WAV, DOCX/XLSX/PPTX (ZIP-based), 7z, RAR, TAR, SQLite, ICO, BMP, TIFF, PSD, SWF, RTF, XML, HTML, and others
- [x] 6.4 Implement `src/detection/signature-registry.ts` — `registerSignature(sig)`, `getSignatures()`, `resetSignatures()` (test-only); validates via `SignatureSchema` before insertion; throws `UnknownSignatureError` on duplicate with conflict
- [x] 6.5 Implement `src/detection/detect-from-magic-bytes.ts` — `detectFromMagicBytes(bytes)` → `MagicBytesResult`; uses compiled trie; throws `InsufficientBytesError` if buffer is shorter than the shortest registered signature
- [x] 6.6 Implement `src/detection/detect-all-from-magic-bytes.ts` — `detectAllFromMagicBytes(bytes)` → `MagicBytesResult[]`; returns all matching signatures, sorted by specificity (longest match first)
- [x] 6.7 Unit tests: known-format buffers match expected MIME types; insufficient buffer throws `InsufficientBytesError`; masked signatures; offset signatures
- [x] 6.8 Property-based tests: `detectFromMagicBytes(bytes)` is always a result or `InsufficientBytesError` — never another error class
- [x] 6.9 Co-located `.md` for each detection file in this step
## 7. Charset detection
- [x] 7.1 Implement `src/charset/sniff-bom.ts` — `sniffBom(bytes)` → `BomDetection | null`; recognises UTF-8 BOM (EF BB BF), UTF-16 BE (FE FF), UTF-16 LE (FF FE), UTF-32 BE/LE (00 00 FE FF / FF FE 00 00)
- [x] 7.2 Implement `src/charset/sniff-html-meta.ts` — `sniffHtmlMeta(bytes)` → `string | null`; scans up to 1 024 bytes for `<meta charset="…">` and `<meta http-equiv="Content-Type" content="…; charset=…">` patterns (case-insensitive)
- [x] 7.3 Implement `src/charset/sniff-xml-decl.ts` — `sniffXmlDecl(bytes)` → `string | null`; extracts `encoding="…"` from the XML declaration if present within the first 128 bytes
- [x] 7.4 Define `src/charset/charset.types.ts` — `BomDetection` (`{ charset: string; bomLength: number }`)
- [x] 7.5 Unit tests: known BOM sequences; HTML meta with various attribute orderings; XML declaration with and without encoding; non-matching inputs return null
- [x] 7.6 Co-located `.md` for each charset file
## 8. Extension + content-type detection
- [x] 8.1 Implement `src/detection/detect-from-extension.ts` — `detectFromExtension(ext)` → `ExtensionDetectionResult`; delegates to `getEntryByExtension`; normalises leading dot; throws `UnknownMimeTypeError` on unknown extension
- [x] 8.2 Implement `src/detection/detect-from-content-type.ts` — `detectFromContentType(contentType)` → `ContentTypeDetectionResult`; calls `parseContentType` then `getMimeEntry`; throws `InvalidContentTypeError` or `UnknownMimeTypeError`
- [x] 8.3 Define `src/detection/detection.types.ts` — `DetectSource`, `DetectInput`, `DetectResult`, `ExtensionDetectionResult`, `ContentTypeDetectionResult`, `StreamDetectionResult`
- [x] 8.4 Unit tests: known extensions map to correct MIME types; unknown extension throws; invalid Content-Type throws; round-trip through `formatContentType`
- [x] 8.5 Co-located `.md` for each file
## 9. Unified detect() and detectFromStream()
- [x] 9.1 Implement `src/detection/detect.ts` — `detect(input)` → `Promise<DetectResult>`; priority order: magic bytes → extension → content-type header; respects `input.signal`; validates input via `DetectInputSchema`
- [x] 9.2 Implement `src/detection/detect-from-stream.ts` — `detectFromStream(stream, opts?)` → `Promise<StreamDetectionResult>`; reads the first chunk from an async iterable or `ReadableStream`, then delegates to `detectFromMagicBytes`; honours `AbortSignal`; documents deferred-streaming intent
- [x] 9.3 Unit tests: all three detection paths exercised; signal cancellation; empty input handled gracefully
- [x] 9.4 Co-located `.md` for each file
## 10. OTel instrumentation
- [x] 10.1 Implement `src/telemetry/meters.ts` — create meter `@suaveplan/mime`; define counter `mime.detect.total` (attribute: `source`), histogram `mime.detect.duration_ms`, counter `mime.parse.total` (attribute: `outcome`)
- [x] 10.2 Wire instrumentation into `detect`, `detectFromMagicBytes`, `detectFromExtension`, `detectFromContentType`, `detectFromStream`, `parseContentType`
- [x] 10.3 Tests: confirm no-op behaviour when no OTel exporter is registered (no throw, no side-effects visible)
- [x] 10.4 Co-located `src/telemetry/meters.md`
## 11. Property tests + benchmarks
- [x] 11.1 `src/__benchmarks__/parse-content-type.bench.ts` — `parseContentType` 10 000 iterations (mitata)
- [x] 11.2 `src/__benchmarks__/detect.bench.ts` — `detect` from 4 KB `Uint8Array` 10 000 iterations
- [x] 11.3 `src/__benchmarks__/get-mime-entry.bench.ts` — `getMimeEntry` lookup 100 000 iterations
- [x] 11.4 Publish baseline numbers in `README.md`
## 12. Docs
- [x] 12.1 Co-located `.md` for every implementation file (≥200 words, simple + advanced examples, API reference, implementation notes)
- [x] 12.2 Rewrite `README.md` with full public API table, detection priority diagram, database category table, charset detection guide, consumer quick-start, and benchmark results
- [x] 12.3 `bun run docs:check --filter=@suaveplan/mime` ≥ 90%
## 13. Verify and publish
- [x] 13.1 `bunx turbo lint typecheck test --filter=@suaveplan/mime` green
- [x] 13.2 `bunx turbo test:coverage --filter=@suaveplan/mime` at 100/100/100/100
- [x] 13.3 `bunx turbo build --filter=@suaveplan/mime` green
- [x] 13.4 `bun scripts/publish.ts --package @suaveplan/mime` to Verdaccio at 0.0.0
- [x] 13.5 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-mime/`

View File

@@ -0,0 +1,62 @@
# OpenSpec Change: enterprise-oauth-client
- **Change ID**: enterprise-oauth-client
- **Package**: `@suaveplan/oauth-client`
- **Category**: universal
- **Target path**: `universal/auth/oauth-client`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/oauth-client.md`
## Summary
OAuth 2.0 + OIDC client with PKCE support, built-in provider presets (Google, Microsoft, GitHub, Apple, Discord, Spotify, Slack, Auth0, Okta, Cognito), JWT decoding, OIDC discovery, token exchange/refresh/revocation, and ID-token claim validation. Runtime-agnostic — no Node or browser assumptions.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: `@suaveplan/error`, `@suaveplan/types`
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
### Client
- `OAuthClient`, `OAuthClientOptions`
- `oauthConfigMissing` (error helper)
### Discovery
- `buildDiscoveryUrl`, `configFromDiscovery`, `fetchDiscoveryDocument`, `OIDCDiscoveryError`, `validateDiscoveryDocument`
### PKCE
- `base64UrlDecode`, `base64UrlEncode`, `createCodeChallenge`, `generateCodeVerifier`, `generateNonce`, `generatePkcePair`, `generateState`, `verifyCodeChallenge`
### Provider
- `createCustomProviderConfig`, `createProviderConfig`, `getAvailableProviders`, `getProviderPreset`, `isWellKnownProvider`
### Token
- `decodeJwt`, `exchangeCode`, `isTokenExpired`, `normalizeTokenResponse`, `OAuthTokenError`, `refreshAccessToken`, `revokeToken`, `validateIdTokenClaims`
### Types / codes
`AuthorizationParams`, `AuthorizationUrlResult`, `CodeChallengeMethod`, `FetchFunction`, `IdTokenClaims`, `IdTokenValidationOptions`, `JwtHeader`, `OAuthGrantType`, `OAuthProviderConfig`, `OIDCDiscoveryDocument`, `PkcePair`, `ProviderPreset`, `TokenExchangeParams`, `TokenRefreshParams`, `TokenResponse`, `TokenSet`, `WellKnownProvider`, `OAuthClientErrorCode`, `registerOAuthClientErrorCodes`, `OAUTH_CLIENT_ERROR_METADATA`.
## New genesis additions
- Codes via `defineCodes`: `OAUTH_CLIENT.DISCOVERY_FAILED`, `OAUTH_CLIENT.TOKEN_EXCHANGE_FAILED`, `OAUTH_CLIENT.REFRESH_FAILED`, `OAUTH_CLIENT.REVOKE_FAILED`, `OAUTH_CLIENT.INVALID_ID_TOKEN`, `OAUTH_CLIENT.INVALID_STATE`, `OAUTH_CLIENT.PKCE_MISMATCH`, `OAUTH_CLIENT.UNKNOWN_PROVIDER`.
- Zod schemas for every type.
- OTel counters per outcome; histogram `oauth_client.token_exchange.duration_ms`.
- Property tests: PKCE verify round-trip (`verifyCodeChallenge(verifier, createCodeChallenge(verifier))` always true).
## Impact
- Adds one new package: `universal/auth/oauth-client`
- Adds one new spec capability: `oauth-client`

View File

@@ -0,0 +1,41 @@
# Capability Spec: oauth-client
**Package**: `@suaveplan/oauth-client`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### OC-01 — PKCE
`generateCodeVerifier()` MUST produce a cryptographically-random string of 43–128 URL-safe characters per RFC 7636. `createCodeChallenge(verifier, method?)` MUST default `method` to `'S256'`. `verifyCodeChallenge(verifier, challenge, method?)` MUST return `true` iff `createCodeChallenge(verifier, method) === challenge`.
### OC-02 — State / nonce
`generateState()` and `generateNonce()` MUST each produce at least 128 bits of entropy encoded as Base64URL. Client-side code MUST compare state values with constant-time comparison to avoid timing oracles.
### OC-03 — Discovery
`fetchDiscoveryDocument(issuer, fetch?)` MUST fetch `${issuer}/.well-known/openid-configuration`, validate against `OIDCDiscoveryDocumentSchema`, and throw `OAUTH_CLIENT.DISCOVERY_FAILED` on network or validation failure. `configFromDiscovery` MUST produce an `OAuthProviderConfig` with all available endpoints.
### OC-04 — Provider presets
`getProviderPreset(name)` MUST return the `OAuthProviderConfig` for any `WellKnownProvider`. `getAvailableProviders()` MUST enumerate every preset. `isWellKnownProvider(name)` MUST return a type predicate.
### OC-05 — Token exchange
`exchangeCode(params)` MUST POST `application/x-www-form-urlencoded` to the token endpoint with `grant_type=authorization_code`, `code`, `redirect_uri`, `client_id`, `code_verifier`, and optional `client_secret`. A non-2xx response MUST surface as `OAUTH_CLIENT.TOKEN_EXCHANGE_FAILED` with `{ status, errorCode, errorDescription }`.
### OC-06 — Refresh / revoke
`refreshAccessToken(params)` MUST POST with `grant_type=refresh_token`. `revokeToken(params)` MUST POST to the provider's revocation endpoint per RFC 7009 and treat 200 and 204 as success.
### OC-07 — JWT decode + ID-token validation
`decodeJwt(token)` MUST return `{ header, payload, signature, raw }` without verifying the signature. `validateIdTokenClaims(claims, options)` MUST check `iss`, `aud`, `exp`, `nbf`, `iat`, `nonce`, `azp`. Failures MUST surface as `OAUTH_CLIENT.INVALID_ID_TOKEN`. Signature verification is out of scope for this capability (delegated to a future verifier).
### OC-08 — Observability
Every token operation MUST record `oauth_client.token_exchange.duration_ms` or `oauth_client.refresh.duration_ms` histograms and corresponding counters.

View File

@@ -0,0 +1,43 @@
# Tasks: enterprise-oauth-client
## 1. Scaffold
- [x] 1.1 `universal/auth/oauth-client/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/error-codes.ts` — codes + side-effect register.
- [x] 2.2 `src/config-errors.ts` — `oauthConfigMissing` helper.
- [x] 2.3 Zod schemas for every type export.
## 3. PKCE
- [x] 3.1 `src/pkce/pkce.ts` — verifier/challenge/nonce/state + Base64URL codec.
- [x] 3.2 Property tests.
## 4. Provider presets
- [x] 4.1 `src/provider/provider.ts` — built-in presets + factory functions.
- [x] 4.2 Fixture tests against each preset's discovery document.
## 5. Discovery
- [x] 5.1 `src/discovery/discovery.ts` — URL builder + fetch + validation.
## 6. Token operations
- [x] 6.1 `src/token/token.ts` — `exchangeCode`, `refreshAccessToken`, `revokeToken`, `decodeJwt`, `validateIdTokenClaims`, `normalizeTokenResponse`, `isTokenExpired`, `OAuthTokenError`.
## 7. Client
- [x] 7.1 `src/client/client.ts` — `OAuthClient` façade; uses injected `FetchFunction` for testability.
## 8. Docs / barrel / subpath exports / README
## 9. Benchmarks
- [x] 9.1 `exchangeCode.bench.ts`, `decodeJwt.bench.ts`.
## 10. Ship gate
- [x] 10.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,74 @@
# OpenSpec Change: enterprise-opentelemetry
- **Change ID**: enterprise-opentelemetry
- **Package**: `@suaveplan/opentelemetry`
- **Category**: universal
- **Target path**: `universal/platform/opentelemetry`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/opentelemetry.md`
## Summary
Zero-dependency, spec-compliant implementation of the OpenTelemetry tracing + metrics data model. Suaveplan's own tracer, meter, context, propagation, and exporter primitives — used as the default tracer when no third-party OTel SDK is wired. Includes a `SuaveplanError` interceptor that auto-records errors as span events.
## Current-state audit (genesis)
Package does not exist yet — fresh build. Genesis currently uses the upstream `@opentelemetry/api` directly; this package lands the vendor-neutral runtime substrate that makes `@opentelemetry/api` a no-op until an exporter is registered.
## Dependencies
- Workspace: `@suaveplan/error`, `@suaveplan/types`
- External (via catalog): `@opentelemetry/api` → `catalog:observability`; `zod` → `catalog:forms`; `tslib` → `catalog:build`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
### Core – context + propagation
- `Context`, `ContextManager`, `getSpanFromContext`, `setSpanInContext`
- `TextMapCarrier`, `TextMapGetter`, `TextMapPropagator`, `TextMapSetter`, `defaultTextMapGetter`, `defaultTextMapSetter`
### Core – tracer + span
- `Tracer`, `TracerProvider`, `TracerProviderConfig`, `TracerProviderOptions`, `ITracer`, `ITracerProvider`
- `Span`, `SpanConfig`, `WritableSpan`
### Core – meter
- `Meter`, `MeterProvider`, `Counter`, `Gauge`, `Histogram`
- `ICounter`, `IGauge`, `IHistogram`, `IMeter`, `IMeterProvider`
### Propagation
- Baggage: `Baggage`, `BaggageEntry`, `W3CBaggagePropagator`, `getBaggageFromContext`, `parseBaggage`, `serializeBaggage`, `setBaggageInContext`
- W3C Trace Context: `W3CTraceContextPropagator`, `getRemoteSpanContext`, `parseTraceparent`, `parseTraceState`, `serializeTraceparent`, `serializeTraceState`
### Exporters
- `ConsoleSpanExporter`, `ConsoleExporterOptions`, `InMemorySpanExporter`
### Error bridge
- `installSuaveplanErrorInterceptor`, `isSuaveplanErrorInterceptorInstalled`, `InstallSuaveplanErrorInterceptorOptions`, `UninstallSuaveplanErrorInterceptor`
### Constants / utils
- `BAGGAGE_HEADER`, `INVALID_SPAN_ID`, `INVALID_TRACE_ID`, `MAX_BAGGAGE_ENTRIES`, `MAX_SPAN_ATTRIBUTES`, `MAX_SPAN_EVENTS`, `MAX_SPAN_LINKS`, `SPAN_ID_HEX_LENGTH`, `TRACE_ID_HEX_LENGTH`, `TRACE_VERSION`, `TRACEPARENT_HEADER`, `TRACESTATE_HEADER`
- `generateSpanId`, `generateTraceId`, `hrTimeMs`, `isValidSpanId`, `isValidTraceId`
### Types
`Attributes`, `AttributeValue`, `ExportResult`, `ExportResultCode`, `HistogramDataPoint`, `HistogramOptions`, `InstrumentationScope`, `MetricDataPoint`, `MetricOptions`, `MetricValueType`, `ReadableSpan`, `Resource`, `SpanContext`, `SpanEvent`, `SpanExporter`, `SpanKind`, `SpanLink`, `SpanOptions`, `SpanStatus`, `SpanStatusCode`, `TraceFlags`.
## New genesis additions
- Codes: `OTEL.INVALID_TRACE_ID`, `OTEL.INVALID_SPAN_ID`, `OTEL.EXPORT_FAILED`, `OTEL.LIMIT_EXCEEDED`.
- Zod schemas for every constant-limit-bearing type.
- Compatibility layer tests that verify `@opentelemetry/api` defers to the package's global tracer provider when set.
- Property tests: `parseTraceparent ∘ serializeTraceparent` round-trip, valid sample-flag combinations preserved.
## Impact
- Adds one new package: `universal/platform/opentelemetry`
- Adds one new spec capability: `opentelemetry`

View File

@@ -0,0 +1,41 @@
# Capability Spec: opentelemetry
**Package**: `@suaveplan/opentelemetry`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### OT-01 — Identifier generation
`generateTraceId()` MUST return a 32-character lowercase hex string (128 bits). `generateSpanId()` MUST return a 16-character lowercase hex string (64 bits). Both MUST be cryptographically random and MUST NOT produce the all-zero IDs defined in `INVALID_TRACE_ID` / `INVALID_SPAN_ID`.
### OT-02 — W3C traceparent
`serializeTraceparent({ version, traceId, spanId, flags })` MUST emit `<version>-<traceId>-<spanId>-<flags>` with `TRACE_VERSION` as version and lowercase hex flags. `parseTraceparent(str)` MUST round-trip cleanly; invalid input MUST throw `OTEL.INVALID_TRACE_ID` or `OTEL.INVALID_SPAN_ID`.
### OT-03 — Baggage
`W3CBaggagePropagator` MUST inject a `baggage: …` header comma-separated list of `key=value` pairs. `parseBaggage(header)` MUST tolerate percent-encoding and strip entries beyond `MAX_BAGGAGE_ENTRIES` (8192 by spec).
### OT-04 — Span limits
A span MUST enforce `MAX_SPAN_ATTRIBUTES`, `MAX_SPAN_EVENTS`, `MAX_SPAN_LINKS`. Exceeding any MUST emit `OTEL.LIMIT_EXCEEDED` (dropped attribute counter) and silently drop further entries.
### OT-05 — Meter contract
`Counter.add(value, attrs?)`, `Histogram.record(value, attrs?)`, `Gauge.set(value, attrs?)` MUST accept numeric values; negative values on `Counter` MUST be rejected per spec.
### OT-06 — Error bridge
`installSuaveplanErrorInterceptor()` MUST subscribe to the `@suaveplan/error` global interceptor and, for every error thrown inside an active span, add a span event `{ name: 'exception', attributes: { 'exception.type', 'exception.message', 'exception.stacktrace', 'exception.code' } }` and set `SpanStatus.ERROR`.
### OT-07 — Exporters
`ConsoleSpanExporter.export(spans)` MUST emit one line per span to the configured logger. `InMemorySpanExporter.export(spans)` MUST append spans to an array available via `getFinishedSpans()` for tests. Both MUST return `ExportResult { code: ExportResultCode.SUCCESS }` on success.
### OT-08 — `@opentelemetry/api` compatibility
Importing the package MUST register its `TracerProvider` and `MeterProvider` as the globals through `@opentelemetry/api` when no other provider is registered. Consumers using `trace.getTracer(…)` from `@opentelemetry/api` MUST receive the Suaveplan tracer.

View File

@@ -0,0 +1,60 @@
# Tasks: enterprise-opentelemetry
## 1. Scaffold
- [x] 1.1 `universal/platform/opentelemetry/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
## 2. Errors + schemas
- [x] 2.1 `src/error-codes.ts` — `OTEL.*` codes.
- [x] 2.2 Zod schemas for wire-format types.
## 3. Constants + types
- [x] 3.1 `src/constants/index.ts`.
- [x] 3.2 `src/types/common.ts` — all enums + union shapes.
## 4. Utils
- [x] 4.1 `src/utils/id-generator.ts` — `generateSpanId`, `generateTraceId`, `isValidSpanId`, `isValidTraceId`, `hrTimeMs`.
## 5. Context
- [x] 5.1 `src/core/context/context.ts` — `Context`, `ContextManager`, span-in-context helpers.
## 6. Tracer + span
- [x] 6.1 `src/core/span/span.ts` — `Span` class.
- [x] 6.2 `src/core/tracer/tracer.ts` — `Tracer`, `TracerProvider`.
## 7. Meter
- [x] 7.1 `src/core/meter/meter.ts` — `Counter`, `Gauge`, `Histogram`, `Meter`, `MeterProvider`.
## 8. Propagation
- [x] 8.1 `src/propagation/baggage/baggage.ts` — full baggage lifecycle + W3C propagator.
- [x] 8.2 `src/propagation/w3c/w3c.ts` — W3C Trace Context propagator.
## 9. Exporters
- [x] 9.1 `src/exporters/console/console.ts` — `ConsoleSpanExporter`.
- [x] 9.2 `src/exporters/memory/memory.ts` — `InMemorySpanExporter`.
## 10. Error bridge
- [x] 10.1 `src/error-bridge/error-bridge.ts` — installs global `SuaveplanError` interceptor recording span events.
## 11. `@opentelemetry/api` compatibility
- [x] 11.1 Global-provider registration on import for drop-in use.
## 12. Docs / barrel / subpath exports / README
## 13. Benchmarks
- [x] 13.1 Span start/end hot path; meter counter/histogram record hot path.
## 14. Ship gate
- [x] 14.1 Verification sequence; fold + archive.

View File

@@ -0,0 +1,49 @@
# OpenSpec Change: enterprise-overview
- **Change ID**: enterprise-overview
- **Package**: `@suaveplan/overview`
- **Category**: universal
- **Target path**: `universal/docs/overview`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/overview.md`
## Summary
Documentation-only package publishing a curated, machine-readable description of the whole monorepo: every package name, one-line purpose, category, dependency edges, layer index, and current genesis status. Consumed by dashboards, README generators, and AI agents (including `openspec/AGENTS.md` tooling). Zero runtime code — ships a typed JSON manifest plus a validator.
## Current-state audit (genesis)
Does not exist on genesis. Genesis currently has `CLAUDE.md` / `FOLDERS.yml` as the authoritative monorepo map; this package turns them into a consumable data artifact.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `zod` → `catalog:forms`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` → `catalog:testing`
## Preserved public surface
### Data artifact
- `manifest.json` — top-level `{ generatedAt, version, categories: Category[], packages: Package[] }`
- `Category` — `{ slug, title, parentCategory?, description }`
- `Package` — `{ name, category, path, layer, summary, dependsOn: string[], status: 'genesis' | 'pending' | 'archived', version }`
### Runtime API
- `loadManifest(path?)` — load and Zod-validate a manifest file.
- `validateManifest(obj)` — `ManifestSchema.parse`.
- `layerFor(name, manifest)` — returns the topological layer (0 = leaves).
- `dependentsOf(name, manifest)`, `dependenciesOf(name, manifest)` — graph walkers.
- `ManifestSchema`, `CategorySchema`, `PackageSchema` — Zod schemas.
## New genesis additions
- Codes via `defineCodes`: `OVERVIEW.MANIFEST_INVALID`, `OVERVIEW.CYCLE_DETECTED`, `OVERVIEW.PACKAGE_NOT_FOUND`.
- Property-based tests (fast-check): cycle detection on random DAGs, topo-layer correctness.
- Build step reads `FOLDERS.yml` + every workspace `package.json` and regenerates `manifest.json` at the repo root.
## Impact
- Adds one new package: `universal/docs/overview`
- Adds one new spec capability: `overview`
- Does not depend on anything.

View File

@@ -0,0 +1,24 @@
# Capability Spec: overview
**Package**: `@suaveplan/overview`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### OVR-01 — Manifest schema
`ManifestSchema` MUST validate `{ generatedAt: ISO-8601 string, version: semver, categories: Category[], packages: Package[] }`. Every `Package.dependsOn` entry MUST reference an existing `Package.name`. Duplicate names MUST be rejected with `OVERVIEW.MANIFEST_INVALID`.
### OVR-02 — Topological layers
`layerFor(name, manifest)` MUST return a non-negative integer such that a package with `dependsOn = []` has layer 0, and every other package's layer is one more than the max of its dependencies' layers. If a cycle is detected, `layerFor` MUST throw `OVERVIEW.CYCLE_DETECTED`.
### OVR-03 — Graph walkers
`dependenciesOf(name, manifest)` MUST return the transitive closure of a package's `dependsOn` edges. `dependentsOf(name, manifest)` MUST return the transitive closure of the reverse edges. Both MUST throw `OVERVIEW.PACKAGE_NOT_FOUND` for unknown names.
### OVR-04 — Generator contract
The generator script MUST scan every workspace `package.json`, merge with `FOLDERS.yml`, and produce a deterministic `manifest.json` at the repo root. Output MUST be stable: re-running the generator with no workspace changes MUST produce byte-identical output.
### OVR-05 — Validation at the boundary
`loadManifest(path?)` MUST read and `ManifestSchema.parse` the file. Malformed JSON MUST surface as `OVERVIEW.MANIFEST_INVALID` with the path and parser reason in context.

View File

@@ -0,0 +1,31 @@
# Tasks: enterprise-overview
## 1. Scaffold
- [x] 1.1 Create `universal/docs/overview/` with package.json, tsconfig(.lib).json, vite+vitest configs, README skeleton.
- [x] 1.2 100/100/100/100 coverage thresholds; istanbul provider.
- [x] 1.3 Cross-cutting guard tests.
- [x] 1.4 Add to root `CLAUDE.md`, `README.md`, `FOLDERS.yml`.
## 2. Schemas + errors
- [x] 2.1 `src/schemas/manifest.schema.ts` — `CategorySchema`, `PackageSchema`, `ManifestSchema`.
- [x] 2.2 `src/errors/overview-error.ts` — codes `OVERVIEW.MANIFEST_INVALID`, `OVERVIEW.CYCLE_DETECTED`, `OVERVIEW.PACKAGE_NOT_FOUND`.
## 3. Runtime API
- [x] 3.1 `src/load/load.ts` — `loadManifest`, `validateManifest`.
- [x] 3.2 `src/graph/graph.ts` — `layerFor`, `dependentsOf`, `dependenciesOf`; cycle detection.
- [x] 3.3 Co-located `.md` docs.
- [x] 3.4 Property tests: random DAG → topo layers monotone; cycles detected.
## 4. Generator script
- [x] 4.1 `scripts/generate-manifest.ts` — walks workspace, parses `FOLDERS.yml`, writes `manifest.json` at repo root.
- [x] 4.2 CI task + pre-commit hook to regenerate on `package.json` change.
## 5. Docs + exports
- [x] 5.1 Subpath exports per feature.
- [x] 5.2 Root `README.md`.
## 6. Benchmarks
- [x] 6.1 `src/__benchmarks__/layer.bench.ts` — layerFor on a 300-package manifest.
## 7. Ship gate
- [x] 7.1 Pre-ship verification sequence green; fold spec; archive.

View File

@@ -0,0 +1,181 @@
# OpenSpec Change: enterprise-pagination
- **Change ID**: enterprise-pagination
- **Package**: `@suaveplan/pagination`
- **Category**: universal
- **Target path**: `packages/universal/pagination`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/pagination.md`
## Summary
Greenfield implementation of `@suaveplan/pagination` for the genesis branch. The existing main-branch pagination package (98.13% test coverage) provides the baseline public-API surface to preserve; this change delivers enterprise-grade pagination primitives covering cursor, offset, and keyset strategies with injectable clock, `AbortSignal` on all async iterators, structured errors under `PAGINATION.*` codes, Zod schemas for all request/response shapes, OTel counters and histograms on public entrypoints, property-based tests for round-trip encode/decode and monotone cursor ordering, and a mitata benchmark suite. All functions are pure — no IO — so no Redis or database containers are needed; the `MemoryPaginationAdapter` covers the full adapter interface contract under test. Streaming features dependent on `@suaveplan/streams` are deferred until that package ships.
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
## Available workspace deps on genesis
| Package | Available |
|---------|-----------|
| `@suaveplan/error` | ✅ |
| `@suaveplan/types` | ✅ |
| `@suaveplan/events` | ✅ |
| `@suaveplan/logger` | ✅ |
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
| `@suaveplan/streams` | ❌ not yet built — skip streaming features for now |
| `@suaveplan/feature-flags` | ❌ not yet built — skip |
External deps (via catalog):
- `zod` → `catalog:forms`
- `@opentelemetry/api` → `catalog:observability`
- `tslib` → `catalog:build`
Dev deps (via catalog):
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
- `fast-check` → `catalog:testing`
- `mitata` → `catalog:testing`
## Preserved public API surface (from main branch)
### Cursor pagination
- `calculateCursorMeta(opts)` → `CursorPaginationMeta`
- `createConnection(edges, meta)` → `Connection<T>`
- `createCursorResponse(data, opts)` → `CursorPaginatedResponse<T>`
- `createEdge(node, cursor)` → `Edge<T>`
- `createEdges(nodes, cursorFn)` → `Edge<T>[]`
- `cursorPaginateArray(array, opts)` → `CursorPaginatedResponse<T>`
- `normalizeCursorRequest(request)` → `CursorPaginationOptions`
- `decodeCursor(cursor)` → `string`
- `encodeCursor(value)` → `string`
- `fetchAfter(adapter, cursor, opts)` → `Promise<CursorPageResult<T>>`
- `fetchBefore(adapter, cursor, opts)` → `Promise<CursorPageResult<T>>`
- `validateCursor(cursor)` → `boolean`
### Offset pagination
- `calculateOffsetMeta(opts)` → `OffsetPaginationMeta`
- `createOffsetResponse(data, meta)` → `OffsetPaginatedResponse<T>`
- `estimateTotalCount(adapter, opts)` → `Promise<number>`
- `getPageRange(meta)` → `{ start: number; end: number }`
- `normalizeOffsetRequest(request)` → `OffsetPaginationOptions`
- `offsetToPage(offset, limit)` → `number`
- `pageToOffset(page, limit)` → `number`
- `paginateArray(array, opts)` → `OffsetPaginatedResponse<T>`
### Keyset pagination
- `decodeKeysetCursor(cursor)` → `Record<string, unknown>`
- `encodeKeysetCursor(keys)` → `string`
- `extractSortKeys(item, config)` → `KeysetSortKey[]`
- `fetchKeysetPage(adapter, opts)` → `Promise<KeysetPageResult<T>>`
- `keysetPaginate(array, opts)` → `KeysetPageResult<T>`
### Helpers
- `collectAll(iterator)` → `Promise<T[]>`
- `collectUpTo(iterator, limit)` → `Promise<T[]>`
- `createCursorIterator(fetchFn, opts)` → `AsyncIterableIterator<T>`
- `createOffsetIterator(fetchFn, opts)` → `AsyncIterableIterator<T>`
- `isPaginatedResponseEmpty(response)` → `boolean`
- `mapPaginated(response, fn)` → `PaginatedResponse<U>`
- `mergeOffsetResponses(responses)` → `OffsetPaginatedResponse<T>`
### Adapters
- `PaginationAdapter<T>` — interface: `count(opts)`, `find(opts)`, `findOne(opts)`
- `createMemoryAdapter<T>(items, config?)` → `MemoryPaginationAdapter<T>`
- `createSortConfig<T>(fields)` → `SortConfig<T>`
- `MemoryPaginationAdapter<T>` — in-memory reference implementation of `PaginationAdapter`
### Page tokens
- `createCursorToken(payload)` → `string`
- `createOffsetToken(payload)` → `string`
- `decodePageToken(token)` → `PageTokenPayload`
- `encodePageToken(payload)` → `string`
- `extractCursorValue(token)` → `string`
- `extractSortInfo(token)` → `{ field: string; direction: SortDirection }`
- `hashFilters(filters)` → `string`
- `validateTokenFilter(token, filters)` → `boolean`
### Types
- `Connection<T>`, `CursorDirection`, `CursorPageResult<T>`, `CursorPaginatedResponse<T>`
- `CursorPaginationMeta`, `CursorPaginationOptions`, `CursorPaginationRequest`
- `Edge<T>`, `FilterConfig`, `KeysetPageResult<T>`, `KeysetPaginationOptions`
- `KeysetSortKey`, `OffsetPaginatedResponse<T>`, `OffsetPaginationMeta`
- `OffsetPaginationRequest`, `PageResult<T>`, `PageTokenPayload`
- `PaginatedIteratorResult<T>`, `PaginationDefaults`, `SortConfig<T>`, `SortDirection`
### Streaming (deferred)
- `collectStream`, `countStream`, `filterStream`, `mapStream`, `paginate`, `takeFromStream` — all skipped until `@suaveplan/streams` is available on genesis.
## New additions for genesis
### Structured errors
- `PaginationError` base extends `SuaveplanError` with codes under `PAGINATION.*` namespace
- Codes:
- `PAGINATION.INVALID_CURSOR` — malformed or tampered base64 cursor string
- `PAGINATION.INVALID_PAGE` — page number out of range (< 1) or non-integer
- `PAGINATION.INVALID_LIMIT` — limit ≤ 0 or exceeds configured maximum
- `PAGINATION.INVALID_KEYSET_CURSOR` — malformed or incompatible keyset cursor
- `PAGINATION.INVALID_TOKEN` — malformed or expired page token
- `PAGINATION.INVALID_SORT` — unknown sort field or conflicting sort config
- `PAGINATION.ABORTED` — async iterator or fetch cancelled via `AbortSignal`
### Zod schemas
- `CursorPaginationRequestSchema`, `CursorPaginatedResponseSchema`
- `OffsetPaginationRequestSchema`, `OffsetPaginatedResponseSchema`
- `KeysetPaginationOptionsSchema`, `KeysetPageResultSchema`
- `PageTokenPayloadSchema`, `SortConfigSchema`, `PaginationDefaultsSchema`
- All exported from `*.schemas.ts` sibling files; validated at public API boundaries
### Injectable clock
- `CursorPaginationOptions.clock?: Clock` — used for token timestamp generation; defaults to `Date.now`
- `OffsetPaginationOptions.clock?: Clock` — same
- `Clock` sourced from `@suaveplan/types`
### AbortSignal on async iterators
- `createCursorIterator(fetchFn, opts)` — `opts.signal?: AbortSignal`; throws `PaginationError` (code `PAGINATION.ABORTED`) on abort between pages
- `createOffsetIterator(fetchFn, opts)` — same
- `collectAll` and `collectUpTo` forward the signal to the iterator
### OTel instrumentation
- Counter: `pagination.request.total` (by strategy label: cursor / offset / keyset; by outcome: success / error / aborted)
- Histogram: `pagination.page.size` (by strategy; records actual items returned per page)
- Histogram: `pagination.encode_decode.duration_ms` (by direction: encode / decode; by type: cursor / keyset / token)
- All instruments are no-ops without exporter; tested with a no-exporter pass
### Property-based tests (fast-check)
- Cursor round-trip: `decodeCursor(encodeCursor(s)) === s` for all non-empty strings
- Keyset cursor round-trip: `decodeKeysetCursor(encodeKeysetCursor(keys))` deep-equals `keys` for arbitrary record shapes
- Page token round-trip: `decodePageToken(encodePageToken(payload))` deep-equals `payload`
- Monotone cursor ordering: for any sorted array, successive cursor positions are non-decreasing
- Offset correctness: `paginateArray(arr, { page, limit })` always returns `≤ limit` items
### Benchmarks (mitata)
- `cursor-encode-decode.bench.ts` — `encodeCursor` + `decodeCursor` round-trip × 10 000 iterations
- `offset-paginate.bench.ts` — `paginateArray` on a 10 000-item array × 10 000 iterations
- `keyset-encode-decode.bench.ts` — `encodeKeysetCursor` + `decodeKeysetCursor` round-trip × 10 000 iterations
- Baseline numbers published in README
## Impact
- Adds one new package: `packages/universal/pagination`
- Adds one new spec capability: `pagination`
- No existing package consumers on genesis yet

View File

@@ -0,0 +1,107 @@
# Capability Spec: Pagination
**Package**: `@suaveplan/pagination`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### PG-01 — Cursor encode/decode contract
`encodeCursor(value)` MUST produce a base64url string with no padding characters. `decodeCursor(cursor)` MUST recover the original string exactly. A malformed cursor MUST throw `PaginationError` with code `PAGINATION.INVALID_CURSOR`. `validateCursor(cursor)` MUST return `false` rather than throw for the same malformed input.
### PG-02 — Cursor pagination normalization
`normalizeCursorRequest(request)` MUST apply default limit when none is supplied, clamp limit to the configured maximum, validate `direction` is one of `'forward' | 'backward'`, and throw `PaginationError` with code `PAGINATION.INVALID_LIMIT` if the supplied limit is ≤ 0 or exceeds the maximum. Output MUST conform to `CursorPaginationOptions`.
### PG-03 — Cursor meta and connection shape
`calculateCursorMeta(opts)` MUST derive `hasNextPage`, `hasPreviousPage`, `startCursor`, and `endCursor` from the provided edges and fetch options. `createConnection(edges, meta)` MUST return a `Connection<T>` with both fields populated. `cursorPaginateArray(array, opts)` MUST return a `CursorPaginatedResponse<T>` that includes `pageInfo` and `edges`.
### PG-04 — Cursor fetch helpers (adapter-backed)
`fetchAfter(adapter, cursor, opts)` MUST call `adapter.find` with the decoded cursor as the lower bound and return a `CursorPageResult<T>` with `edges`, `pageInfo`, and `meta`. `fetchBefore(adapter, cursor, opts)` MUST use the decoded cursor as the upper bound and reverse the result order so consumers always receive items in logical sort order.
### PG-05 — Offset pagination normalization
`normalizeOffsetRequest(request)` MUST throw `PaginationError` with code `PAGINATION.INVALID_PAGE` if `page < 1` or is non-integer, and `PAGINATION.INVALID_LIMIT` if `limit ≤ 0`. It MUST apply default values from `PaginationDefaults` when fields are absent. `pageToOffset(page, limit)` and `offsetToPage(offset, limit)` MUST be inverses of each other for all valid non-negative integer inputs.
### PG-06 — Offset pagination array and meta
`paginateArray(array, opts)` MUST return exactly `min(limit, remaining)` items starting from `pageToOffset(page, limit)`. It MUST never throw for an out-of-bounds page — it MUST return an empty `data` array and a correct `meta` indicating no items. `calculateOffsetMeta(opts)` MUST derive `totalPages`, `currentPage`, `nextPage`, `previousPage`, `hasNextPage`, and `hasPreviousPage`.
### PG-07 — Offset response merge
`mergeOffsetResponses(responses)` MUST concatenate `data` arrays in order and recompute `meta` as if the merged result were a single page starting at offset 0. The output MUST be a valid `OffsetPaginatedResponse<T>`.
### PG-08 — Keyset cursor encode/decode contract
`encodeKeysetCursor(keys)` MUST JSON-serialise the record then base64url-encode it. Non-JSON-serialisable values (e.g. `undefined`, circular references) MUST throw `PaginationError` with code `PAGINATION.INVALID_KEYSET_CURSOR`. `decodeKeysetCursor(cursor)` MUST recover the original record exactly; a malformed base64 or invalid JSON MUST throw `PAGINATION.INVALID_KEYSET_CURSOR`.
### PG-09 — Keyset pagination traversal
`keysetPaginate(array, opts)` MUST use `opts.cursor` as a non-inclusive lower bound (for forward traversal) or upper bound (for backward traversal) and return at most `opts.limit` items. `extractSortKeys(item, config)` MUST return `KeysetSortKey[]` matching `config.fields` in the declared order. `fetchKeysetPage(adapter, opts)` MUST delegate to `adapter.find` with keyset bounds and wrap the result as a `KeysetPageResult<T>`.
### PG-10 — Page token encode/decode and validation
`encodePageToken(payload)` MUST JSON-serialise and base64url-encode a `PageTokenPayload`. `decodePageToken(token)` MUST validate the result against `PageTokenPayloadSchema`; invalid structure MUST throw `PAGINATION.INVALID_TOKEN`. `hashFilters(filters)` MUST produce the same hash for deep-equal `FilterConfig` objects regardless of key insertion order. `validateTokenFilter(token, filters)` MUST return `true` only when the token's stored hash matches `hashFilters(filters)`.
### PG-11 — Injectable clock for token timestamps
`encodePageToken` and `createCursorToken` / `createOffsetToken` MUST accept an optional `clock?: Clock` from `@suaveplan/types`. When supplied, `clock.now()` MUST be used instead of `Date.now()` for any timestamp embedded in the token payload.
### PG-12 — AbortSignal on async iterators
`createCursorIterator(fetchFn, opts)` and `createOffsetIterator(fetchFn, opts)` MUST accept `opts.signal?: AbortSignal`. Before each `fetchFn` call, the iterator MUST check whether the signal is already aborted and, if so, throw `PaginationError` with code `PAGINATION.ABORTED` without calling `fetchFn`. Abort during an in-flight `fetchFn` MUST propagate naturally (the promise rejects). `collectAll` and `collectUpTo` MUST forward the signal to the underlying iterator.
### PG-13 — Adapter interface
`PaginationAdapter<T>` MUST expose three async methods: `count(opts): Promise<number>`, `find(opts): Promise<T[]>`, `findOne(opts): Promise<T | null>`. The interface MUST be generic over `T` and carry no implementation. `MemoryPaginationAdapter<T>` MUST implement all three methods against an in-memory array and honour `SortConfig` multi-field sort, cursor slicing, keyset slicing, and offset slicing in `find`. `createSortConfig<T>(fields)` MUST validate that all field names exist on `T` at the type level.
### PG-14 — Zod schemas at public boundaries
`CursorPaginationRequestSchema`, `OffsetPaginationRequestSchema`, `KeysetPaginationOptionsSchema`, and `PageTokenPayloadSchema` MUST be exported from their respective `*.schemas.ts` files. Passing an invalid value to any schema's `.parse()` MUST throw a `ZodError`; consuming code is responsible for converting that to a `PaginationError`.
### PG-15 — Structured errors
All errors thrown by the package MUST extend `PaginationError`, which MUST extend `SuaveplanError` from `@suaveplan/error`. Error codes MUST be registered via `defineCodes` under the `PAGINATION` namespace. The full code registry is: `PAGINATION.INVALID_CURSOR`, `PAGINATION.INVALID_PAGE`, `PAGINATION.INVALID_LIMIT`, `PAGINATION.INVALID_KEYSET_CURSOR`, `PAGINATION.INVALID_TOKEN`, `PAGINATION.INVALID_SORT`, `PAGINATION.ABORTED`.
### PG-16 — OTel instrumentation
The package MUST emit the following instruments via `@opentelemetry/api`:
- Counter `pagination.request.total` with attributes `strategy` (`cursor` | `offset` | `keyset`) and `outcome` (`success` | `error` | `aborted`)
- Histogram `pagination.page.size` with attribute `strategy`; records the count of items returned per page fetch
- Histogram `pagination.encode_decode.duration_ms` with attributes `direction` (`encode` | `decode`) and `type` (`cursor` | `keyset` | `token`)
All instruments MUST be no-ops when no exporter is registered. Tests MUST verify that calling instrumented functions without an exporter does not throw.
### PG-17 — Property-based test contracts
The following properties MUST be covered by fast-check tests:
- `decodeCursor(encodeCursor(s)) === s` for all non-empty strings
- `decodeKeysetCursor(encodeKeysetCursor(keys))` deep-equals `keys` for all JSON-serialisable record shapes
- `decodePageToken(encodePageToken(payload))` deep-equals `payload` for all valid `PageTokenPayload` shapes
- `paginateArray(arr, { page, limit })` returns `≤ limit` items for all valid page/limit combinations
- `pageToOffset(offsetToPage(o, l), l) === o` for all non-negative integer offsets and positive integer limits
### PG-18 — Benchmark suite
The package MUST include a `src/__benchmarks__/` directory with mitata benchmarks:
- `cursor-encode-decode.bench.ts` — `encodeCursor` + `decodeCursor` round-trip × 10 000 iterations
- `offset-paginate.bench.ts` — `paginateArray` on a 10 000-item array × 10 000 iterations
- `keyset-encode-decode.bench.ts` — `encodeKeysetCursor` + `decodeKeysetCursor` round-trip × 10 000 iterations
Baseline numbers MUST be published in `README.md`.
### PG-19 — Streaming features deferred
`collectStream`, `countStream`, `filterStream`, `mapStream`, `paginate` (stream variant), and `takeFromStream` are NOT included in this genesis implementation. They MUST be added in a follow-up change once `@suaveplan/streams` is available on genesis.
### PG-20 — sideEffects: false + coverage 100%
`"sideEffects": false` MUST be set in `package.json` and enforced by a tree-shake guard test. Vitest coverage thresholds MUST be set to 100/100/100/100 (statements/branches/functions/lines) with provider `istanbul`. No `.skip` or `.only` is permitted in `src/`.

View File

@@ -0,0 +1,146 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/data/pagination.
# Tasks: enterprise-pagination
## 1. Scaffold
- [x] 1.1 Create `packages/universal/pagination/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
## 2. Types + errors
- [x] 2.1 Implement `src/types/pagination.types.ts` — `Connection<T>`, `CursorDirection`, `CursorPageResult<T>`, `CursorPaginatedResponse<T>`, `CursorPaginationMeta`, `CursorPaginationOptions`, `CursorPaginationRequest`, `Edge<T>`, `FilterConfig`, `KeysetPageResult<T>`, `KeysetPaginationOptions`, `KeysetSortKey`, `OffsetPaginatedResponse<T>`, `OffsetPaginationMeta`, `OffsetPaginationRequest`, `PageResult<T>`, `PageTokenPayload`, `PaginatedIteratorResult<T>`, `PaginationDefaults`, `SortConfig<T>`, `SortDirection`
- [x] 2.2 Implement `src/errors/pagination-error.ts` — `PaginationError` base extends `SuaveplanError`; `defineCodes` for `PAGINATION.INVALID_CURSOR`, `PAGINATION.INVALID_PAGE`, `PAGINATION.INVALID_LIMIT`, `PAGINATION.INVALID_KEYSET_CURSOR`, `PAGINATION.INVALID_TOKEN`, `PAGINATION.INVALID_SORT`, `PAGINATION.ABORTED`
- [x] 2.3 Implement `src/error-codes.ts` and auto-register via side-effect import in `src/index.ts`
- [x] 2.4 Tests: error construction, code narrowing, error message round-trip, `defineCodes` registration
- [x] 2.5 Co-located `src/errors/pagination-error.md`
## 3. Zod schemas
- [x] 3.1 `src/cursor/cursor.schemas.ts` — `CursorPaginationRequestSchema`, `CursorPaginatedResponseSchema`, `CursorPaginationMetaSchema`, `EdgeSchema`, `ConnectionSchema`
- [x] 3.2 `src/offset/offset.schemas.ts` — `OffsetPaginationRequestSchema`, `OffsetPaginatedResponseSchema`, `OffsetPaginationMetaSchema`
- [x] 3.3 `src/keyset/keyset.schemas.ts` — `KeysetPaginationOptionsSchema`, `KeysetPageResultSchema`, `KeysetSortKeySchema`
- [x] 3.4 `src/page-token/page-token.schemas.ts` — `PageTokenPayloadSchema`
- [x] 3.5 `src/adapters/adapters.schemas.ts` — `SortConfigSchema`, `PaginationDefaultsSchema`
- [x] 3.6 Tests: valid inputs parse, invalid inputs throw with `PAGINATION.*` code, schema `.parse()` round-trips match type inferences
- [x] 3.7 Co-located `.md` for each schemas file
## 4. Cursor pagination
- [x] 4.1 Implement `src/cursor/cursor.ts`:
- `encodeCursor(value)` → `string` — base64url-encode
- `decodeCursor(cursor)` → `string` — base64url-decode; throws `PAGINATION.INVALID_CURSOR` on malformed input
- `validateCursor(cursor)` → `boolean`
- `normalizeCursorRequest(request)` → `CursorPaginationOptions` — apply defaults, clamp limit, validate direction
- `calculateCursorMeta(opts)` → `CursorPaginationMeta`
- `createEdge(node, cursor)` → `Edge<T>`
- `createEdges(nodes, cursorFn)` → `Edge<T>[]`
- `createConnection(edges, meta)` → `Connection<T>`
- `createCursorResponse(data, opts)` → `CursorPaginatedResponse<T>`
- `cursorPaginateArray(array, opts)` → `CursorPaginatedResponse<T>`
- [x] 4.2 Implement `src/cursor/cursor-pagination.ts`:
- `fetchAfter(adapter, cursor, opts)` → `Promise<CursorPageResult<T>>`
- `fetchBefore(adapter, cursor, opts)` → `Promise<CursorPageResult<T>>`
- [x] 4.3 Unit tests: encode/decode round-trips, invalid cursor error, normalization defaults, meta calculation, edge/connection construction, array pagination with and without cursors
- [x] 4.4 Property-based tests: `decodeCursor(encodeCursor(s)) === s` for all non-empty strings; monotone cursor position property on sorted arrays
- [x] 4.5 Co-located `src/cursor/cursor.md` and `src/cursor/cursor-pagination.md`
## 5. Offset pagination
- [x] 5.1 Implement `src/offset/offset.ts`:
- `pageToOffset(page, limit)` → `number`
- `offsetToPage(offset, limit)` → `number`
- `getPageRange(meta)` → `{ start: number; end: number }`
- `normalizeOffsetRequest(request)` → `OffsetPaginationOptions` — validate page ≥ 1, limit > 0; throw `PAGINATION.INVALID_PAGE` / `PAGINATION.INVALID_LIMIT`
- `calculateOffsetMeta(opts)` → `OffsetPaginationMeta`
- `createOffsetResponse(data, meta)` → `OffsetPaginatedResponse<T>`
- `paginateArray(array, opts)` → `OffsetPaginatedResponse<T>`
- `estimateTotalCount(adapter, opts)` → `Promise<number>`
- `mergeOffsetResponses(responses)` → `OffsetPaginatedResponse<T>`
- [x] 5.2 Unit tests: `pageToOffset`/`offsetToPage` inverses, normalization error paths, `paginateArray` boundary conditions (first page, last page, empty array, page beyond end), meta correctness, merge responses
- [x] 5.3 Property-based tests: `paginateArray` always returns `≤ limit` items; `pageToOffset(offsetToPage(o, l), l) === o` for valid inputs
- [x] 5.4 Co-located `src/offset/offset.md`
## 6. Keyset pagination
- [x] 6.1 Implement `src/keyset/keyset-pagination.ts`:
- `encodeKeysetCursor(keys)` → `string` — JSON + base64url; throws `PAGINATION.INVALID_KEYSET_CURSOR` on non-serialisable values
- `decodeKeysetCursor(cursor)` → `Record<string, unknown>` — base64url + JSON.parse; throws `PAGINATION.INVALID_KEYSET_CURSOR` on malformed input
- `extractSortKeys(item, config)` → `KeysetSortKey[]`
- `keysetPaginate(array, opts)` → `KeysetPageResult<T>` — pure in-memory keyset pagination over a sorted array
- `fetchKeysetPage(adapter, opts)` → `Promise<KeysetPageResult<T>>`
- [x] 6.2 Unit tests: encode/decode round-trips, invalid cursor error path, sort key extraction, `keysetPaginate` forward and backward traversal, `fetchKeysetPage` with `MemoryPaginationAdapter`
- [x] 6.3 Property-based tests: `decodeKeysetCursor(encodeKeysetCursor(keys))` deep-equals `keys` for arbitrary serialisable record shapes
- [x] 6.4 Co-located `src/keyset/keyset-pagination.md`
## 7. Page tokens
- [x] 7.1 Implement `src/page-token/page-token.ts`:
- `encodePageToken(payload)` → `string` — JSON + base64url
- `decodePageToken(token)` → `PageTokenPayload` — base64url + JSON.parse + Zod validation; throws `PAGINATION.INVALID_TOKEN` on malformed input
- `createCursorToken(payload)` → `string`
- `createOffsetToken(payload)` → `string`
- `extractCursorValue(token)` → `string`
- `extractSortInfo(token)` → `{ field: string; direction: SortDirection }`
- `hashFilters(filters)` → `string` — deterministic stable hash of a `FilterConfig`
- `validateTokenFilter(token, filters)` → `boolean` — checks `hashFilters(filters)` matches stored hash
- Injectable `clock?: Clock` for token timestamp (defaults to `Date.now`)
- [x] 7.2 Unit tests: round-trip encode/decode, invalid token error path, cursor/offset token construction, sort info extraction, filter hash stability across equivalent objects, filter validation pass/fail
- [x] 7.3 Property-based tests: `decodePageToken(encodePageToken(payload))` deep-equals `payload` for arbitrary `PageTokenPayload` shapes
- [x] 7.4 Co-located `src/page-token/page-token.md`
## 8. Helpers
- [x] 8.1 Implement `src/helpers/helpers.ts`:
- `isPaginatedResponseEmpty(response)` → `boolean`
- `mapPaginated(response, fn)` → `PaginatedResponse<U>`
- `createCursorIterator(fetchFn, opts)` → `AsyncIterableIterator<T>` — `opts.signal?: AbortSignal`; throws `PAGINATION.ABORTED` on abort between pages
- `createOffsetIterator(fetchFn, opts)` → `AsyncIterableIterator<T>` — same abort behaviour
- `collectAll(iterator, signal?)` → `Promise<T[]>` — forwards signal; respects abort mid-collect
- `collectUpTo(iterator, limit, signal?)` → `Promise<T[]>` — stops at `limit` items; respects signal
- `mergeOffsetResponses` re-exported from offset module for convenience
- [x] 8.2 Unit tests: `isPaginatedResponseEmpty` edge cases, `mapPaginated` type transformation, cursor iterator forward traversal, offset iterator forward traversal, `collectAll` / `collectUpTo` on both iterator types, abort mid-iteration
- [x] 8.3 Co-located `src/helpers/helpers.md`
## 9. Adapters
- [x] 9.1 Implement `src/adapters/database-adapter.ts`:
- `PaginationAdapter<T>` interface — `count(opts): Promise<number>`, `find(opts): Promise<T[]>`, `findOne(opts): Promise<T | null>`
- `SortConfig<T>` type and `createSortConfig<T>(fields)` factory
- `MemoryPaginationAdapter<T>` class — full in-memory implementation: sorts by `SortConfig`, applies cursor/keyset/offset slicing, respects all `PaginationAdapter` opts
- `createMemoryAdapter<T>(items, config?)` → `MemoryPaginationAdapter<T>` factory
- [x] 9.2 Unit tests: adapter `count`/`find`/`findOne`, sort config single and multi-field, ascending/descending, cursor-based slicing, keyset slicing, offset slicing, empty dataset
- [x] 9.3 Co-located `src/adapters/database-adapter.md`
## 10. OTel instrumentation
- [x] 10.1 `src/otel/instrumentation.ts` — create and export meter, register instruments:
- Counter `pagination.request.total` (attributes: `strategy`, `outcome`)
- Histogram `pagination.page.size` (attributes: `strategy`)
- Histogram `pagination.encode_decode.duration_ms` (attributes: `direction`, `type`)
- [x] 10.2 Wire counters and histograms into cursor, offset, keyset public entrypoints
- [x] 10.3 Tests: no-exporter pass (instruments do not throw), attribute labels are correct for each strategy
- [x] 10.4 Co-located `src/otel/instrumentation.md`
## 11. Benchmarks
- [x] 11.1 `src/__benchmarks__/cursor-encode-decode.bench.ts` — `encodeCursor` + `decodeCursor` round-trip × 10 000 iterations (mitata)
- [x] 11.2 `src/__benchmarks__/offset-paginate.bench.ts` — `paginateArray` on a 10 000-item array × 10 000 iterations
- [x] 11.3 `src/__benchmarks__/keyset-encode-decode.bench.ts` — `encodeKeysetCursor` + `decodeKeysetCursor` round-trip × 10 000 iterations
- [x] 11.4 Publish baseline numbers in `README.md`
## 12. Docs
- [x] 12.1 Co-located `.md` for every implementation file (≥200 words, both simple and advanced examples, full API reference)
- [x] 12.2 Rewrite `README.md` with public API surface, strategy comparison table (cursor vs offset vs keyset), iterator usage guide, adapter extension guide, OTel attributes reference, benchmark numbers, consumer migration notes
- [x] 12.3 `bun run docs:check --filter=@suaveplan/pagination` ≥ 90%
## 13. Verify and publish
- [x] 13.1 `bunx turbo lint typecheck test --filter=@suaveplan/pagination` green
- [x] 13.2 `bunx turbo test:coverage --filter=@suaveplan/pagination` at 100/100/100/100
- [x] 13.3 `bunx turbo build --filter=@suaveplan/pagination` green
- [x] 13.4 `bun scripts/publish.ts --package @suaveplan/pagination` to Verdaccio at 0.0.0
- [x] 13.5 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-pagination/`

View File

@@ -0,0 +1,38 @@
# OpenSpec Change: enterprise-password-formats
- **Change ID**: enterprise-password-formats
- **Package**: `@suaveplan/password-formats`
- **Category**: universal
- **Target path**: `universal/auth/password-formats`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/password-formats.md`
## Summary
Universal import/export layer for common password-manager export formats — Apple Passwords CSV, Bitwarden JSON, Chrome CSV, KeePass XML, LastPass CSV, 1Password `.1pux` archive and CSV. Produces a normalised `PasswordEntry[]` plus reverse CSV export.
## Current-state audit (genesis)
Package does not exist yet — fresh build.
## Dependencies
- Workspace: none (leaf)
- External (via catalog): `zod`, `papaparse`, `fast-xml-parser`, `fflate` → appropriate catalogs; `tslib`; `@opentelemetry/api`
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
## Preserved public surface
- `detect(input): PasswordFormat` — sniff format from filename/content
- `parse(input, format?): Promise<PasswordEntry[]>` — unified parse entry point
- `validate(entries): ValidationResult` — structural validation
- `exportToCsv(entries): string`
- Format-specific parsers: `parseApplePasswordsCsv`, `parseBitwardenJson`, `parseChromeCsv`, `parseKeePassXml`, `parseLastPassCsv`, `parseOnePassword1Pux`, `parseOnePasswordCsv`
- Types: `PasswordEntry`, `PasswordFormat`, `ValidationResult`, `ParseOptions`
## New genesis additions
- Codes: `PASSWORD_FORMATS.UNKNOWN_FORMAT`, `PASSWORD_FORMATS.PARSE_ERROR`, `PASSWORD_FORMATS.VALIDATION_FAILED`, `PASSWORD_FORMATS.ENCRYPTED_INPUT`.
- Zod schema for `PasswordEntry` (+ per-format raw-row schemas).
- OTel histogram `password_formats.parse.duration_ms{format}`; counter `password_formats.entries.total{format}`.
- Property tests: CSV round-trip (`parse(exportToCsv(entries))` ≈ `entries` after normalisation).
## Impact
- Adds one new package: `universal/auth/password-formats`
- Adds one new spec capability: `password-formats`

Some files were not shown because too many files have changed in this diff Show More