first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-validation/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,86 @@
# Bitpacker Validation OTel Instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OTel meter, tracer, counter, and histogram declarations for `@suaveplan/bitpacker-validation`. Ships canonical instrumentation primitives — `validationOpsTotal`, `validationOpDurationMs`, `safetyViolationsTotal`, and the `BITPACKER_VALIDATION_SPAN_NAMES.validate` span name — so consumers wiring `BitPackerValidator` or `SafetyChecker` into a pipeline can attach observability without re-deriving names. The instruments file is the canonical source of truth for the package's metric and span vocabulary.
The metric and span names follow the suaveplan convention: tracer/meter named `@suaveplan/bitpacker-validation`; span named `bitpacker_validation.validate`; counters/histograms named `bitpacker_validation.<scope>.<metric>`. This shape matches the rest of the bitpacker family so a downstream observability dashboard can group bitpacker metrics under the same namespace prefix.
## Features
- `getBitPackerValidationTracer()` — returns the package's tracer
- `validationOpsTotal` — counter labelled by `success`
- `validationOpDurationMs` — histogram of validate-call duration
- `safetyViolationsTotal` — counter labelled by safety-violation `kind`
- `BITPACKER_VALIDATION_SPAN_NAMES` — `{ validate: "bitpacker_validation.validate" }`
## Usage: Basic Example
```ts
import {
BITPACKER_VALIDATION_SPAN_NAMES,
getBitPackerValidationTracer,
validationOpsTotal,
validationOpDurationMs,
} from "@suaveplan/bitpacker-validation";
const tracer = getBitPackerValidationTracer();
tracer.startActiveSpan(BITPACKER_VALIDATION_SPAN_NAMES.validate, (span) => {
const startedAt = Date.now();
try {
// …validation work…
validationOpsTotal.add(1, { success: "true" });
} catch (err) {
validationOpsTotal.add(1, { success: "false" });
throw err;
} finally {
validationOpDurationMs.record(Date.now() - startedAt);
span.end();
}
});
```
## Usage: Advanced Example
```ts
import {
safetyViolationsTotal,
SafetyError,
} from "@suaveplan/bitpacker-validation";
function recordSafetyOutcome(err: unknown) {
if (err instanceof SafetyError) {
safetyViolationsTotal.add(1, { kind: err.code });
}
}
```
## API Reference
### `getBitPackerValidationTracer(): Tracer`
Returns the OTel `Tracer` instance for the package.
### `validationOpsTotal: Counter`
Counter incremented per top-level `validate` call. Labels: `success: "true" | "false"`.
### `validationOpDurationMs: Histogram`
Histogram of elapsed milliseconds per `validate` call.
### `safetyViolationsTotal: Counter`
Counter incremented per safety-check violation. Labels: `kind: "bufferOverflow" | "recursionOverflow" | "arrayTooLarge" | …` (one per documented safety code).
### `BITPACKER_VALIDATION_SPAN_NAMES`
Const map: `{ validate: "bitpacker_validation.validate" }`.
## Implementation Notes
- **Declare-only at this layer.** The instruments are declared centrally; wiring into the existing `BitPackerValidator` and `SafetyChecker` methods is a follow-on activity that consumers compose into their own validation pipelines. Centralizing the metric/span vocabulary here keeps the names consistent across every wire-up.
- **No per-rule spans.** Validation rules fire per field; one span per rule would explode trace volume. Rule-level outcomes are recorded as counter increments instead.

View File

@@ -0,0 +1,67 @@
# Register Schemas — `@suaveplan/bitpacker-validation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-validation` package. Exposes `registerBitPackerValidationSchemas(registry)` which converts the package's five public boundary schemas into JSON-Schema form and registers them under `bitpacker:@suaveplan/bitpacker-validation` at version 1. Re-registration on the same registry is a no-op.
The five schemas covered are: `safetyLimits` (the `SafetyChecker` constructor bundle), `safetyPerformanceOptions` (selective skip flags), `schemaSafetyLimits` (per-schema embedded `_safety` block), `bitPackerValidatorOptions` (the `BitPackerValidator` constructor bundle), and `validateSchemaOptions` (the `validateSchema` per-call bundle). Together they describe every external configuration shape the package accepts.
## Features
- `BITPACKER_VALIDATION_SUBJECT` constant — `"bitpacker:@suaveplan/bitpacker-validation"`
- `BITPACKER_VALIDATION_SCHEMA_VERSION` constant — `1`
- `registerBitPackerValidationSchemas(registry)` — idempotent registration
- Five-key payload covering safety limits, performance options, embedded limits, validator options, and per-call options
## Usage: Basic Example
```ts
import { createRegistry } from "@suaveplan/schema-registry";
import {
BITPACKER_VALIDATION_SUBJECT,
registerBitPackerValidationSchemas,
} from "@suaveplan/bitpacker-validation/register-schemas";
const registry = createRegistry();
registerBitPackerValidationSchemas(registry);
```
## Usage: Advanced Example
```ts
import { createRegistry, SchemaRegistry } from "@suaveplan/schema-registry";
import { registerBitPackerSchemas } from "@suaveplan/bitpacker/register-schemas";
import { registerBitPackerValidationSchemas } from "@suaveplan/bitpacker-validation/register-schemas";
const registry = createRegistry();
registerBitPackerSchemas(registry);
registerBitPackerValidationSchemas(registry);
const payload = registry.getLatest("bitpacker:@suaveplan/bitpacker-validation")
.schema as Record<string, unknown>;
// payload.safetyLimits, payload.bitPackerValidatorOptions, …
```
## API Reference
### `BITPACKER_VALIDATION_SUBJECT: "bitpacker:@suaveplan/bitpacker-validation"`
Const string. The exact subject under which this package's schemas are registered.
### `BITPACKER_VALIDATION_SCHEMA_VERSION: 1`
Const number. The version under which the schemas are registered.
### `registerBitPackerValidationSchemas(registry: SchemaRegistry): void`
Idempotent registration. Returns without writing if the subject is already present.
## Implementation Notes
- **Five-key payload.** Each key corresponds to one of the package's exported schemas. Downstream consumers can pick the schema by name.
- **`unrepresentable: "any"`.** Required because some schemas use `z.custom` for class instances and callables — the JSON-Schema converter cannot represent custom types and would otherwise throw.
- **Idempotent via `listSubjects()` membership check.** Same pattern as the rest of the bitpacker family.

View File

@@ -0,0 +1,93 @@
# Safety Checks
## Purpose
Production-time defensive checks against pathological inputs to the bitpacker — schemas with infinite recursion, arrays with millions of elements, objects with thousands of keys, floats that are `NaN` or `Infinity`. The `SafetyChecker` class wraps every public-boundary pack/unpack call in a lightweight check that fails fast on the limits it was constructed with, before the bitpacker hot path consumes excessive memory or compute. Safety is opt-in (each `SafetyEnabledSchema` carries an embedded `_safety` block) and selective (the `SafetyPerformanceOptions` lets a consumer disable individual checks for benchmarked-acceptable workloads).
The module also ships the `RecursionGuard` factory that the `BitPacker.unpack` path uses to enforce `maxRecursionDepth`. Without the guard, an attacker-controlled schema with a self-referential `_ref` could blow the JS stack via O(n) recursion on a large input. The guard's `withGuard(fn)` form auto-cleans up on exception so a deeply-nested unpack failure doesn't leave the depth counter wedged.
## Features
- `SafetyChecker` — main class, constructed with `SafetyLimits` + optional `SafetyPerformanceOptions`
- `createRecursionGuard(checker)` — factory for a `RecursionGuard` with `enter`/`exit`/`withGuard`
- `validatePackedSize(bytes, checker)` / `estimatePackedSize(schema, data)` — pre-flight size checks
- `SafetyError` — thrown on any limit breach, with a stable `code` field
- Six published codes: `BUFFER_OVERFLOW`, `RECURSION_OVERFLOW`, `ARRAY_TOO_LARGE`, `OBJECT_TOO_LARGE`, `INVALID_FLOAT`, `CUSTOM_VALIDATION_FAILED`
- `DEFAULT_SAFETY_LIMITS` — `{ maxBufferSize: 10MB, maxRecursionDepth: 100, maxArrayLength: 10_000, maxObjectKeys: 1_000, allowInfiniteFloats: false, allowNaNFloats: false }`
## Usage: Basic Example
```ts
import { SafetyChecker, DEFAULT_SAFETY_LIMITS } from "@suaveplan/bitpacker-validation";
const checker = new SafetyChecker(DEFAULT_SAFETY_LIMITS);
checker.checkArray(new Array(5000)); // ok
checker.checkArray(new Array(50_000)); // throws SafetyError(ARRAY_TOO_LARGE)
```
## Usage: Advanced Example
```ts
import {
SafetyChecker,
SafetyError,
createRecursionGuard,
} from "@suaveplan/bitpacker-validation";
const checker = new SafetyChecker({
maxBufferSize: 1024 * 1024,
maxRecursionDepth: 32,
maxArrayLength: 1000,
maxObjectKeys: 100,
allowInfiniteFloats: false,
allowNaNFloats: false,
});
const guard = createRecursionGuard(checker);
function unpackSafely(schema: unknown, data: unknown): unknown {
return guard.withGuard(() => {
try {
return performUnpack(schema, data);
} catch (err) {
if (err instanceof SafetyError && err.code === "RECURSION_OVERFLOW") {
// …emit metric, fall back to a depth-limited representation…
}
throw err;
}
});
}
```
## API Reference
### `class SafetyChecker`
Constructor: `new SafetyChecker(limits?, performance?)`. `limits` defaults to `DEFAULT_SAFETY_LIMITS`. `performance` defaults to `{}` (every check enabled).
#### `checkArray(arr: unknown[])`, `checkObject(obj: object)`, `checkBuffer(bytes: Uint8Array)`, `checkFloat(value: number)`
Each method throws `SafetyError` with the corresponding code on limit breach.
#### `getMetrics(): SafetyMetrics`
Returns the per-violation counters and the timestamp of the last violation.
### `function createRecursionGuard(checker: SafetyChecker): RecursionGuard`
Returns a guard with `enter()`, `exit()`, and `withGuard(fn)`. `withGuard` auto-cleans on exception.
### `class SafetyError extends Error`
`code: string`. Six published codes (see Features).
### `function estimatePackedSize(schema, data)` / `validatePackedSize(bytes, checker)`
Pre-flight estimation and post-pack validation. The estimator is cheap; use it before `checker.checkBuffer` to short-circuit clearly-oversize packs.
## Implementation Notes
- **Custom validators run after built-ins.** A `SchemaSafetyLimits.customValidators` entry resolves to a registered `CustomSafetyValidator` and runs after the built-in checks pass. A custom validator may throw `SafetyError(CUSTOM_VALIDATION_FAILED)` to surface its own constraint name.
- **`RecursionGuard.withGuard` is exception-safe.** The implementation uses `try { … } finally { exit() }` so the depth counter always decrements, even if the wrapped function throws. Calling `enter` without a matching `exit` would leak depth and cause spurious `RECURSION_OVERFLOW` later.
- **`checkFloat` is opt-in via `allowInfiniteFloats` / `allowNaNFloats`.** Both default to `false` because most game and protocol schemas have no legitimate use for `Infinity` or `NaN`. Consumers that legitimately need them set the flags to `true` and accept the risk.
- **Metrics are aggregate, not per-call.** `SafetyMetrics` accumulates violation counts across the checker's lifetime. Callers who want per-call metrics must reset the checker between calls or accumulate at their own layer.

View File

@@ -0,0 +1,89 @@
# Safety Checks Schemas
## Purpose
Zod schemas for the configuration types of `@suaveplan/bitpacker-validation`'s safety subsystem: `SafetyLimits` (the absolute caps applied by every `SafetyChecker`), `SafetyPerformanceOptions` (selective skip flags for hot-path code), and `SchemaSafetyLimits` (per-schema embedded `_safety` overrides). Each schema is used at the `SafetyChecker` constructor boundary so a misconfigured limits bundle is rejected at the call site rather than producing surprising allow/deny behaviour deep in a pack/unpack chain.
The schemas are deliberately strict — `.strict()` rejects unknown keys, and numeric fields are bounded to non-negative integers because every limit is either a count (`maxRecursionDepth`, `maxArrayLength`, `maxObjectKeys`) or a byte count (`maxBufferSize`). A negative limit would be either a developer mistake or an attempt to bypass safety; either way, refusing it at parse time prevents the SafetyChecker from entering an undefined state.
## Features
- `safetyLimitsSchema` — strict, all fields required, numerics non-negative integers
- `safetyPerformanceOptionsSchema` — strict, all `skip*` flags optional booleans
- `schemaSafetyLimitsSchema` — strict, every override optional, `customValidators` is `string[]`
- `as unknown as z.ZodType<T>` casts where Zod's `T | undefined` collides with `exactOptionalPropertyTypes`
- `.strict()` on every schema — unknown keys throw
## Usage: Basic Example
```ts
import { SafetyChecker } from "@suaveplan/bitpacker-validation";
import { safetyLimitsSchema } from "@suaveplan/bitpacker-validation";
const limits = safetyLimitsSchema.parse({
maxBufferSize: 1024 * 1024,
maxRecursionDepth: 32,
maxArrayLength: 5000,
maxObjectKeys: 200,
allowInfiniteFloats: false,
allowNaNFloats: false,
});
const checker = new SafetyChecker(limits);
```
## Usage: Advanced Example
```ts
import {
SafetyChecker,
SafetyError,
safetyLimitsSchema,
safetyPerformanceOptionsSchema,
schemaSafetyLimitsSchema,
} from "@suaveplan/bitpacker-validation";
function makeSafeChecker(rawLimits: unknown, rawPerf: unknown): SafetyChecker {
const limitsResult = safetyLimitsSchema.safeParse(rawLimits);
if (!limitsResult.success) {
throw new SafetyError(
`Invalid safety limits: ${limitsResult.error.message}`,
"INVALID_LIMITS"
);
}
const perfResult = safetyPerformanceOptionsSchema.safeParse(rawPerf);
if (!perfResult.success) {
throw new SafetyError(
`Invalid performance options: ${perfResult.error.message}`,
"INVALID_PERF"
);
}
return new SafetyChecker(limitsResult.data, perfResult.data);
}
// Embedded per-schema override:
const blob = schemaSafetyLimitsSchema.parse({
maxBufferSize: 64 * 1024,
customValidators: ["png-magic-bytes"],
});
```
## API Reference
### `safetyLimitsSchema: z.ZodType<SafetyLimits>`
Validates a fully-populated `SafetyLimits` bundle. Every numeric field is required and must be a non-negative integer; both boolean flags are required. Throws `ZodError` on missing fields, negative values, or unknown keys.
### `safetyPerformanceOptionsSchema: z.ZodType<SafetyPerformanceOptions>`
Validates a `SafetyPerformanceOptions` bundle of optional `skip*` boolean flags. An empty bundle is valid. Throws `ZodError` on unknown keys or non-boolean values.
### `schemaSafetyLimitsSchema: z.ZodType<SchemaSafetyLimits>`
Validates an embedded `_safety` block. Every field is optional. `customValidators` must be a `string[]` (the names of validators registered out-of-band). Throws `ZodError` on unknown keys, negative numerics, or non-string validator names.
## Implementation Notes
- **Non-negative integer bounds.** Every numeric field is bounded by `.int().nonnegative()`. A floating-point limit would be ambiguous (round half-up? half-down?) and a negative limit is meaningless. Refusing both at parse time keeps the SafetyChecker contract simple.
- **`as unknown as z.ZodType<T>` cast.** Optional boolean fields under `exactOptionalPropertyTypes: true` produce `T | undefined` from Zod's `.optional()`, which doesn't match the target's `?:` declaration. The cast is purely a TypeScript shim — runtime semantics are identical.
- **`.strict()` everywhere.** `SafetyChecker` reads only the documented fields. A typo'd key would otherwise be silently dropped, leaving the caller convinced they had configured a limit they hadn't. Strict-mode is the only safe default for safety configuration.
- **No cross-field validation.** The schema does not enforce e.g. `maxBufferSize ≥ maxArrayLength * minElementSize` — that's a SafetyChecker invariant, not a config invariant. The schemas validate shape; the checker validates semantics.

View File

@@ -0,0 +1,68 @@
# Safety Module Barrel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public-surface barrel for the `safety/` subdirectory. Re-exports every name that's part of the safety subsystem's published contract — `SafetyChecker`, `createRecursionGuard`, `SafetyError`, `validatePackedSize`, `estimatePackedSize`, `DEFAULT_SAFETY_LIMITS`, and the type companions `SafetyLimits`, `SafetyPerformanceOptions`, `SafetyValidationContext`, `SafetyEnabledSchema`, `SafetyMetrics`, `SafetyEvent`, `SafetyEventHook`, `SchemaSafetyLimits` — so consumers can import from `@suaveplan/bitpacker-validation` without reaching into the safety module's internals.
The barrel uses explicit named re-exports per `AGENTS.md` §6a (no `export *`), so the public contract is reviewable in one file rather than scattered across the implementation tree. Anything not re-exported here is internal to the package even if it's `export`-ed at the file level.
## Features
- `SafetyChecker` — the runtime safety enforcer
- `createRecursionGuard` — factory for the recursion-depth guard
- `SafetyError` — thrown on any limit breach
- `validatePackedSize` / `estimatePackedSize` — buffer-size helpers
- `DEFAULT_SAFETY_LIMITS` — the ready-to-use default limits bundle
- Type companions — `SafetyLimits`, `SafetyPerformanceOptions`, `SafetyValidationContext`, `SafetyEnabledSchema`, `SafetyMetrics`, `SafetyEvent`, `SafetyEventHook`, `SchemaSafetyLimits`
## Usage: Basic Example
```ts
import {
SafetyChecker,
DEFAULT_SAFETY_LIMITS,
} from "@suaveplan/bitpacker-validation";
const checker = new SafetyChecker(DEFAULT_SAFETY_LIMITS);
```
## Usage: Advanced Example
```ts
import {
SafetyChecker,
SafetyError,
createRecursionGuard,
type SafetyEvent,
type SafetyEventHook,
} from "@suaveplan/bitpacker-validation";
const onEvent: SafetyEventHook = (event: SafetyEvent) => {
console.warn(`Safety event: ${event.kind} at ${event.path}`);
};
const checker = new SafetyChecker({
maxBufferSize: 4 * 1024 * 1024,
maxRecursionDepth: 64,
maxArrayLength: 5000,
maxObjectKeys: 500,
allowInfiniteFloats: false,
allowNaNFloats: false,
}, undefined, onEvent);
const guard = createRecursionGuard(checker);
guard.withGuard(() => doRecursiveWork());
```
## API Reference
This file is a barrel; see the per-symbol documentation in `safety-checks.md` for the canonical descriptions. The barrel exists to give consumers a single import path and to prevent accidental leakage of internal helpers from `safety-checks.ts`.
## Implementation Notes
- **Explicit named re-exports.** Per `AGENTS.md` §6a, this barrel uses explicit named exports, not `export *`. Adding a new symbol to `safety-checks.ts` does NOT automatically expose it to consumers — it must be added to this barrel deliberately.
- **One barrel per subdirectory.** The `safety/` subdirectory has exactly one barrel (this file). Subdirectories with multiple feature modules use one barrel per feature, not a global subdirectory barrel.
- **No re-export of external types.** Per suaveplan convention, this barrel does NOT re-export types from `@suaveplan/types` or `@opentelemetry/api`; consumers import those directly from their packages.

View File

@@ -0,0 +1,76 @@
# Validator Options Schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas for the two configuration shapes accepted by `@suaveplan/bitpacker-validation`'s public surface: `BitPackerValidatorOptions` (the constructor bundle for `BitPackerValidator`) and `ValidateSchemaOptions` (the per-call bundle for `validateSchema`). Both are validated at the package boundary so a typo'd flag (`stricMode` for `strictMode`) fails at parse time rather than producing a false-negative validator that quietly skips checks.
The shapes are nearly identical; `ValidateSchemaOptions` adds a single `throwOnError` flag so callers can opt into exception-based control flow without constructing a long-lived validator. Both schemas are strict to reject unknown keys and use `.optional()` for every field — every option has a documented default so an empty bundle is valid.
## Features
- `bitPackerValidatorOptionsSchema` — strict, all three fields optional, `maxErrors` non-negative integer
- `validateSchemaOptionsSchema` — strict, four optional fields including `throwOnError`
- `as unknown as z.ZodType<T>` casts where Zod's `T | undefined` collides with `exactOptionalPropertyTypes`
## Usage: Basic Example
```ts
import {
BitPackerValidator,
bitPackerValidatorOptionsSchema,
} from "@suaveplan/bitpacker-validation";
const options = bitPackerValidatorOptionsSchema.parse({
strictMode: true,
maxErrors: 25,
});
const validator = new BitPackerValidator(options);
```
## Usage: Advanced Example
```ts
import {
BitPackerValidator,
BitPackerValidationError,
ErrorCodes,
bitPackerValidatorOptionsSchema,
validateSchemaOptionsSchema,
} from "@suaveplan/bitpacker-validation";
function configuredValidator(rawOpts: unknown): BitPackerValidator {
const result = bitPackerValidatorOptionsSchema.safeParse(rawOpts);
if (!result.success) {
throw new BitPackerValidationError(
ErrorCodes.INVALID_OPTIONS,
`BitPackerValidator options rejected at boundary: ${result.error.message}`
);
}
return new BitPackerValidator(result.data);
}
const callOptions = validateSchemaOptionsSchema.parse({
throwOnError: true,
strictMode: true,
});
```
## API Reference
### `bitPackerValidatorOptionsSchema: z.ZodType<BitPackerValidatorOptions>`
Strict Zod schema. Validates that the input is an object with at most three keys (`strictMode`, `checkExtraFields`, `maxErrors`) — each optional. `maxErrors` must be a non-negative integer. Throws `ZodError` on unknown keys, non-boolean flags, or out-of-range `maxErrors`.
### `validateSchemaOptionsSchema: z.ZodType<ValidateSchemaOptions>`
Strict Zod schema. Validates that the input is an object with at most four keys (the three above plus `throwOnError`) — each optional. Same numeric and boolean constraints as the validator schema.
## Implementation Notes
- **Why two near-duplicate schemas.** `BitPackerValidatorOptions` lives on the validator's lifetime; `ValidateSchemaOptions` is a per-call override that may add `throwOnError` (only meaningful for a single call). Keeping them separate prevents callers from passing `throwOnError` to the constructor where it would have no effect.
- **Strict.** A misspelled flag in either bundle would otherwise silently default to false, masking a configuration bug. `.strict()` makes the bug audible at parse time.
- **`as unknown as` cast.** Same EOPT mismatch as elsewhere in this change — Zod's `.optional()` produces `T | undefined`, the target uses `?:`. Runtime semantics are identical.

View File

@@ -0,0 +1,103 @@
# Validation Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type companions for `@suaveplan/bitpacker-validation`: the validator options bundles, the error class, the error code map, the report row and report-summary shapes, and the `ValidateSchemaOptions` per-call bundle. Every public surface of the package references one or more of these types — the `BitPackerValidator` class, the `validateSchema` free function, the `validateDataReport` helper, and the safety subsystem. Centralizing them in `types.ts` keeps the wire-format contract in one place so cross-version consumers can negotiate against a single source of truth.
The `BitPackerValidationError` class extends the platform `Error` with a stable `code` field and an optional `path` (the dot-separated field path in the offending data). Code values come from the `ErrorCodes` const map; `ErrorDescriptions` translates each code to a human-readable string for use in default error messages.
## Features
- `BitPackerValidatorOptions` — `strictMode`, `checkExtraFields`, `maxErrors` (constructor bundle for `BitPackerValidator`)
- `ValidateSchemaOptions` — same fields plus `throwOnError` (per-call bundle for `validateSchema`)
- `ValidationReport` — `{ valid, errors, warnings }` summary returned by report-style validation
- `ValidationReportItem` — `{ code, message, path?, severity }` per-row record
- `ErrorCodes` — const map of stable error codes (e.g. `INVALID_TYPE`, `OUT_OF_RANGE`, `MISSING_FIELD`, `EXTRA_FIELD`, `ARRAY_TOO_LONG`)
- `ErrorDescriptions` — `Record<ErrorCode, string>` of human-readable descriptions
- `BitPackerValidationError` — `Error` subclass with `code` and optional `path` + `expected` / `received` context
- `BitPackerValidationErrorOptions` — constructor options for the error class
## Usage: Basic Example
```ts
import {
BitPackerValidationError,
ErrorCodes,
} from "@suaveplan/bitpacker-validation";
throw new BitPackerValidationError({
code: ErrorCodes.INVALID_TYPE,
message: "Expected a number for field 'age', got string",
path: "age",
});
```
## Usage: Advanced Example
```ts
import {
BitPackerValidator,
BitPackerValidationError,
ErrorCodes,
type BitPackerValidatorOptions,
type ValidationReport,
} from "@suaveplan/bitpacker-validation";
const opts: BitPackerValidatorOptions = {
strictMode: true,
checkExtraFields: true,
maxErrors: 25,
};
const validator = new BitPackerValidator({ x: 255, name: "-32" }, opts);
const report: ValidationReport = validator.validate({ x: 100, name: "ok", extra: 1 });
if (!report.valid) {
for (const item of report.errors) {
if (item.code === ErrorCodes.EXTRA_FIELD) {
console.warn(`Extra field at ${item.path}`);
}
}
}
```
## API Reference
### `interface BitPackerValidatorOptions`
`strictMode?: boolean` (warnings become errors), `checkExtraFields?: boolean` (default `true`), `maxErrors?: number` (default `10`).
### `interface ValidateSchemaOptions extends BitPackerValidatorOptions`
Adds `throwOnError?: boolean` so `validateSchema` can opt into exception-based control flow.
### `interface ValidationReport`
`{ valid: boolean; errors: ValidationReportItem[]; warnings: ValidationReportItem[] }` — top-level summary.
### `interface ValidationReportItem`
`{ code: string; message: string; path?: string; severity: "error" | "warning" }` — one row per finding.
### `class BitPackerValidationError extends Error`
`code: string`, `path?: string`, `expected?: unknown`, `received?: unknown`. Constructed via `new BitPackerValidationError(options)`.
### `const ErrorCodes`
Frozen const map. Stable across versions; new codes append, existing codes do not change semantics.
### `const ErrorDescriptions`
`Record<string, string>` — human-readable description per code.
## Implementation Notes
- **Stable error codes are part of the wire-format contract.** Consumers MAY assert on `ErrorCodes.INVALID_TYPE` etc; renaming or repurposing a code is a behaviour change requiring a `## Migration` note per `AGENTS.md` §13a.
- **`path` is dot-separated.** `path: "user.address.zip"` for nested objects, `path: "items.3.name"` for array indices. The convention is shared with the `@suaveplan/diff` JSON-Pointer style.
- **`severity` is open.** `error` and `warning` are the documented values today; future codes may introduce `info` for advisory-only reports. Consumers switching on `severity` should use a default branch.
- **`expected` / `received` are advisory.** They are NOT included in every error — only those where the type information is meaningful (e.g. type mismatch, range violations). Consumers must treat them as optional context, not load-bearing data.

View File

@@ -0,0 +1,77 @@
# Validation Utilities
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Type guards and shape-checks shared across the `@suaveplan/bitpacker-validation` validators. These are the cheap, common predicates — "is this an object?", "is this an optional schema?", "is this a valid schema-shaped value?" — that every per-rule validator hits in its hot path. Centralizing them keeps the per-rule logic free of duplicated `typeof` ladders and ensures the predicates evolve in lockstep when new schema shapes ship.
The functions are pure and side-effect-free; they don't throw, they don't allocate, and they don't recurse. Schema-driven recursion is the caller's responsibility — these helpers answer one question at one level of the structure.
## Features
- `isObject(value)` — guards a non-null `object` (excluding arrays)
- `isOptional(value)` — recognizes the `{ _optional: ... }` schema shape
- `isValidSchema(schema)` — quick structural check that a value matches one of the published `BitPackerSchema` shapes
## Usage: Basic Example
```ts
import { isObject, isOptional } from "@suaveplan/bitpacker-validation";
if (isObject(value) && !isOptional(value)) {
// …treat value as a regular object schema or data record…
}
```
## Usage: Advanced Example
```ts
import {
isObject,
isOptional,
isValidSchema,
} from "@suaveplan/bitpacker-validation";
function validateField(schema: unknown, data: unknown, path: string) {
if (!isValidSchema(schema)) {
throw new Error(`Schema at ${path} is malformed`);
}
if (isOptional(schema) && data === undefined) {
return; // missing optional field is fine
}
if (isObject(schema) && isObject(data)) {
// recurse into the nested object
for (const key of Object.keys(schema as Record<string, unknown>)) {
validateField(
(schema as Record<string, unknown>)[key],
(data as Record<string, unknown>)[key],
`${path}.${key}`
);
}
}
}
```
## API Reference
### `isObject(value: unknown): value is object`
Returns `true` if `value` is a non-null object that is not an `Array`. Used everywhere a per-key descent into a schema or data record is appropriate.
### `isOptional(value: unknown): boolean`
Returns `true` if `value` is a non-null object with an `_optional` key. The function does NOT recursively validate the wrapped schema — that's the caller's job.
### `isValidSchema(schema: unknown): boolean`
Returns `true` if `schema` matches one of the published `BitPackerSchema` shapes (number, string, boolean, null, bigint token, array, object, or one of the discriminated wrappers `_enum`/`_optional`/`_oneOf`/`_if`/`_version`/`_ref`/`_bp`/`_customType`). Returns `false` for malformed values like functions, symbols, or unrecognized shapes.
## Implementation Notes
- **No recursion.** Each helper answers one question about one level. Nested validation is the caller's responsibility; making the helpers recursive would couple them to the validator's traversal strategy.
- **`isOptional` does not validate the wrapped schema.** A `{ _optional: <garbage> }` will pass `isOptional` and fail `isValidSchema(value._optional)`. Splitting these checks lets the caller produce more specific error messages.
- **`isValidSchema` is structural, not semantic.** It confirms the shape but does not check that referenced types in `_ref` resolve, that `_oneOf` arrays are non-empty, or that `_version._schemas` is well-ordered. Semantic validation belongs to the validator class.
- **Performance.** All three helpers are `O(1)` in the size of the input. They are safe to call from inside hot-path loops without measurable overhead.

View File

@@ -0,0 +1,64 @@
# findArrayLengthErrors
## Purpose
Per-array length-rule detector used by the recursive `validateData` walker. Given a `BitPackerSchema` array shape (`[schema]` for variable-length, `[maxLength, schema]` for max-bounded), this function checks the corresponding `data` array against the rule and pushes rows into the validator's error accumulator if the length is out of bounds. Splitting the check out of `validateData` keeps the main walker's switch ladder readable and lets the array-specific logic evolve independently of the rest of the validator.
The function is hot-path machinery; it accepts the same context shape as `validateData` (the mutable error/warning arrays + per-call options) and returns `void`. It does NOT recurse into the array elements — that's the walker's job after the length check passes.
## Features
- Detects `data.length > maxLength` violations for max-bounded arrays
- Detects non-array `data` against an array schema
- Pushes `ValidationReportItem` rows with `code: "ARRAY_TOO_LONG"` or `"INVALID_TYPE"`
- Honours `maxErrors` short-circuit
- Path-aware: passes the current `path` through to the row's `path` field
## Usage: Basic Example
```ts
import { findArrayLengthErrors, type ValidationReportItem } from "@suaveplan/bitpacker-validation";
const errors: ValidationReportItem[] = [];
findArrayLengthErrors(
[10, 255], // schema: max 10 uint8 entries
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // data: 11 entries
"items",
{ errors, warnings: [], maxErrors: 10, checkExtraFields: false, strictMode: false }
);
console.log(errors[0].code); // "ARRAY_TOO_LONG"
console.log(errors[0].path); // "items"
```
## Usage: Advanced Example
```ts
import {
findArrayLengthErrors,
ErrorCodes,
type ValidationReportItem,
} from "@suaveplan/bitpacker-validation";
const errors: ValidationReportItem[] = [];
findArrayLengthErrors(
[100, "uuid"],
"not-an-array",
"users",
{ errors, warnings: [], maxErrors: 1, checkExtraFields: false, strictMode: false }
);
// errors[0].code === ErrorCodes.INVALID_TYPE
// errors[0].path === "users"
```
## API Reference
### `findArrayLengthErrors(schema: ArraySchema, data: unknown, path: string, ctx: ValidationContext): void`
Checks `data` against the length rule encoded in `schema`. If `schema = [innerSchema]`, any array length is valid. If `schema = [maxLength, innerSchema]`, length must be `≤ maxLength`. Pushes rows into `ctx.errors` on violations.
## Implementation Notes
- **No element-level validation.** This function only handles the length rule. Element validation (each item against `innerSchema`) is the caller's responsibility — typically the recursive `validateData` walker after this check passes.
- **`maxLength: 0` is valid.** A zero max-length means an empty-array-only schema. Non-empty arrays produce `ARRAY_TOO_LONG` errors. Consumers wanting "no constraint" use the variable-length form `[schema]` instead.
- **Type errors short-circuit length checks.** A non-array `data` produces a single `INVALID_TYPE` row and the function returns; it does NOT report a derivative `ARRAY_TOO_LONG` for a non-array.
- **`maxErrors` is enforced.** Once `ctx.errors.length >= ctx.maxErrors`, the function returns without pushing additional rows.

View File

@@ -0,0 +1,78 @@
# findExtraFields
## Purpose
Extra-field detector for the object-schema branch of the recursive `validateData` walker. Given a `BitPackerSchema` object shape (`{ x: 255, name: "-32" }`) and the corresponding `data` record, this function checks whether `data` carries any keys that are NOT mentioned in `schema` and pushes warnings or errors into the validator's accumulator depending on `strictMode`. Splitting the check out of `validateData` keeps the object-walker logic readable and makes the rule discoverable.
Extra-field detection is opt-in via `ctx.checkExtraFields`. When disabled, the function is a no-op so consumers who deliberately accept additional fields (e.g. forward-compatibility shims) don't pay the cost.
## Features
- Detects keys in `data` that are not declared in `schema`
- Pushes `EXTRA_FIELD` warnings (or errors under `strictMode`)
- Honours `maxErrors` short-circuit
- Per-key `path` entries so consumers can pinpoint the offending field
- No-op when `checkExtraFields === false`
## Usage: Basic Example
```ts
import { findExtraFields, type ValidationReportItem } from "@suaveplan/bitpacker-validation";
const warnings: ValidationReportItem[] = [];
findExtraFields(
{ x: 255, name: "-32" },
{ x: 100, name: "ok", extra: 99 },
"",
{
errors: [],
warnings,
maxErrors: 10,
checkExtraFields: true,
strictMode: false,
}
);
console.log(warnings[0].code); // "EXTRA_FIELD"
console.log(warnings[0].path); // "extra"
```
## Usage: Advanced Example
```ts
import {
findExtraFields,
ErrorCodes,
type ValidationReportItem,
} from "@suaveplan/bitpacker-validation";
const errors: ValidationReportItem[] = [];
findExtraFields(
{ id: "uuid", name: "-32" },
{ id: "abc", name: "ok", legacy_field: "ignored" },
"user",
{
errors,
warnings: [],
maxErrors: 5,
checkExtraFields: true,
strictMode: true, // promotes EXTRA_FIELD to errors
}
);
console.log(errors[0].code); // ErrorCodes.EXTRA_FIELD
console.log(errors[0].path); // "user.legacy_field"
console.log(errors[0].severity); // "error"
```
## API Reference
### `findExtraFields(schema: ObjectSchema, data: object, path: string, ctx: ValidationContext): void`
Iterates `data`'s own enumerable keys; for each key not present in `schema`, pushes a row with `code: "EXTRA_FIELD"` into `ctx.warnings` (or `ctx.errors` under `strictMode`). The row's `path` is `${path}.${key}` (or just `key` if `path === ""`).
## Implementation Notes
- **Own enumerable keys only.** The function uses `Object.keys` semantics; inherited or non-enumerable properties are NOT inspected. Consumers using `Object.create` chains must flatten to plain records before validating.
- **`strictMode` promotion.** When `strictMode === true`, extra-field findings are pushed into `ctx.errors` instead of `ctx.warnings`. Severity on the row is set to match. This matches the rest of the validator's strict-mode behaviour.
- **`maxErrors` short-circuit.** Once `ctx.errors.length >= ctx.maxErrors` (or `ctx.warnings.length >= ctx.maxErrors` under non-strict mode), the function returns.
- **`checkExtraFields: false` is a hard no-op.** The function returns immediately without iterating keys. This is the right choice for forward-compatibility — a consumer accepting extra fields incurs zero check cost.
- **Path uses dot-notation.** `path: "user.legacy_field"` for a nested object; `path: "user.email"` for a sub-field. The convention is shared with `findArrayLengthErrors` and `validateData`.

View File

@@ -0,0 +1,72 @@
# validateData
## Purpose
The recursive workhorse that drives every validator entry point in `@suaveplan/bitpacker-validation`. `validateData(schema, data, path, options)` walks `schema` and `data` in lockstep, accumulating `ValidationReportItem` rows into a passed-in collector array as it descends. Every other validation entry point — `validateSchema`, `BitPackerValidator.validate`, `validateDataReport` — ultimately delegates here.
The function is structured around the schema's discriminated union: the top-level `switch`/`if`-ladder dispatches by schema kind (object / array / `_optional` / `_oneOf` / `_enum` / etc.) and recurses into the matching branch. Each branch reports a stable `code` from `ErrorCodes` so consumers can switch on the failure mode without pattern-matching the message text.
## Features
- Single recursive entry point for all validators
- Produces `ValidationReportItem` rows with stable codes (`INVALID_TYPE`, `OUT_OF_RANGE`, `MISSING_FIELD`, `EXTRA_FIELD`, `ARRAY_TOO_LONG`, …)
- Honours `maxErrors` to short-circuit on heavily-malformed input
- Honours `checkExtraFields` to opt into / out of extra-field detection
- Composes with `findExtraFields` and `findArrayLengthErrors` for the per-rule detail checks
- Path-aware reporting via dot-separated `path` field on each row
## Usage: Basic Example
```ts
import { validateData, type ValidationReportItem } from "@suaveplan/bitpacker-validation";
const errors: ValidationReportItem[] = [];
validateData(
{ x: 255, name: "-32" },
{ x: 100, name: "ok" },
"",
{ errors, warnings: [], maxErrors: 10, checkExtraFields: true, strictMode: false }
);
console.log(errors); // []
```
## Usage: Advanced Example
```ts
import {
validateData,
ErrorCodes,
type ValidationReportItem,
} from "@suaveplan/bitpacker-validation";
function describeViolations(schema: unknown, data: unknown) {
const errors: ValidationReportItem[] = [];
const warnings: ValidationReportItem[] = [];
validateData(schema, data, "$", {
errors,
warnings,
maxErrors: 50,
checkExtraFields: true,
strictMode: true,
});
return errors
.filter((row) => row.code === ErrorCodes.OUT_OF_RANGE)
.map((row) => `${row.path}: ${row.message}`);
}
```
## API Reference
### `validateData(schema: BitPackerSchema, data: unknown, path: string, ctx: ValidationContext): void`
Walks `schema` and `data` in lockstep, appending error/warning rows into `ctx.errors` / `ctx.warnings`. `path` is the dot-separated path to the current field (`""` at root). `ctx` carries per-call options and the running accumulators.
The function returns `void`; results flow through the mutable arrays in `ctx`. This is hot-path machinery and is structured for minimal allocation per recursion frame.
## Implementation Notes
- **Mutable accumulators.** The function takes the error/warning arrays by reference and pushes into them. This is the cheapest pattern for hot-path validation — allocating a fresh array per recursion frame would dominate the cost.
- **`maxErrors` short-circuit.** Once `ctx.errors.length >= ctx.maxErrors`, the function stops accumulating and returns immediately. This caps the cost of validating heavily-malformed input. The data is NOT fully traversed in this case.
- **`strictMode` promotes warnings.** When `strictMode === true`, the function pushes warnings into `ctx.errors` instead of `ctx.warnings`. Downstream consumers see all rows as errors.
- **No throwing.** The function never throws — it accumulates. The decision to throw is made by the caller based on `throwOnError`. This keeps the recursion stack-safe and lets callers decide their failure semantics.
- **Path format.** `path: ""` for root, `path: "x"` for a top-level field, `path: "users.3.email"` for nested array element with dot+index notation.

View File

@@ -0,0 +1,102 @@
# Validation Top-Level API
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - validator — Stateful validator for a fixed `BitPack…
- [validator](./validator.md) — Stateful validator for a fixed `BitPackerSchema`. `BitPackerValidator<T>` is the right choice when a high-throughput pi…
- validator — Stateful validator for a fixed `BitPack…
- [validator](./validator.md) — Stateful validator for a fixed `BitPackerSchema`. `BitPackerValidator<T>` is the right choice when a high-throughput pi…
- validator — Stateful validator for a fixed `BitPack…
- [validator](./validator.md) — Stateful validator for a fixed `BitPackerSchema`. `BitPackerValidator<T>` is the right choice when a high-throughput pi…
- validator — Stateful validator for a fixed `BitPack…
- [validator](./validator.md) — Stateful validator for a fixed `BitPackerSchema`. `BitPackerValidator<T>` is the right choice when a high-throughput pi…
- [validator](./validator.md) — Stateful validator for a fixed `BitPackerSchema`. `BitPackerValidator<T>` is the right choice when a high-throughput pi…
## Purpose
The four free-function entry points that most consumers use to validate data against a `BitPackerSchema` without constructing a `BitPackerValidator` instance. `validateSchema(schema, data, options?)` is the canonical full-form entry; `isValid(schema, data)` is the boolean-only fast path; `getValidationErrors(schema, data)` returns just the error list; `validateDataReport(schema, data)` returns the full `ValidationReport` shape suitable for forwarding to a downstream consumer.
These functions are stateless and allocate a fresh validator per call. They are the right choice for one-off validation on a request-handler boundary; for long-lived pipelines that validate millions of records against the same schema, construct a `BitPackerValidator` once and reuse it.
## Features
- `validateSchema(schema, data, options?)` — full-form entry returning `ValidationReport`; throws if `options.throwOnError === true` and any error is found
- `isValid(schema, data)` — boolean-only fast path; equivalent to `validateSchema(...).valid` but skips the report construction
- `getValidationErrors(schema, data)` — returns only the error rows from the report
- `validateDataReport(schema, data)` — returns the full `ValidationReport`; alias for `validateSchema(schema, data, { throwOnError: false })`
## Usage: Basic Example
```ts
import { validateSchema, isValid } from "@suaveplan/bitpacker-validation";
const schema = { x: 255, name: "-32" };
const report = validateSchema(schema, { x: 100, name: "ok" });
if (!report.valid) {
console.log(report.errors);
}
if (isValid(schema, { x: 100, name: "ok" })) {
// …safe to pack…
}
```
## Usage: Advanced Example
```ts
import {
validateSchema,
BitPackerValidationError,
ErrorCodes,
} from "@suaveplan/bitpacker-validation";
function safePack(schema: unknown, data: unknown) {
const report = validateSchema(schema, data, {
throwOnError: true,
strictMode: true,
checkExtraFields: true,
maxErrors: 10,
});
return packIt(schema, data);
}
try {
safePack({ x: 255 }, { x: 300, extra: "bad" });
} catch (err) {
if (err instanceof BitPackerValidationError) {
if (err.code === ErrorCodes.OUT_OF_RANGE) {
// recover with a clamped value
}
}
}
```
## API Reference
### `validateSchema(schema: BitPackerSchema, data: unknown, options?: ValidateSchemaOptions): ValidationReport`
Validates `data` against `schema` and returns a `ValidationReport`. If `options.throwOnError === true` and `errors.length > 0`, throws `BitPackerValidationError` with the first error's code and message.
### `isValid(schema: BitPackerSchema, data: unknown): boolean`
Returns `true` if `data` matches `schema`. Cheap boolean check; equivalent in result to `validateSchema(schema, data).valid`.
### `getValidationErrors(schema: BitPackerSchema, data: unknown): ValidationReportItem[]`
Returns just the error rows. Warnings are excluded.
### `validateDataReport(schema: BitPackerSchema, data: unknown): ValidationReport`
Returns the full report. Alias for `validateSchema(schema, data)`.
## Implementation Notes
- **Stateless free-function form.** Each call constructs a fresh validator; there is no shared state or cache. For high-throughput pipelines, construct a `BitPackerValidator` once and call `validator.validate(data)` repeatedly.
- **`throwOnError` only fires on errors, not warnings.** Warnings never throw, regardless of `strictMode`. The `strictMode` flag _promotes_ warnings to errors before the throw decision; it does not change the throw threshold itself.
- **`maxErrors` is a soft cap.** Validation stops collecting after `maxErrors` errors are found. Subsequent errors in the data are NOT reported. This caps the error-collection cost on heavily-malformed input but means consumers see only the first `maxErrors` rows.
- **No path on free-function errors.** The error rows include `path` for sub-field errors, but a top-level type mismatch (`schema = 255, data = "abc"`) reports `path: ""` — the empty string. Consumers checking paths must handle the empty case.

View File

@@ -0,0 +1,79 @@
# BitPackerValidator
## Purpose
Stateful validator for a fixed `BitPackerSchema`. `BitPackerValidator<T>` is the right choice when a high-throughput pipeline validates millions of records against the same schema — constructing the validator once and calling `validate(data)` repeatedly amortizes the per-construction cost. The class is generic on `T extends BitPackerSchema` so the type-level guarantees flow through to the consumer's data shape.
The validator composes the same per-rule validators that the free-function `validateSchema` uses; the only difference is that this class binds them to a stable schema and options bundle and accumulates per-call options without rebuilding the rule graph. This becomes meaningful at >10k validations/second on the same schema.
## Features
- `new BitPackerValidator(schema, options?)` — bind to a schema once
- `validate(data, perCallOptions?)` — return `ValidationReport` for one input
- `isValid(data)` — boolean-only fast path
- `validateOrThrow(data)` — `validate` followed by `throw` on first error
- Per-instance configuration (`strictMode`, `checkExtraFields`, `maxErrors`)
## Usage: Basic Example
```ts
import { BitPackerValidator } from "@suaveplan/bitpacker-validation";
const validator = new BitPackerValidator({ x: 255, name: "-32" });
for (const record of stream) {
const report = validator.validate(record);
if (!report.valid) console.warn(report.errors);
}
```
## Usage: Advanced Example
```ts
import {
BitPackerValidator,
BitPackerValidationError,
ErrorCodes,
} from "@suaveplan/bitpacker-validation";
const userValidator = new BitPackerValidator(
{ id: "uuid", name: "-64", email: { _optional: "-128" } },
{ strictMode: true, checkExtraFields: true, maxErrors: 5 }
);
function ingest(record: unknown) {
try {
userValidator.validateOrThrow(record);
// …safe to pack…
} catch (err) {
if (err instanceof BitPackerValidationError && err.code === ErrorCodes.MISSING_FIELD) {
// …request-side fix-up…
}
throw err;
}
}
```
## API Reference
### `class BitPackerValidator<T extends BitPackerSchema>`
Constructor: `new BitPackerValidator(schema: T, options?: BitPackerValidatorOptions)`. The schema is bound for the validator's lifetime; per-call options can override the constructor defaults.
#### `validate(data: unknown, perCallOptions?: ValidateSchemaOptions): ValidationReport`
Returns a `ValidationReport`. If `perCallOptions.throwOnError === true` and any errors are found, throws `BitPackerValidationError` with the first error.
#### `isValid(data: unknown): boolean`
Returns `true` if `data` matches the bound schema.
#### `validateOrThrow(data: unknown): void`
Equivalent to `validate(data, { throwOnError: true })`. Throws on the first error; does not allocate the report on success.
## Implementation Notes
- **Schema binding is immutable.** The schema passed to the constructor cannot be replaced. Constructing a new validator is the right way to switch schemas.
- **Per-call options merge over constructor defaults.** Constructor options set the validator-wide defaults; per-call options on `validate(data, opts)` override per call. Boolean fields are last-write-wins; `maxErrors` follows the same rule.
- **No shared mutable state.** Multiple validators with the same schema do not share state; each instance is independent. Two threads (or two async contexts) can safely use separate validators on the same schema.
- **`validateOrThrow` short-circuits.** It is the right choice when the consumer treats validation failure as a hard error — it skips report construction on success and emits a typed exception on failure.