fix(objectql)!: enforce a progress field's declared min / max at the write seam (#20386) - #20482
Conversation
…write seam (#20386) The number arm returned for `progress` right after the finite check, above the bounds, so a `progress` field declaring `max: 100` stored 150 and one declaring `min: 0` stored -5 (201 on memory and SQLite) while a `number` field refused both. `FieldSchema.min` / `max` declare the check with no type exclusion; triage ruled ENFORCE. The early return moves below the bounds and stays above `scale` and `precision`, whose own contracts name type sets `progress` is not in, so only the bounds start binding. Refusals carry the `number` field's codes, `min_value` / `max_value`. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
…ite door on SQLite (#20386) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
…nt (#20386) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
…0386) check:error-code-casing reads a bare `{ code: 'max_value' }` as an error.code emission; naming the field makes it the field-addressed validator code it is. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 038a4c54ed8bd2e37b5e76e43c14571c80da88c8 && git checkout 038a4c54ed8bd2e37b5e76e43c14571c80da88c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8e028591857980ae69b9f9badb380dfa61367e62 af9e5101ac2b36a0c9028189d837fa7393231c0b && git checkout -B drift-repro 8e028591857980ae69b9f9badb380dfa61367e62 && git merge --no-ff af9e5101ac2b36a0c9028189d837fa7393231c0b
node scripts/docs-audit/affected-docs.mjs --json 8e028591857980ae69b9f9badb380dfa61367e62 |
Contract reviewServed-tier: Read: card #20386 (body and all 3 comments: triage ① Derived judgmentsThe one code change. The diff on
Accept-set and public-surface census. The only accept-set change is the narrowing on Sentence audit — changeset. Every sentence TRUE: the launch-window Sentence audit — PR body. TRUE, with the following readings:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20386
Clause-②: no (narrowing)
A
progressfield's declaredmin/maxare now enforced at the objectql write seam, per triage5865053231(ENFORCE, no decision card). Aprogresswrite outside a declared bound is refused with400 VALIDATION_FAILEDand thenumberfield's own field codes,max_value/min_value.scaleandprecisionstay unread onprogress. Measured head:af9e5101a.What changes
packages/objectql/src/validation/record-validator.ts, the number arm. Theif (t === 'progress') return null;early return moves from above themin/maxchecks to directly below them, and abovescale/precision. The record write door:''skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308 docblock that deferred this now says why the bounds bind and why the return stays abovescale/precision: each of those keys' own.describe()names a type setprogressis not in.min/maxline now listsprogress. It named the five types that were enforced, so leaving it would have made it false. This is one line outside "the number arm and the record write door:''skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308 docblock" (declared below as a deviation). It is line 31, far from the date line PR fix(core,objectql)!: a date or datetime names a year from 0001 to 9999, refused at the comparand door and the write door (#20264) #20469 edits (line 55 onmain).record-validator.blank-typed-value.test.tspinned the old boundary (progressmax: 100accepting150.5). It now pins what stays true:summaryreads no bound orscale, andprogressreads noscale. One test name inrecord-validator.precision.test.tssaidprogress's "bounds the numeric branch never reads", and it is reworded. Its assertion is unchanged..changeset/20386-progress-min-max-enforced.md:@objectstack/objectqlminor, BREAKING banner, theClause-②line, a before → after line and the ADR-0087 disposition (below).Measured premises (dispatch zone 2)
H1:
progressbounds are skipped onorigin/main. Held. Reproduced ondc0ab6a2ethrough the realRestServerPOST /api/v1/data/:objecthandler, over a realObjectQLengine on the SQLiteSqlDriverand on the memory driver. This was a scratch harness, not committed, becausecheck:driver-memory-censusrefuses a new driver-memory consumer.5f5bc7580)progress,max: 100150150(real)150VALIDATION_FAILED/max_value{ max: 100 }, no rowprogress,min: 0-5-5(real)-5VALIDATION_FAILED/min_value{ min: 0 }, no rownumber,max: 100(control)150max_value, no rownumber,min: 0(control)-5min_value, no rowprogressin bounds / on each bound50,0,100H2:
scale/precisionafter the move. Held, with a measured boundary. With the return placed below the bounds,scaleandprecisionare still not enforced onprogress:33.5underscale: 0gets 201, and99.5underprecision: 2gets 201, on SQLite and memory. Deleting the return outright would start both. Measured by ablation (below): four pins turn red withmax_scale/max_precision. So the placement is load-bearing, and it is pinned from both sides.H3: producers. Zero writes outside a declared bound.
SliderField, theprogresseditor (FieldEditWidget.tsx:87progress: SliderField), is byte-identical at the pinned.objectui-shadd3f7e1be3and at objectui HEADb8e0941. It passesmin = field.min ?? 0andmax = field.max ?? 100to@radix-ui/react-slider(^1.4.7). That component'supdateValuesdoesclamp(snapToStep, [min, max])before everyonValueChange(read in the 1.4.7 tarball). So it cannot emit a value outside a declared bound.dc0ab6a2e: 2progressfields declare bounds,showcase_task.progressand the field zoo'sf_progress, bothmin: 0, max: 100. Their writers are 12 seed rows, theshowcase_mark_doneaction (progress: 100) and the dogfood field-zoo matrix (60). All of them are in bounds.Pins
packages/objectql/src/validation/record-validator.progress-bounds.test.ts(new, 14 tests):150getsmax_value{ max: 100 },-5getsmin_value{ min: 0 }, and in-bounds values plus both inclusive bounds are accepted);numberrefusal;scale/precisionunread onprogress, while the same declaration onsliderrefuses;insertManypartial success, update by id and by predicate, thevalidatedry run, and a control showing that in-bounds values arrive as the same number.packages/rest/src/rest-data-progress-bounds.test.ts(new, 6 tests), on the realRestServerroutes over SQLite, reading the physical column past every read coercion:150/-5with thenumberfield's envelope and write or change nothing;33.5writes underscale: 0, precision: 2.Verification
Heavy runs went through
scripts/pm/os-verify-lock.sh. All readings are ataf9e5101aunless stated otherwise.pnpm turbo run build --filter='@objectstack/rest...' --concurrency=2: 25/25, VERDICT command-exit 0. It was re-run after each merge ofmain(last at5c4148234). The objectql source has not changed since.progress-bounds,blank-typed-value,precision,number-value,record-validator,engine-number-value-door,engine-blank-typed-value-door): 7 files, 333/333.--project local: 328 files, 6081/6081 (at6a029a923, before the second merge, which brought only spec and driver-sql commits).typecheck(tsc, scripts, andcheck:test-typecheck, whosetsconfig.test.jsonincludessrc/**/*): exit 0.rest-data-progress-bounds,rest-data-number-value,rest-data-blank-typed-valueandimport-integration: 4 files, 100/100.typecheckincludingcheck:test-typecheck: exit 0.dist/built from basedc0ab6a2e, the REST pin read 5 failed / 1 passed. Each failure wasexpected 201 to be 400or a batch row reporting success. The one green is the in-bounds control. Thedist/index.jsnumber arm was read directly before and after: the return sat above the bounds, then belowmax. Afterpnpm --filter @objectstack/objectql buildat5f5bc7580the pin reads 6/6.3b7b55406).scripts/ablation-replace.mjsre-plantedif (t === 'progress') return null;above the bounds: anchor 1 → 0, blob9ede5b5b→d396c53a. Result: the new objectql file reads 10 failed / 4 passed. The 4 greens are the controls: in-bounds values,scaleunread,precisionunread, and the engine in-bounds control. Restored: blob9ede5b5bequals HEAD, andgit diff HEADis empty. The subject resolves through a relative import tosrc/, so no rebuild was involved. Direction: red.9ede5b5b→d683b83e. 4 failed: the twoprogress-boundspins forscale/precision, theblank-typed-valuescalepin, and theprecisiontest that excludesprogress. Restored the same way. Direction: red.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackataf9e5101aderived 64 commands. 62 exit 0. 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET):check:dual-build-cjs-loadsandcheck:type-check-debtboth need a whole-repo build.--ranreconciliation: 64 derived, 62 run, 2 NOT-MEASURED (derived from exit 3), 0 UNRUN, exit 0.5c4148234:check:error-code-casingexit 1 on four bare{ code: 'max_value' }style assertions. They are now field-addressed (af9e5101a), and the gate reads 0.eslint --no-inline-config --format jsonon the 5 changed.tsfiles: 5 files, 0 errors, 0 warnings.--print-configapplies the config's rules to each file (6 rules on the validator, 5 on the REST test).eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move any untouched file's verdict.pnpm lintis declared to CI.ADR-0087 disposition: which precedent, and why
not-required (no-migration-prescription), following PR #20423, not #7501:precision(total digits) at the write seam —max_precision(#19992) #20423 is the closer precedent: the same arm, the same kind of change (a declared numeric bound starting to bind at the write seam, a narrowing of the write accept set with no authored key moving), and a gate-era marker.scaleis never enforced — values with more decimals are accepted and stored verbatim (min/max on the same field are enforced) #7501's changeset (number-scale-enforced-by-rejection.md,951476719) declared no BREAKING banner, socheck:adr-0087-registrationnever asked it for a marker. It carries none, and there is nothing to copy.One conflict with the dispatch order's wording is worth stating. The seat's dispatch order asks for "a FROM → TO line" (claim 5873443045 itself names only
.changeset/20386-*.md; seat edit after review 5875022310). Measured with the gate's own exportedfindMigrationPrescription: a line opening with the literalFROM → TOlabel is read as a migration prescription (branchfrom-to-label), and that refusesno-migration-prescription. The only category left would then beregistered, which would need a new ledger entry inpackages/spec. That is out of scope for this card and wrong on the facts, since nothing authorable moves. So the changeset carries the mapping as "What a caller sees, before → after" (201, stored as sent →400 VALIDATION_FAILED+max_value/min_value, nothing stored), plus the one-line fix. The detector reads that as no prescription, andcheck:adr-0087-registrationpasses.Acceptance notes
scale/precisiondeclared on aprogressfield parse, and nothing reads them at the write seam..describe()texts name the types they bind on, andprecision's says "Not read on any other field type".progress:ObjectFieldInspectorisNumericcovers onlynumber,currencyandpercent.SliderField's undeclared-bound defaults (min ?? 0,max ?? 100) are narrower than the server, which enforces nothing undeclared.progressfield declaringminabove 100 and nomax. The slider then clamps into[min, 100], so it would emit100, which is now refused.docs/qa/platform-checklist/areas/records-forms.json, itemrecords-forms.field-type-constraints: its steps do not reachprogressbounds (triage said so). This belongs to the next checklist-author sweep. Holder: none.datetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264) edits the header's date line and the date / datetime arm of the same file. The hunks are far apart and there is no textual overlap. The later lander mergesmain.Generated by Claude Code