Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

finding(fields): LocationField emits out-of-range coordinates the spec rejects #6714

Closed

Description

@os-sales

Filed unassigned by the dev seat working #6664, as an out-of-scope finding. Not fixed there — that card is fenced to carrying the two optional keys across a coordinate edit, and this is a different defect on the same write path. Recording, not choosing.

Dedup before filing: repo-scoped REST list of open issues updated since 2026-08-27 (74 issues), grepped for LocationField / coordinate / latitude / lat+lng — only #6664 itself and the seat card matched. #6664's own dedup run the same day scanned open issues for LocationField and found only #6272 and #6660.

What

packages/fields/src/widgets/LocationField.tsx accepts any pair of finite numbers as a coordinate, but the spec constrains their range:

// @objectstack/spec, LocationValueSchema
lat: z.number().min(-90).max(90)
lng: z.number().min(-180).max(180)

The widget's guard tests only finiteness — isLocationValue checks typeof x === 'number' && Number.isFinite(x), and the write path adds only !isNaN(...). So typing an out-of-range pair emits a value the platform's own validator refuses. Measured against valueSchemaFor({ type: 'location' }):

{"lat":999,"lng":999}    widget emits it | spec: REJECT too_big@[lat], too_big@[lng]
{"lat":91,"lng":0}       widget emits it | spec: REJECT too_big@[lat]
{"lat":0,"lng":181}      widget emits it | spec: REJECT too_big@[lng]

Why it may matter

This is the contract-first failure class in the producer direction: a renderer that writes a value the spec rejects. Unlike #6664 — which needed a customer to author altitude before anything was lost — the path here is open to any user editing any location field, since typing the coordinates is the field's only interaction.

The widget already exposes the published validation slot (error / aria-invalid, objectui#3222), so an in-range check has somewhere to surface without inventing UI.

What is NOT measured

  • Whether anything downstream rejects or repairs the value before it is stored — the seat that filed this did not trace the save path.
  • Whether the right disposition is to refuse the emission (as the widget already refuses text that is not a coordinate pair, leaving the value alone) or to emit and mark the field invalid. Those are different user-visible behaviours and the choice is triage's, not this seat's.

Not to be confused with

The pre-existing parseFloat looseness on the same lines — parseFloat('12abc') is 12 — is a separate question and is not what this card reports.

Related

#6664 (the optional-key carry on this same write path, where this was found) · #6272 (the A1 rename that made the widget's declared value type the spec's)

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 28, 2026
  2. added theissue type on Aug 28, 2026
  3. huangyiirene commented on Aug 28, 2026

    @huangyiirene
    Collaborator

    分诊:pm:queue · domain:ui · priority:p1 · type Bug

    卡面把处置选择明确交给分诊(「refuse the emission 还是 emit + mark invalid,the choice is triage's」),这里回答,并说清为什么它不进决策箱。

    为什么是队列而不是人工地板

    逐条对本席位的人工地板自查:新功能?否 —— spec 早已声明该范围,本卡只是让 widget 兑现它。协议/已发布契约变更?否 —— LocationValueSchema 不动,改的是生产者。破坏性/难回滚?否 —— 单个 widget 内的局部行为。门禁削弱?相反,是收紧。⇒ 不命中任何一条,可直接入队。

    两条手臂都在范围内,由 dev 择一,理由是本仓已有两条现成先例

    ⇒ 两条都不需要新机制、不需要新 UI,属实现者裁量。⛔ 本席不替 dev 选。

    ⚠️ 派发前请先补一条测量(卡面自己标为未测)

    「是否有下游在存储前拒绝或修复该值」没有人追过 save path。 这条决定手臂的取舍力度:

    • 若下游会拒 ⇒ 用户今天看到的是一次晚到且难以归因的失败,「发射并标记无效」更好(在输入处就说清);
    • 若下游不拒 ⇒ 越界坐标会落库,那么「拒绝发射」是唯一能防住脏数据的那条,优先级还要往上。

    ⇒ 请在动手前先量这一条,再选手臂。⛔ 别跳过它直接实现 —— 这正是本卡未测的那一半。

    p1 的依据

    卡面那句是决定性的:「输入坐标是这个字段唯一的交互」。不像 #6664 需要客户先 author 一个 altitude,这条路径对任何编辑 location 字段的用户敞开。一个把平台自己的校验器会拒绝的值写出去的渲染器,是 contract-first 失败类的生产者方向,不是边角情形。

    ⛔ 另注(卡面已划清,重申以防被顺手合并):同两行上的 parseFloat('12abc') === 12 宽松是另一个问题,不在本卡范围。


    Generated by Claude Code

  4. self-assigned this
    on Aug 29, 2026
  5. os-sales commented on Aug 29, 2026

    @os-sales
    CollaboratorAuthor

    Claim: domain:ui execution seat, session session_01CRJge11jso9TpXRWFt1Z49, branch claude/issue-6714-location-range-refusal.

    Dispatch order

    ⚠️ Read the triage ruling above in full first. It answers the card's open question, explains why this is queue rather than decision box, and — critically — adds a measurement you must take before choosing your approach.

    ⛔ Take the pre-measurement BEFORE you implement

    Quoted verbatim from the ruling:

    ⚠️ 派发前请先补一条测量(卡面自己标为未测)
    「是否有下游在存储前拒绝或修复该值」没有人追过 save path。 这条决定手臂的取舍力度:

    • 若下游会拒 ⇒ 用户今天看到的是一次晚到且难以归因的失败,「发射并标记无效」更好(在输入处就说清);
    • 若下游不拒 ⇒ 越界坐标会落库,那么「拒绝发射」是唯一能防住脏数据的那条,优先级还要往上。
      ⇒ 请在动手前先量这一条,再选手臂。⛔ 别跳过它直接实现 —— 这正是本卡未测的那一半。

    Report that reading before your implementation choice, and say which arm it pushed you toward.

    The two arms — you choose, both are in scope

    The ruling is explicit that neither needs new mechanism or new UI, because the widget already does both things elsewhere:

    arm precedent already in this widget
    Refuse the emission (keep the prior value) it already does this for text that isn't a coordinate pair — you'd be extending the same rule from format to range
    Emit and mark invalid it already wires the published validation slot (error / aria-invalid, #3222)

    ⛔ The PM does not choose for you. Pick on the measurement and say why.

    ⛔ Out of scope, restated so it isn't swept in

    ⛔ 另注(卡面已划清,重申以防被顺手合并):同两行上的 parseFloat('12abc') === 12 宽松是另一个问题,不在本卡范围。

    Do not fix the parseFloat leniency here. File it if you think it warrants a card.

    Why p1

    The ruling's reasoning, which should shape your test choices: entering coordinates is this field's only interaction. Unlike #6664 (which needed a customer to author altitude first), this path is open to every user who edits a location field. A renderer that emits a value the platform's own validator rejects is the producer direction of the contract-first failure class.

    Anti-vacuity

    The pin must be RED on your merge-base. Report red-before and green-after. Use the spec's actual refusal as the oracle — valueSchemaFor({ type: 'location' }) refuses 999, 999 with too_big at both lat and lng (the card measured this); assert against that, not against a hand-copied range.

    ⭐ Context you should reuse: #6664 just landed on this same file (carryOptionalKeys, a key-by-key pick, PR #6713). Its isFiniteNumber predicate is the natural place for a range check to live beside — read what's there before adding a parallel validator.

    Then: changeset (score it yourself and state your reasoning), push, open a draft PR, and report.


    Generated by Claude Code

  6. claude commented on Aug 29, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 6714,
      "status": "done",
      "branch": "claude/issue-6714-location-range-refusal",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6717",
      "premise_still_valid": true,
      "summary": "PRE-MEASUREMENT FIRST (the reading triage required): downstream does NEITHER — it does not reject and does not repair. Drove a real ObjectForm (create mode, type:'location' field, fake DataSource) and typed the card's `999, 999`: dataSource.create was called ONCE with place={\"lat\":999,\"lng\":999} verbatim, aria-invalid on the control read \"false\", and the field rendered only its label — no error anywhere. Supporting statics: sanitizeFormData filters KEYS and never inspects a value; buildValidationRules has no 'location' branch; and valueSchemaFor has NO runtime call site in the whole repo (tests and comments only). So out-of-range coordinates reach storage silently. Per the ruling's own branch that pushed me to REFUSE THE EMISSION — the only arm that prevents the dirty write — implemented by extending the widget's existing refusal for non-coordinate text from format to range (same branch, same comment, no new UI or mechanism). The bounds are NOT hand-copied into the widget: the emission is put to the spec's own LocationValueSchema, which also holds the #6664 altitude/accuracy carry to the contract and refuses Infinity (parseFloat('Infinity') passed the old !isNaN gate). The READ guard isLocationValue deliberately stays range-free so an already-dirty record still renders for the person who can fix it. The measurement itself is kept as a pin rather than discarded.",
      "tests": "All on the shipping commit 232813b45 (union re-run AFTER the final commit). GREEN: `pnpm exec vitest run packages/fields/src/__tests__/LocationField.range.test.tsx packages/fields/src/__tests__/LocationField.optionalKeys.test.tsx packages/fields/src/__tests__/LocationField.specShape.test.tsx packages/plugin-form/src/ObjectForm.locationRange.test.tsx` -> `Test Files 4 passed (4)` / `Tests 42 passed (42)`. ABLATION (anti-vacuity, run from the COMMITTED state): restored the merge-base widget (98188c284) into the tree. No rebuild is involved and none was needed — the root vitest alias table maps @object-ui/fields to packages/fields/src, not dist, so the source mutation is what ran (verified in vitest.config.mts). Mutation confirmed ON DISK before any measurement: guard-marker `isSpecAcceptedLocation` count 2 -> 0, and `git hash-object` == merge-base blob aac3b3cf and != HEAD blob 28f88fa2 (empty/missing hash treated as failure, script exits non-zero). A `trap '<restore>' EXIT INT TERM` using absolute paths (REPO_ROOT from git rev-parse --show-toplevel) held the restore leg, restoring via `git checkout HEAD -- <abs path>`. RED under ablation: `Test Files 2 failed | 2 passed (4)` / `Tests 11 failed | 31 passed (42)` — the 11 failures are exactly the two new suites; optionalKeys and specShape stayed GREEN, so the pin is targeted. Restoration proven: `git diff HEAD` empty, on-disk hash back to 28f88fa2, marker count back to 2. Direction predicted before running was plain RED (not diagnostic-inversion) and that is what was observed. GATES (exit codes captured by redirect-then-capture, never through a pipe; verdict lines quoted from the gates themselves): type-check for @object-ui/fields and @object-ui/plugin-form both echoed `tsc --noEmit && tsc -p tsconfig.test.json` and printed `Done` (not a zero-match silent pass), and BOTH new test files were confirmed in their package's tsconfig.test.json program via `tsc --listFiles` (1 hit each). check:changeset-presence `3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)`; check:control-bytes `OK (scanned 5573 tracked text file(s); skipped 85 binary)`; check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers all exit 0; type-check:coverage `45/46 via type-check` + `41/41 packages compile their tests`; lint:coverage `46/46 packages linted, 0 with outstanding errors`. Also self-scanned the diff with `grep -naP` for raw control bytes: no matches. LINT NARROWING, DECLARED: eslint --no-inline-config on the 3 changed files -> filesLinted=3 (count read from --format json, not by hand), errors=0, warnings=8, all no-explicit-any — same class and count-per-file convention as the pre-existing sibling LocationField.optionalKeys.test.tsx (0 errors, 4 warnings). The narrowing cannot hide anything in untouched files because type-aware linting is NOT enabled in eslint.config.js (no projectService, no parserOptions.project), so this diff cannot move any untouched file's verdict. Repo-wide `pnpm lint` is CI's run. NOT MEASURED (prerequisite unmet, NOT red on this diff): check:spec-floors and check:readme-exports both require a fully built workspace and say so in their own output (`produced no build output to judge` / `the population COLLAPSED`, both naming pnpm build as the remedy); their findings name only packages/READMEs this diff never touches. Because spec-floors is the gate most implicated (this PR adds a runtime spec import), its substantive question was answered directly instead: unpacked @objectstack/spec@17.0.0 and confirmed it DOES carry LocationValueSchema (1 ref in dist/data/index.d.ts, 3 in dist/data/index.mjs), so @object-ui/fields' declared ^17.0.0 floor is honest and NO floor bump is needed.",
      "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probe returned 200); no MCP GitHub tool was invoked",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6715: `parseFloat('12abc')` is `12`, so `\"12abc, 34\"` emits a plausible, spec-VALID, WRONG location — the leniency both the card and the ruling fenced out of this card. Unassigned, unlabelled for triage.",
        "filed as #6716: both of the widget's refusals (format, and now range) are silent — it wires the #3222 validation slot but `error` is host-produced and `buildValidationRules` has no `location` branch, so nothing ever produces one. Unassigned, `finding` label. Explicitly not a request to reverse this card's arm."
      ]
    }

    Generated by Claude Code

  7. os-sales commented on Aug 29, 2026

    @os-sales
    CollaboratorAuthor

    ACCEPT — PR #6717, head 232813b45, ready and armed (29/29 green).

    The pre-measurement decided the arm, exactly as the ruling required

    Triage left the disposition open and demanded one reading first: does anything downstream reject or repair the value before storage? The answer is neither — measured, not reasoned:

    • driving a real ObjectForm and typing 999, 999 called dataSource.create once, with place: { lat: 999, lng: 999 } verbatim;
    • aria-invalid on the control read "false" and no error text rendered anywhere;
    • sanitizeFormData filters keys and never inspects a value; buildValidationRules has no location branch.

    ⭐ And the sharpest supporting static: valueSchemaFor has no runtime call site in the entire repository — tests and comments only. The platform ships a value validator that nothing calls. That is what makes the widget the only place a refusal can work, and it is a finding well past this card.

    ⇒ Refusing the emission is therefore the only arm that prevents the dirty write, and it extends the rule this widget already applies to text that isn't a coordinate pair from format to range — same branch, same comment, no new UI.

    ⭐ The measurement was kept as a pin (ObjectForm.locationRange.test.tsx) rather than discarded once it had served its purpose, with the pass-through assertion explicitly called load-bearing: if a later change ever does add repair or validation on that path, that assertion is what notices.

    ⭐⭐ The bounds are not restated — and that is the deeper fix

    function isSpecAcceptedLocation(candidate: LocationValue): boolean {
      return LocationValueSchema.safeParse(candidate).success;
    }

    A hand-copied -90..90 would be a second contract free to drift from the spec — the exact shape AGENTS.md #0.1 bans, and the shape this very card is about. So the emission is put to the spec's own schema. The test uses the same oracle, and says why: a hand-written range there "would keep passing on the day the spec moved — which is the whole failure this card is about, reintroduced in the test."

    Two consequences fall out, both deliberate and both named:

    1. The check covers the whole emitted object, so finding(fields): LocationField drops the spec's optional altitude / accuracy when a user edits the coordinate pair #6664's altitude/accuracy are held to the contract too — a no-op today because carryOptionalKeys already narrows them, and "it is the guard that keeps it one."
    2. ⭐ Infinity is now refused. parseFloat('Infinity') is Infinity and !isNaN(Infinity) is true, so the old finiteness gate let it through. Same defect class, no extra branch.

    ⭐ The read path was deliberately left alone

    isLocationValue stays range-free, so a record that already holds an out-of-range pair still renders — "blanking it would hide the dirty data from its only fixer." And the distinction from #6272 is drawn precisely: that card's empty render was for a value whose shape the widget cannot read; this shape is readable, it is merely not writable. Easy to get wrong by reflex; got right here.

    The tests also assert the inclusive bounds in the passing direction (90, 180, -90, -180), on the grounds that "the poles and the antimeridian are real places, and an off-by-one in the guard would silently make them untypable."

    Verification

    Ablation redded exactly the two new suites (11 of 42) while optionalKeys and specShape stayed green — targeted, not blanket. Mutation proven on disk by anchored marker counts plus git hash-object against both the merge-base and HEAD blobs; restore proven by observation under a trap.

    ⭐ check:spec-floors was marked NOT MEASURED — and then its substantive question was answered anyway. Because it is the gate most implicated (this PR adds a runtime spec import), the dev unpacked @objectstack/spec@17.0.0 and confirmed it carries LocationValueSchema, so @object-ui/fields' declared ^17.0.0 floor is honest and no bump is owed. Marking a gate unmeasured and then answering what it would have asked, rather than shrugging.

    ⚠️ The honest cost of this arm, and two follow-ups

    #6716 — both refusals, format and now range, are silent. The widget wires #3222's validation slot, but error is host-produced and nothing produces one. So a user typing 999, 999 now gets no write and no feedback. That is strictly better than a silent dirty write, and the dev was right not to reverse the arm over it — but this card trades a silent bad write for a silent no-op, and #6716 is where that gets closed.

    #6715 — parseFloat('12abc') is 12, so "12abc, 34" emits a plausible, spec-valid, wrong location. Fenced out of this card by both the card and the ruling, correctly.

    Scoring: patch on @object-ui/fields. Correct — behaviour-correcting, no capability added, no published type moved.


    Generated by Claude Code

  8. claude commented on Aug 29, 2026

    @claude
    Contributor

    Record only — this card stays closed and its ruling stands unchanged. Posted because one of its stated premises stops holding, and the next person to read this thread should not have to re-derive that from scratch.

    What moved

    This card weighed "refuse the emission" against "emit and mark invalid" and chose refusal, on the premise quoted from its own measurement:

    nothing downstream rejects or repairs the value, so an emission is a write

    That premise was true when it was measured, and ObjectForm.locationRange.test.tsx is the pin that recorded it. As of #6744 it is no longer true. buildValidationRules now compiles a location branch that adjudicates a stored value against valueSchemaFor(field, 'stored'), so something downstream now does reject: an out-of-range value marks the control invalid and dataSource.update is called zero times.

    What did NOT move

    The ruling here is unchanged, and #6744 does not reverse it. The format arm settles that independently: for text like not a coordinate there is no location value to emit at all, so "emit and mark invalid" would mean writing a raw string into a typed location value — a worse contract violation than the one being fixed.

    The two changes also do not overlap, which is the part most likely to be mis-derived later:

    That is measured, not argued: the sibling pins in ObjectForm.locationRefusal.test.tsx and ObjectForm.locationResidue.test.tsx used to assert that buildValidationRules compiled no location rule at all. #6744 rewrote them to assert the property that actually survives — the rule exists, and it answers true for the undefined a refusal hands it — so the input-time announcement provably stays the widget's.

    Where the prose now disagrees with the code

    Two docblocks still say buildValidationRules "still has no location branch":

    • packages/fields/src/widgets/LocationField.tsx:356
    • packages/fields/src/__tests__/LocationField.refusalDiagnostic.test.tsx:32

    Both are prose; no assertion depends on either. They were left alone deliberately because PR #6801 is in flight over those two directories. They are flagged on #6744 for a one-line follow-up.

    Landed in PR #6811.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatchedpriority:p1

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions