feat(validator): fill declared defaults during validation - #65
Conversation
…lidation Opt-in via options.fillDefaults: 'requiredOnly'. An omitted required field whose schema declares a usable default validates as that default instead of failing as mandatory. The filled value flows through the walk, so a filled boolean arms its own nested branch and defaults under it fill recursively, in a single pass. Results gain normalizedValues (per domain, never mutating the input) and appliedDefaults. Off by default; existing behavior unchanged. MAIA-1286
Clone runtime object/array defaults at the fill site so results never alias the schema's default instance, pin cross-domain fills and a filled default failing its own validation with tests, tighten comments and docs that duplicated the option JSDoc. MAIA-1286
There was a problem hiding this comment.
Pull request overview
Adds an opt-in validation mode to fill declared defaults for omitted required fields during validateForman / validateFormanWithDomains, and returns the filled output (normalizedValues) plus an audit trail (appliedDefaults) so callers can persist/repair configurations using the same values validation actually walked.
Changes:
- Introduces
fillDefaults: 'requiredOnly'and records filled defaults per domain during validation, emittingnormalizedValues+appliedDefaultsin the result. - Adds
setValueAtPath(copy-on-write path write) to buildnormalizedValueswithout mutating inputs. - Adds a comprehensive Jest spec suite covering fills across nesting, arrays, RPC-injected specs, and cross-domain routing.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/validator.ts |
Implements default-filling at the required check, tracks fills per domain, and assembles normalizedValues/appliedDefaults in the result. |
src/utils.ts |
Adds setValueAtPath to build normalized values immutably via copy-on-write updates. |
src/types.ts |
Extends public types/options/result shape to include fillDefaults, normalizedValues, and appliedDefaults. |
test/fill-defaults.spec.ts |
Adds targeted test coverage for required-only default filling behavior and edge cases. |
README.md |
Documents the new fillDefaults: 'requiredOnly' option and its outputs. |
AGENTS.md |
Updates validator architecture notes to include the new default-filling behavior and result fields. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ntime clone guard Copilot review: isObject + Array.isArray instead of an always-false-narrowed typeof comparison, appliedDefaults[].value widened to unknown since JSON-sourced schemas can carry object/array defaults at runtime, spec case pinning the clone. MAIA-1286
|
Service-level proof, run against a live internal deployment that consumes this library: the real validation tool called over HTTP with real auth, on the module whose rpc-injected required defaults motivated this PR (the two toggles exist in no manifest, they only appear once the validator resolves
Middle column is the drop-in guarantee: with the option off the wire responses are byte-identical, so releasing this changes nothing for existing consumers. Right column took a one-line change in the consumer (adding the option to its existing |
An empty string now counts as an omission and fills, matching useDefaults in @integromat/blueprint and the builder UI predicate it cites; an explicit null remains a provided value and still fails as mandatory. MAIA-1286
…uality Mechanical, no intent change: these assertions pin valid/errors/warnings (plus states/schemas where relevant), and exact-equality made them also assert the absence of every other result key. The next commit adds two result fields, so they are widened to subset matching first, separately, to keep that diff readable. MAIA-1286
…s always Mirrors BlueprintValidator's useDefaults modes: 'requiredOnly' fills required fields only, 'always' fills omitted optional fields too. The fill moves just ahead of the mandatory check so both modes share one gate. normalizedValues and appliedDefaults are now on every entry-point result (FormanNormalizedValidationResult), so consuming code does not fork on whether the option is set; with no fills they echo the input values. MAIA-1286
91ee926 to
7686e77
Compare
Adversarial pass over the branch. Comment/JSDoc duplication of the option
contract cut back to the constraints the code cannot show, the v2.0.0 note
reduced to what breaks and how to migrate, and six test cases removed that a
named earlier case already pinned (the 'always' block was a cross-product over
predicate arms that do not vary by mode).
Also corrects two claims: a filled boolean conditions its nested branch rather
than arming it unconditionally ('false' leaves it inactive), and normalizedValues
shares untouched subtrees with the input rather than being a deep copy.
MAIA-1286
8144d70 to
a57f056
Compare
|
Reviewed the mechanism ( Verified working as described
Findings — three inline, none blocking; the first is the one I would actually want changed before merge. The The other two are latent rather than live: |
…ilently Review feedback. The fill record and setValueAtPath both failed open on states that cannot occur, and either would have reported a fill in appliedDefaults that normalizedValues does not contain - the one invariant this feature sells. The fill record now uses the non-null assertion the rest of the file uses, and setValueAtPath throws on a path it cannot write. Also states the '' exception once instead of asserting the opposite alongside it: '' counts as an omission, so under 'always' a deliberately cleared optional field comes back with its default. Pinned by a test, with 'requiredOnly' shown as the escape hatch. MAIA-1286
Jakub Stok (jakubstokcelonis)
left a comment
There was a problem hiding this comment.
Re-reviewed at cb9c769. All three findings addressed; verified the fixes rather than reading them.
Checked on the branch
npx tsc --noEmitclean, 502/502 green (499 + your three new tests).- Both new tests genuinely fail pre-fix. Restoring
return valuesinsetValueAtPathturns the throw test red; reverting the predicate to strictundefinedturns two''tests red. Real regression tests. - The
!does what you said it does. It erases at compile time, so I checked the runtime: the property access on an absent root throwsTypeError: Cannot read properties of undefined (reading 'appliedDefaults')at the push. Loud, not silent — which was the actual concern, not the spelling. - Docs no longer contradict themselves, and the
'requiredOnly'escape hatch is named in both the README and the JSDoc.
On keeping the '' parity — you convinced me, and I checked the premise rather than taking it. I read the actual useDefaults implementation in @integromat/blueprint (lib/type.validator.js:72-78):
if ((options.useDefaults === true ||
options.useDefaults === 'always' ||
(options.useDefaults === 'requiredOnly' && param.required === true)) &&
(value === undefined || value === '') &&
param.default !== undefined) {Same predicate, same mode split. So restricting ''-as-omission to required fields really would have made this 'always' mean something other than Blueprint's 'always' — exactly the one-cell divergence you described, and it would have been found the hard way years from now. Your three reasons hold and the third is the decisive one: clear-preserving semantics is a new named mode, not a redefinition of this one. Dropping it.
One divergence from Blueprint that is real and worth knowing you have: Blueprint gates on param.default !== undefined, so it fills a null/'' default; you additionally require != null && !== ''. Verified the falsy cases that actually matter still fill correctly:
default: null -> no fill default: 0 -> fills 0
default: '' -> no fill default: false -> fills false
0/false filling is the case the motivating boolean toggles depend on, so this is right. The divergence is documented and strictly safer than Blueprint's. Not asking for a change — just noting it's a second named divergence alongside the no-mutation one, if you ever reconcile the two validators.
LGTM. Approving.
|
Addendum — the Dominik raised this and he's right that it's unaddressed: BlueprintValidator's option is Having looked, I think your two-string type is right, and the argument is stronger than "it's fine":
web-api never exposes it. useDefaults: [{ name: 'useDefaults', type: 'select',
options: [{ value: 'always' }, { value: 'requiredOnly' }], required: false }]The internal callers ( So: no change requested, approval stands. But it deserves one line saying so, because read cold — two types side by side, one arm missing — it looks like an oversight rather than a decision, which is exactly how it was read. Something like:
Worth adding next to the One process note, and I mean this constructively. This one took three rounds — |
|
Thanks for going to the blueprint source instead of taking the parity claim on trust, thats the check I couldnt really do from inside this repo. And youre right about the second divergence. Merging. Version bump to 2.0.0 goes in as its own |
Version bump to **2.0.0**. Includes #65: `validateForman` / `validateFormanWithDomains` gain an opt-in `fillDefaults` option (`'requiredOnly'` | `'always'`) that fills declared defaults for omitted fields during validation, mirroring BlueprintValidator's `useDefaults` modes, and return the filled values as `normalizedValues` plus an audit trail in `appliedDefaults`. **Why a major.** Nothing was removed or renamed and the return type only narrows, so `valid`/`errors` readers are unaffected. But both result fields are now present on every result, with or without the option, so anyone deep-comparing the whole result object or forwarding it into a fixed-shape response will see two new keys. With the option off, validation outcomes (`valid`, `errors`, `warnings`) are byte-identical to 1.19.0. The README carries the release note and the migration line. Once merged, creating the `v2.0.0` GitHub release triggers the npm + JSR publish workflows.
|
You were right and I talked past this one. My reply above answered the Added it in #68, one line next to the
And the process note landed. Answering the literal ask and stopping is exactly what happened here, three times over on this PR and then once more on your addendum. What I should have brought the first time is the whole option surface with what I was porting, what I was dropping and why, rather than making you find each gap. Taking that one. |
Follow-up to #65. ### What breaks A single-branch boolean (`nested: [...]`, applies when `true`) with the toggle at `false` is still walked since 1.18.0 so stale values keep their type rules. That walk also pushed the branch's fields into `schemas`, flattened, with `required: true` intact. Consumers persist `schemas` as the module's resolved form and validate runtime bundles against it. Those validators never see the toggle, so a required field from the inactive branch gets demanded at run time, e.g. `Missing value of required parameter 'fallbackConnectionId'` on a module whose fallback toggle is `false`. ### Why it surfaced now Before #65 an absent toggle failed as mandatory and never reached the walk, so the leak only fired when the caller sent `false` explicitly. With `fillDefaults: 'requiredOnly'` the toggle fills to `false` and the inactive walk runs on every module that predates the toggle, which is most of them. The fill was correct; what it exposed was this. ### The fix `suppressRequired` has exactly one setter, the inactive single-branch walk, so both `schemaFields.push` sites now skip when it is set: the nested site (same-domain branches) and the domain-root site (a single-branch `nested: { domain, store }` pointing at another domain). The two-branch form already stayed out of `schemas` via `registerOnly`; a test now pins that. Validation itself is unchanged: stale values in the inactive branch are still type-checked, strict mode still knows their names, and an active branch still lands in `schemas` exactly as before. ### Tests Five cases in `boolean-nested.spec.ts`: explicit `false` leaves the branch out, `true` keeps it in, a `false` that `fillDefaults` filled leaves it out, a cross-domain inactive branch stays out of the other domain's `schemas`, and the two-branch form reports only its active side (the last one is a pin, it passed before). The first four go red on main. 508/508 green, tsc and build clean. ### Not in this PR - `states` for the inactive branch is untouched. It carries labels for stale values, nothing a runtime validator reads. Happy to align it in a follow-up if you'd rather the inactive branch contribute nothing at all. - `schemas` spreads an `rpc://` string sitting inside a collection `spec` into an indexed-character object (`{"0":"r","1":"p",...}`). Separate defect in the same output, I'll file it rather than widen this one.
[Has Ai Code]
Jira: https://make.atlassian.net/browse/MAIA-1286
Releasing as 2.0.0. Adds an opt-in
fillDefaultsvalidation option mirroring BlueprintValidator'suseDefaultsmodes: with'requiredOnly', an omitted required field whose schema declares a usable default validates as that default instead of failing"Field is mandatory."; with'always', omitted optional fields with defaults fill too. Every result now carriesnormalizedValues(the input values with fills applied) andappliedDefaults(what was filled, where), with or without the option, so the caller-side pattern (if (valid) use(normalizedValues)) does not fork on the flag.Why
A required field with a declared default fails validation when the caller omits it, even though the form UI would have materialized exactly that default on save. Consumers that build configs programmatically (MCP tools driving module configuration) either have to guess a value for a field they may never have seen (
rpc://-injected fields only exist after resolution) or re-implement forman traversal outside the library to fill defaults themselves, which cannot be done correctly: a filled toggle conditions nested fields, and nested resolution is this library's own domain knowledge. #61 tried suppressing the error without producing values and was closed for exactly that reason; this PR does the filling where the walk already happens.How it works
The substitution sits just before the mandatory check in
validateFormanValue: option on, valueundefinedor''(the same fillable predicate as BlueprintValidator'suseDefaultsand the builder UI it cites; an explicitnullstays a provided value and fails), default able to satisfy the required check (null/''cannot).'requiredOnly'fills required fields only;'always'extends the same fill to omitted optional fields. The filled value then continues through the normal walk, so a filled boolean conditions its nested branch exactly as a provided one would (falseleaves it inactive) and defaults under an armed branch fill recursively, in the same single pass, rpc-resolved specs included. Nothing fills undersuppressRequiredand provided values are never overwritten. Fills are recorded per domain root;normalizedValuesis built with a copy-on-write path write (setValueAtPath), so inputs are never mutated — though subtrees nothing was written into are shared with the input rather than deep-copied, which keeps the zero-fill case free.Where to look
src/types.ts— the public contract: thefillDefaultsoption and the two new result fields. Start here.src/validator.ts— the fill just before the mandatory check (~16 lines) and the result assembly.src/utils.ts—setValueAtPath/setIn.Shape
+702/-114 across 16 files. Ordered so the bulk reads separately from the mechanism:
3b1df93,68aca7f, plus review fixesb0e2fdc8955361f9a097ca57f056cb9c769srcacross four files. This is the review surface.test/fill-defaults.spec.ts, 22 cases, plussetValueAtPathunit tests intest/utils.spec.ts.0065b1491be2b3,7686e77An adversarial trim pass ran over the whole branch before review (
a57f056): it cut comment/JSDoc duplication of the option contract back to the constraints the code cannot show, shortened the release note to what breaks and how to migrate, and removed six test cases a named earlier case already pinned — the'always'block was a cross-product over predicate arms that do not vary by mode. It also corrected two claims that were overstated here and in the docs; see Notes. 27 cases → 21, +740 → +660. Review has since added back three tests (see below), landing at +702.Each fill guard was mutation-verified: dropping the
undefinedgate, the'always'arm, the inactive-branch guard, and no-oping the fill record each turn their intended tests red.Verified against a live consumer
Measured at
8955361, before two later changes (see the caveat below). The branch build was staged into a live internal deployment of the service that consumes this library, and the real validation endpoint was called over HTTP with the stored module configuration from the motivating incident — its two required toggles are injected byrpc://resolution and exist in no manifest. With the option on, the failing payload dropped from three errors to the one genuine defect with both toggles filled and reported, the honestly-repaired payload flipped from rejected to accepted, and explicitly provided values stayed untouched. Full table in the comments.Two things changed after that run, so read the option-off column with them in mind.
f9a097cmade''fill (it previously required strictundefined), and68aca7fmade the two result fields unconditional. With the option off, validation outcomes —valid,errors,warnings— are unchanged, but the result object now carries two additional keys. That is precisely why this ships as a major rather than a minor, and it is not a drop-in guarantee at the object level. The consumer PR needed a response guard for exactly this reason.Notes
cb9c769) made two impossible states loud instead of silent: the fill record andsetValueAtPathboth failed open on states that cannot occur, and either would have reported a fill inappliedDefaultsthatnormalizedValuesdoes not contain — the one invariant this feature sells.setValueAtPathalso gained the unit tests it never had.''counts as an omission and fills, matching the blueprint predicate. Under'always'that means a deliberately cleared optional field comes back with its default;'requiredOnly'leaves it alone. Both are pinned by a test, and the docs now state the exception once rather than asserting the opposite beside it. Review raised restricting''-as-omission to required fields; kept the parity behaviour instead, since it would make this'always'mean something different from BlueprintValidator's'always', and no caller uses the mode yet. Clear-preserving semantics would be a new named mode, not a change to this one.'always'mode and the always-presentnormalizedValuesboth come from review feedback, for parity with BlueprintValidator's option surface and for a consistent consumer pattern.valid/errorsreaders are untouched. But always-present result fields change the shape for anyone deep-comparing the result or forwarding it into a fixed-shape response — this repo's own suite needed the assertion widening above, and the MCP Server consumer needed its response guard extended to three more tools. Cheaper to call that a major than to surprise someone. The README carries the release note and the migration line.appliedDefaultspaths are dot-joined to match error paths, so the two correlate directly.pathToStringis deliberately not reused: it emits bracket notation, which would decorrelate the two.useDefaultsmodes, so the two validators agree on what an omitted field means. Two named divergences. The first is why the option is not calleduseDefaults: no in-place mutation. This library never casts or modifies caller input; the filled values come back innormalizedValuesinstead. Reusing the name would promise behavior it does not have.null/''default is never filled, in either mode, where Blueprint fills anydefault !== undefinedand so would write one. It could not satisfy a required check, and on an optional field it is indistinguishable from the omission itself. Strictly the safer of the two behaviours, and0/falsedefaults still fill, which is what the motivating boolean toggles depend on. Surfaced in review, which checked the predicate against the@integromat/blueprintsource rather than taking the parity claim on trust.defaultis consulted. A select whose options carry their owndefault: truemarker but whose field declares nodefaultstill fails as mandatory; deriving a fill from option markers would be a separate decision.