9.5 KiB
Capability Spec Delta: validation (critical-bugfix)
Package: @suaveplan/validation
Status: PROPOSED
This delta:
- Adds VL-13 — Synchronous parse paths MUST refuse to run when any
refinement is async, surfacing
VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNCrather than silently reporting the value valid. - Modifies VL-08 wording slightly to reflect the loud-fail contract in
VL-13 (async refinements remain awaited only on
parseAsync/safeParseAsync, butparse/safeParseno longer accept schemas carrying them). - Modifies VL-09 to make the
coerceStringfailure-mode explicit (returnsnullfor non-coerciblesymbolinputs). - Modifies VL-10 to document
.withHooks(hooks)as a method onBaseSchemareturning an immutable clone, and that hooks fire from every terminal branch ofparse/parseAsync(and the safe variants by inheritance). - Modifies VL-11 to document that emission happens inside
BaseSchema.parse/BaseSchema.parseAsync(not as an opt-in by callers), tagged withschema.type = constructor.nameand usingperformance.now()for the duration histogram. - Adds
**Verified by:**lines to every requirement (VL-01 through VL-13).
ADDED Requirements
VL-13 — Async-refinement loud-fail in synchronous parse
BaseSchema.parse and BaseSchema.safeParse MUST refuse to run when any
refinement attached to the schema is async. parse MUST throw a
ValidationError whose first issue carries the code
VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC. safeParse MUST return
ParseFailure with the same code. This eliminates the prior silent-skip
behaviour where async predicates' promises were discarded and the value was
reported valid regardless of the predicate's eventual result, which is a
security-grade footgun for revocation / uniqueness / rate-limit checks.
parseAsync / safeParseAsync are unchanged — they continue to await async
refinements normally.
Verified by: src/core/schema/schema.test.ts::"sync parse throws ASYNC_REFINEMENT_REQUIRES_PARSEASYNC when an async refinement is present" AND src/core/schema/schema.test.ts::"safeParse returns ASYNC_REFINEMENT_REQUIRES_PARSEASYNC failure when async refinement is present" AND src/core/schema/schema.test.ts::"async refinement passes" AND src/core/schema/schema.test.ts::"async refinement fails".
MODIFIED Requirements
VL-08 — Async refinements (modified)
.refine(fn) MUST accept both synchronous and async predicate functions.
Async refinements are awaited via .parseAsync() / .safeParseAsync(). The
synchronous parse paths (.parse() / .safeParse()) MUST NOT silently
skip async refinements: see VL-13.
Verified by: src/core/schema/schema.test.ts::"async refinement passes" AND src/core/schema/schema.test.ts::"async refinement fails" AND src/core/schema/schema.test.ts::"sync parse throws ASYNC_REFINEMENT_REQUIRES_PARSEASYNC when an async refinement is present".
VL-09 — Coerce utilities (modified)
coerce(schema) MUST coerce string inputs to the target type before parsing.
coerceString, coerceNumber, coerceBoolean, coerceDate are pure
functions; a failed coercion returns null (which the wrapper translates
into ParseFailure) rather than throwing. coerceString returns null for
non-coercible inputs (symbol); other inputs are coerced via String().
Verified by: src/utils/coerce.test.ts (full suite — coerceString / coerceNumber / coerceBoolean / coerceDate + coerce wrapper) AND src/utils/coerce.test.ts::"returns null for symbol input (non-coercible per VL-09)".
VL-10 — Hooks (modified)
Schemas MUST support optional lifecycle hooks
{ beforeValidate, afterValidate, onError } settable via a
.withHooks(hooks) method on BaseSchema that returns an immutable
clone with the supplied hooks merged onto any pre-existing hooks (per-key
replace semantics). Hooks are called from every terminal branch of parse /
safeParse / parseAsync / safeParseAsync: beforeValidate is called
before _parse, afterValidate on successful resolution, onError
immediately before ValidationError is thrown / surfaced. In the
synchronous path the hook return value (including any returned promise) is
discarded; in the async path returned promises are awaited.
Verified by: src/core/schema/schema.spec-conformance.test.ts::"calls beforeValidate and afterValidate on successful sync parse" AND src/core/schema/schema.spec-conformance.test.ts::"calls onError on sync parse failure" AND src/core/schema/schema.spec-conformance.test.ts::"calls onError when sync refinement fails" AND src/core/schema/schema.spec-conformance.test.ts::"awaits async beforeValidate/afterValidate in parseAsync" AND src/core/schema/schema.spec-conformance.test.ts::"awaits async onError on async parse failure (type mismatch)" AND src/core/schema/schema.spec-conformance.test.ts::"awaits async onError when async refinement fails" AND src/core/schema/schema.spec-conformance.test.ts::"merges hooks across multiple withHooks calls (clone path)".
VL-11 — OTel instrumentation (modified)
validation.parse.success and validation.parse.failure counters and
validation.parse.duration_ms histogram MUST be emitted via
@opentelemetry/api from every terminal branch of BaseSchema.parse and
BaseSchema.parseAsync (not opt-in). Each metric carries a schema.type
attribute equal to this.constructor.name (e.g. "StringSchema",
"ObjectSchema"). Duration is measured via performance.now(). All
emission is a no-op when no MeterProvider is registered.
Verified by: src/core/schema/schema.spec-conformance.test.ts::"emits validation.parse.success on successful parse" AND src/core/schema/schema.spec-conformance.test.ts::"emits validation.parse.failure on failed parse" AND src/core/schema/schema.spec-conformance.test.ts::"emits failure on sync refinement failure" AND src/core/schema/schema.spec-conformance.test.ts::"emits success on async parse + emits duration histogram" AND src/core/schema/schema.spec-conformance.test.ts::"emits failure on async parse failure (type mismatch)" AND src/core/schema/schema.spec-conformance.test.ts::"emits failure on async refinement failure" AND src/otel/validation-otel.test.ts (no-op + lazy init).
Verified-by mapping (full requirement set)
| Requirement | Verified by |
|---|---|
| VL-01 — Self-contained schema engine | tests/dep-graph.test.ts AND src/core/schema/schema.spec-conformance.test.ts::"string().parse returns string and accepts unknown input" AND src/core/schema/schema.spec-conformance.test.ts::"refine, transform, optional, nullable are present" |
| VL-02 — Never-throw contract | src/core/schema/schema.test.ts::"throws ValidationError for invalid input" AND src/core/schema/schema.test.ts::"returns failure for invalid input" AND src/core/schema/schema.spec-conformance.test.ts::"safeParse never throws on bizarre input" AND src/core/schema/schema.spec-conformance.test.ts::"safeParseAsync never throws on bizarre input" |
| VL-03 — ValidationError shape | src/errors/errors.test.ts (full suite — extends SuaveplanError, flatten / format, code registry) |
| VL-04 — String schema constraints | src/schemas/string/string.test.ts (full suite — min / max / length / regex / email / url / uuid / startsWith / endsWith / includes + trim / toLowerCase / toUpperCase) |
| VL-05 — Number schema constraints | src/schemas/number/number.test.ts (full suite — min / max / int / positive / negative / multipleOf / finite) |
| VL-06 — Object schema modes | src/schemas/object/object.test.ts (full suite — strip / strict / passthrough + pick / omit / partial / required / extend / merge) |
| VL-07 — Union short-circuit | src/schemas/union/union.test.ts (full suite — first-match short-circuit + aggregated arm errors) |
| VL-08 — Async refinements | see modified entry above |
| VL-09 — Coerce utilities | see modified entry above |
| VL-10 — Hooks | see modified entry above |
| VL-11 — OTel instrumentation | see modified entry above |
| VL-12 — sideEffects: false + coverage 100% | tests/tree-shake.test.ts::"sideEffects is false" AND coverage gate bunx turbo test:coverage --filter=@suaveplan/validation |
| VL-13 — Async-refinement loud-fail in synchronous parse | see ADDED entry above |
Newly added implementation in this change
BaseSchema.withHooks(hooks)builder method.- Hook invocation at every terminal branch of
parse/parseAsync. recordParseSuccess/recordParseFailure+ duration histogram emission at every terminal branch ofparse/parseAsync.safeParsere-implemented as atry { parse } catchshim so the safe path inherits OTel + hook emission._assertNoAsyncRefinementsForSyncParse— loud-fail guard for VL-13.coerceStringreturnsnullforsymbolinputs.- New error code
VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC.
Newly added tests in this change
src/core/schema/schema.test.ts::"sync parse throws ASYNC_REFINEMENT_REQUIRES_PARSEASYNC when an async refinement is present"src/core/schema/schema.test.ts::"safeParse returns ASYNC_REFINEMENT_REQUIRES_PARSEASYNC failure when async refinement is present"src/utils/coerce.test.ts::"returns null for symbol input (non-coercible per VL-09)"- (All seventeen scenarios in
src/core/schema/schema.spec-conformance.test.tswere authored by the priorvalidation-spec-conformancechange but only began passing once this change wired the underlying hooks + OTel implementation.)