Skip to content

fix(fields): validate a stored location value on an edit form - #6811

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6744-location-validation-branch
Aug 30, 2026
Merged

fix(fields): validate a stored location value on an edit form#6811
os-sam merged 2 commits into
mainfrom
claude/issue-6744-location-validation-branch

Conversation

@claude

@claudeclaudeBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes#6744

The defect

buildValidationRules is the producer of the host-side error prop that every field widget's published objectui#3222 slot reads, and it had no branch for location. So a coordinate that was already in the record and violated the spec's range was never validated on an edit form: the control rendered it, nothing marked it invalid, and submitting re-wrote it unchanged.

This is a different defect from #6714 / #6716, which are about a refusal at INPUT time. A refusal means onChange never fires, so the typed text never becomes a form value and this rule is handed undefined. The two do not overlap and neither replaces the other.

The hard precondition came first

The maintainer ruling of 2026-08-29 (director session batch #4) made landing conditional on counting existing out-of-range coordinates across everything measurable, and forbade shipping a hard block on a non-zero reading.

Reading: 0 refused out of 28 stored location values. Zero within measurable scope.

Taken with the platform's own validator rather than a hand-written range: every stored location value in every measurable dataset was run through valueSchemaFor({ type: 'location' }, 'stored').

datasetstored valuesrefused
demo / dogfood, app-showcase seed (showcase_account.hq x14, showcase_task.location x10, showcase_field_zoo.f_location x1)250
dogfood, packages/qa/dogfood/test/field-zoo.matrix.ts10
catalog, examples/schema-catalog/src/schemas/fields-location/20
total280

Controls from the same call, so the zero is a reading and not a broken probe: { lat: 999, lng: 999 } was refused with too_big at both keys, and { lat: 37.7749, lng: -122.4194 } was accepted.

A second, independent sweep over 12,465 files across both repos for coordinate literals (both the spec lat/lng spelling and the retired latitude/longitude one) found 44 out-of-range values. Every one of them is a test negative fixture, a source docblock, docs prose, or a changeset. Zero in any dataset.

Also counted, because the rule adjudicates the whole value and not only its range: stored location values the spec refuses for any other reason (a retired { latitude, longitude } record, a numeric string). That class is also zero within measurable scope. It is the same 28-value adjudication above, which used the full schema.

Boundary, stated as the ruling requires. Customer deployments are not measurable from a development container. This zero means "zero within measurable scope". It is never evidence that no such coordinate exists.

The shape

buildValidationRules now compiles a validate.location entry that adjudicates a PRESENT value against valueSchemaFor(field, 'stored').

  • The bounds are not restated in objectui. A hand-copied range would be a second contract free to drift from the spec (AGENTS.md #0.1), so the schema is asked and the message is built from its own issues. The same discipline LocationField's existing isSpecAcceptedLocation and refusedRangeMessage already follow.
  • It agrees with the platform's write path by construction. The engine's record validator checks a stored location against this same schema under ADR-0104 D1 (packages/objectql/src/validation/record-validator.ts), warn-first until a deployment's os migrate value-shapes scan certifies zero violations and rejecting afterwards. The form now surfaces that verdict where the person who can correct it is standing, instead of inventing a verdict of its own.
  • Absence stays required's business. The spec's schema describes a present value and refuses null and undefined outright, so the rule asks core's isMissingForRequired, the repo's single presence contract and the same predicate the form renderer's own required validator calls. That is what keeps a create form with an untouched location field valid.
  • A field-authored validate composes under its own key rather than being replaced, spelled the same way the form renderer already normalises rules.validate when it adds required.
  • The field def is passed through verbatim, never rebuilt from keys picked out of it, so the spec keeps deciding what a location value is. Per-def WeakMap caching mirrors the platform's own shapeSchemaFor, as valueSchemaFor's contract requires of runtime consumers.

Pins, in both directions

New: packages/plugin-form/src/ObjectForm.locationStoredRange.test.tsx (13 cases), driving a real ObjectForm.

The expected message is BUILT from the schema's issues in the test rather than typed out, so a bound that moves in the spec cannot leave a stale literal passing here.

Reverse verification

The branch was deleted from the committed tree and the pins re-run. @object-ui/fields is aliased to packages/fields/src by the root vitest.config.mts (line 279), so the ablation acts on source and needs no rebuild for either leg.

  • Mutation proven on disk: HEAD blob 1ca93832, on-disk after ffc00029, deleted-anchor count 1 to 0, injected-marker count 1.
  • Ablated result: 5 failed, 8 passed. The five are exactly the branch-dependent ones (both blocked-direction cases and the three rule-seam cases). The eight that stayed green are the unchanged-behaviour pins, which is the control that says they are not merely measuring the branch.
  • Restore proven: on-disk hash back to 1ca93832, marker count 0, git diff HEAD empty.

Two sibling pins were rewritten, not deleted

ObjectForm.locationRefusal.test.tsx (#6716) and ObjectForm.locationResidue.test.tsx (#6715) each asserted that buildValidationRules compiles NO rule for a location field. That was a scope fence for those cards, and this card is the one that answers it. Both now assert the property that actually survives and that a future edit could still break: the rule exists, and a refusal hands it undefined, so the input-time announcement stays the widget's. ObjectForm.locationRange.test.tsx's docblock was updated for the same reason.

Verification

Green union re-run at aa8372ad4, the final commit, after the docblock correction:

  • pnpm exec vitest run packages/fields/ packages/plugin-form/ packages/components/src/renderers/form/253 files, 3142 tests passed.
  • pnpm --filter @object-ui/fields type-check and pnpm --filter @object-ui/plugin-form type-check — both green. All four edited or added test files are in plugin-form's tsconfig.test.json project (verified by --listFiles, one hit each), so that green actually covers them.
  • Gates, each quoting its own verdict line: check:control-bytes OK (5646 files) - check:spec-symbols OK - check:phantom-deps "Every in-scope import is declared by the package that publishes it" - check:self-import OK - check:esm-specifiers OK - check:i18n-keys OK - check:side-effects-array OK - check:vi-mock-specifiers OK - check:designer-field-key-parity OK - check:action-forward-parity OK - changeset presence and no-major both OK.
  • eslint on both changed packages in full: 306 files, 0 errors, exit 0 (1645 pre-existing warnings, none of them errors).
  • check:control-bytes re-run at aa8372ad4: "OK (scanned 5646 tracked text file(s); skipped 85 binary)".

Not measured here, and left to CI:

  • check:readme-exports — after building the two changed packages it reports zero findings for either of them, but its repo-wide population needs every package's dist and the gate prints "the population COLLAPSED, this run proves nothing" without it. This PR adds no export.
  • check:sdui-registration-pins and check:eager-closure — both need a console build. The gate itself calls its no-build path "exit 2, not a pass". Worth a reviewer's eye on the eager-closure number: this PR adds a runtime import of @objectstack/spec/data to the fields barrel. Its exposure is bounded, because that module is already an eager runtime import in @object-ui/core (server-owned-value.ts, unmaterialized-fields.ts, filter-tokens.ts) and in @object-ui/types (zod/form.zod.ts), so no new vendor chunk is introduced. Budget headroom on main is 45,996 bytes.

The two docblocks this branch falsified are corrected here

Both said buildValidationRules "still has no location branch", which this branch makes false. Leaving that prose in the same package would be a contradiction for the next reader, so it is corrected in the same PR rather than deferred.

Each sentence was a compound claim and only half of it is falsified, so only that half was rewritten:

beforeafter
widgets/LocationField.tsx:356"buildValidationRules still has no location branch, and this card does not give it one.""buildValidationRules HAS a location branch as of objectui#6744 - for that STORED case, never for these refusal arms - and this card did not give it one."
__tests__/LocationField.refusalDiagnostic.test.tsx:32"It still has no location branch and this card does not give it one.""It HAS one as of objectui#6744 - for that STORED case, never for these refusal arms - and this card did not give it one."

The "this card did not give it one" half is true in both places and is kept verbatim: #6716 and the refusal-diagnostic card did not add the branch, this one did. The surrounding paragraph in each file - that a branch installed there sees undefined in both refusal arms while firing correctly for a stored pair - is confirmed by this change, not contradicted, and was left untouched.

Prose only: no assertion, no behaviour, no export, no changeset. Neither file is in PR #6801's file list, verified against its 14 files (it touches CurrencyField, GeolocationField, NumberField, PercentField, numberBadInput and two NumberInputWidgets tests).

Scope

Locked to location, per the ruling. Whether other field types have the same stored-value gap in buildValidationRules was not surveyed and is a separate card.


Session: session_01CRJge11jso9TpXRWFt1Z49


Generated by Claude Code

`buildValidationRules` is the producer of the host-side `error` prop that
every field widget's published objectui#3222 slot reads, and it had no
branch for `location`. A coordinate already in the record that violated
the spec's range was therefore never validated on an edit form: the
control rendered it, nothing marked it invalid, and submitting re-wrote
it unchanged.
It now compiles a `validate.location` entry that adjudicates a PRESENT
value against `valueSchemaFor(field, 'stored')` — the platform's own
value-shape contract (ADR-0104 D1), the same schema the engine's record
validator checks a stored `location` against. The bounds are not
restated in objectui and the message is built from the schema's own
issues, so neither can drift from the spec (AGENTS.md #0.1).
Absence stays `required`'s business: the spec's schema describes a
present value and refuses null/undefined outright, so the rule asks
core's `isMissingForRequired` rather than inventing a second definition
of "empty". A field-authored `validate` composes under its own key
instead of being replaced.
Landed on a hard precondition from the maintainer ruling of 2026-08-29:
28 stored location values across every measurable dataset (app-showcase
seed, qa dogfood field-zoo matrix, objectui schema-catalog) adjudicated
by that same schema — 28 accepted, 0 refused. That zero means "zero
within measurable scope"; customer deployments are not measurable here.
The two sibling pins that asserted the branch's ABSENCE are rewritten to
assert the property that actually survives: a refusal produces no value,
so the host rule is handed `undefined` and the input-time announcement
stays the widget's (objectui#6714/#6716 unchanged).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
Both said `buildValidationRules` "still has no `location` branch". This
branch gives it one, so shipping that prose in the same package would
leave a contradiction for the next reader — the failure mode the
docblock exists to prevent.
Each sentence was a compound claim and only half of it is falsified:
- "still has no `location` branch" -> false as of this branch
- "this card does not give it one" -> still true (#6716 / the
refusal-diagnostic card did not add it; objectui#6744 did)
So the true half is kept verbatim and only the false half is corrected,
with a pointer to the card that added the branch and a note that it
serves the STORED case and never these refusal arms — which is the
property the surrounding paragraph is actually about, and which this
branch confirms rather than contradicts.
Prose only: no assertion, no behaviour, no export, no changeset. Neither
file is in PR #6801's file list (verified against its 14 files).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.1 KB3222.7 KB
Main entry chunk (gzip)148.1 KB350 KB
Entry fileindex-CvBmIYAz.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)241.47KB60.97KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review August 29, 2026 22:15
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit e552c31Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6744-location-validation-branch branch August 30, 2026 03:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-sam@claude