6.4 KiB
OpenSpec Change: validation-critical-bugfix
- Change ID: validation-critical-bugfix
- Package:
@suaveplan/validation - Status: PROPOSED
- Date: 2026-04-27
Summary
Fix three correctness gaps in @suaveplan/validation and bring the spec into
conformance:
- Critical security footgun —
parse()/safeParse()previously silently skipped async refinements. A schema such asstring().refine(async (token) => await checkRevoked(token))consumed via.parse()would returnvalidfor revoked tokens. Synchronous parse paths now refuse to run when any refinement is async, surfacingVALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNCinstead. - Ghost API (VL-10) —
BaseSchemanow implements.withHooks(hooks)withbeforeValidate/afterValidate/onErrorinvocation at every terminal branch ofparseandparseAsync. Hooks are awaited on async paths and their return value is discarded on sync paths. - Ghost OTel emission (VL-11) —
recordParseSuccess/recordParseFailureand the duration histogram are now emitted fromBaseSchema.parse/BaseSchema.parseAsyncon every terminal branch, tagged withschema.type = this.constructor.name.
The change also closes one VL-09 gap (coerceString now returns null for
non-coercible symbol inputs so the wrapper can produce a ParseFailure
rather than yielding the misleading "Symbol(x)" token), folds the existing
validation-spec-conformance work into a single shipping change, and adds
**Verified by:** lines to every requirement in
openspec/specs/validation/spec.md.
Current state
@suaveplan/validation ships 12 schema types, an immutable builder pattern,
and 100% line coverage at baseline. Three documented MUST-clauses had drifted
from the implementation:
- Async refinement footgun.
BaseSchema.parsecalled_applyRefinementsSyncwhich containedif (r.async) continue. The async predicate's promise was discarded and the value was reportedvalidregardless of the predicate's eventual result. The original suite even codified this as expected behaviour inschema.test.ts::"sync parse skips async refinements without error"— flipping that test is part of this change. - VL-10 hooks unwired.
ValidationHookMapandValidationHookDatawere exported as types, but noBaseSchema.withHooksmethod existed and no parse path invoked any hook. - VL-11 OTel unwired.
recordParseSuccess/recordParseFailurelived insrc/otel/validation-otel.tsbut nosrc/call site ever invoked them; only the unit test for the otel module exercised the recorders. - VL-09 coerceString. Returned a string for every input including
symbol(whereString()throws), forcing the wrapper into a_inner._parseround-trip that could never succeed and yielding misleading"Symbol(x)"text. The spec mandates failed coercion produceParseFailure. - Spec annotations. All 12 requirements lacked
**Verified by:**lines required bybun scripts/openspec-conformance.ts.
What changes
- schema.ts.
- Add
_assertNoAsyncRefinementsForSyncParsethat throws aValidationErrorwith the new codeVALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNCwhen invoked fromparse().parseAsync()is unchanged. - Add
withHooks(hooks)builder that returns an immutable clone with hooks merged onto pre-existing hooks (per-key replace semantics). - Invoke hooks in
parse/parseAsyncatbeforeValidate/afterValidate/onErrorboundaries. - Wire
recordParseSuccess/recordParseFailure(with duration viaperformance.now()) at every terminal branch ofparse/parseAsync. - Re-implement
safeParseas atry { return parse(input) } catchshim so OTel + hooks fire on the safe path too. - Add a
_phantomInput: TInputdeclared field soInferInput<S>resolves to the schema's actual input type instead of degrading tounknown.
- Add
- errors.ts / common.ts. Register the new
VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNCcode in both the runtimedefineCodesregistry and theValidationCodestring-union type. - utils/coerce.ts.
coerceStringnow returnsstring | null; returnsnullforsymbolinputs. - Tests. Add scenarios in
schema.test.ts(sync-throws, safeParse-fails-cleanly) and one incoerce.test.ts(symbol → null). Existing async-refinement-skips test is replaced. - Spec. Add
**Verified by:**lines to every VL-NN requirement and add one new requirement (VL-13) for the async-refinement loud-fail behaviour.
Why now
The three drift items above are the highest-severity gaps in the package: the async-refinement footgun is a security-grade silent-failure bug in the core parse path, and the unwired VL-10 / VL-11 mean any consumer reading the spec gets a fictional picture of the API. Bundling them into a single shipping change is cheaper than three sequential rounds of the 5-gate.
Impact
- Public API gains
BaseSchema.withHooks(hooks)(additive) and theVALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNCerror code (additive).coerceStringreturn type narrows fromstringtostring | null(callers must now handlenull; the only in-repo caller iscoerce()'s wrapper, which already guards on the symbol branch). parse()/safeParse()start surfacing failures for schemas that previously silently accepted invalid values via async refinements. This is the intended correctness fix; downstream code that relied on the bug must switch toparseAsync/safeParseAsync.- One new spec requirement (VL-13). All eleven existing requirements
unchanged in wording; only
**Verified by:**lines are added. - Coverage stays at 100 / 100 / 100 / 100.
Deferred
The following Zod-API surface items are out of scope and tracked here so follow-up work can pick them up:
discriminatedUnion(key, schemas)— typed-tagged-union short-circuit.lazy(() => schema)/ recursive schemas via thunks.brand<TBrand>()— nominal types.default(value)— fall back to a value when input isundefined.catch(value)— fall back to a value on parse failure.superRefine((value, ctx) => ...)— multi-issue refinement with explicitctx.addIssue.
These are additive features; the current schema engine is internally expressive enough to host them once a follow-up change formalises them.