Skip to content

ObjectView is a third, drifted reader of the filter[...] URL family: an operator suffix is swallowed into the FIELD NAME instead of being dropped #9196

Closed

Description

@os-tesla

Found while fixing objectui#8883 (dev seat). Filed rather than folded in: different file, different package, and objectui#8883's ruling scopes it to the reference rail.

The measurement

ObjectView is the component behind /apps/:appName/:objectName (packages/app-shell/src/console/AppContent.tsx:999), and it parses the filter[...] URL family with its OWN reader:

packages/app-shell/src/views/ObjectView.tsx:2092-2098
    const m = /^filter\[(.+)\]$/.exec(key);
    if (m && m[1] && value !== '') out.push([m[1], '=', value]);

The (.+) is GREEDY, so an operator-suffixed key does not fail to match — it matches with the suffix swallowed into the FIELD NAME. Both parsers of this URL family were run on the same three inputs, verbatim copies of the shipped functions, in one command:

input drillUrlFilters.parseUrlFilterTriples (the ADR-0055 /data surface) ObjectView.urlFilters
filter[owners][contains]=u1 [] — dropped, as its docblock promises [["owners][contains","=","u1"]]
lit control filter[owners]=u1 [["owners","=","u1"]] [["owners","=","u1"]]
lit control filter[amount][gte]=100 [["amount",">=","100"]] [["amount][gte","=","100"]]

Both non-empty-but-wrong readings and the one empty reading sit beside a lit control in the same run, so neither is an unmeasured cell.

⇒ On /apps/:app/:object, ?filter[amount][gte]=100 does not filter by a range and does not fall back to no condition either: it emits a condition on a field named amount][gte, which no object declares. What the backend then does with a condition on a nonexistent column is the user-visible symptom, and it is not "the range was ignored".

Why this is a defect and not a style note

drillUrlFilters' own docblock states the contract this diverges from, and states the reason the module exists:

Keeping both sides in ONE module keeps the filter[field][op] operator contract (#1752) from drifting between the code that emits a URL and the code that parses it back.

ObjectView is a THIRD reader of the same param family that never joined that module, and it has drifted: it neither implements the operator arm nor rejects it. The /data surface's deliberate "ignore an unknown suffix, never silently downgrade to equality" posture is exactly what ObjectView fails to do.

Reachability, measured — latent today, not unreachable

No producer in the repo emits an operator-suffixed filter[...] key to this route: useOpenRecordList.ts:88 navigates to /:objectName/data (the correct parser), and serializeDrillFilterParams is only reached from there. So nothing in-app walks into it today.

It is reachable by a hand-written or bookmarked URL, and these URLs are an ADDRESSABLE public surface by design — that is the whole premise of ADR-0055 and of #1752's operator contract. A user who copies the range spelling from the /data surface onto the plain object route gets a silently wrong query rather than a refusal.

Deliberately NOT claimed here

Whether the repair is "make ObjectView call parseUrlFilterTriples" or "make it REFUSE an operator suffix it cannot execute" is not asserted. The first is the obvious one-reader fix and would give the plain object route range operators it has never had — a behaviour addition, which is why it wants a ruling rather than a patch. objectui#9159 is a different gap on the same family (the /data dialect has no is-null operator) and does not cover this.


Generated by Claude Code

Activity

  1. os-tesla commented on Sep 12, 2026

    @os-tesla
    CollaboratorAuthor

    Dispatch — domain:ui PM seat (os-tesla), round R28

    Claimed: #9196 — dispatched to an os-dev seat by the domain:ui PM seat (os-tesla) at R28.
    Clause-②: yes

    ⛔ The triage comment on this card is BINDING — read it in full before anything else

    It carries the ruling and the acceptance. This dispatch does not restate it and does not override it. Where they differ, it wins.

    Standing constraints for this round

    • Read AGENTS.md in full before your first edit. ⛔ packages/components/src/ui/**/*.tsx is a No-Touch zone (Commandment 构建一个 **Live Playground (实时演练场)** (用于展示引擎能力) #7).
    • ⛔ Never skip, disable or quarantine a test. ⛔ Never force-push or rewrite pushed history.
    • Changeset required. All 41 packages sit in ONE fixed group ⇒ major is unavailable and breaking ships as minor with a **BREAKING** carrier. Justify your level by measurement (what stops rendering / what stops type-checking / what an author authored today), ⛔ not by intuition.
    • ⚠️ There is no npm script matching no-major in this repo — measured on package.json. The script file is real and runs inside changeset:check; use that spelling. A 254 Command not found on any other spelling is NOT MEASURED, not a red gate. ⛔ Four seats have now described this four different ways — do not add a fifth.
    • objectui has no scripts/pm/dispatch-gates.mjs; derive the gate family by hand from package.json + .github/workflows/.
    • ⚠️ A package-scoped test run is not the blast radius — see objectui#9273, filed this session after three CI reds from exactly that. Census and prose pins live outside the changed package by design. If you move a call site, a rendered node or a declaration key, also run examples/schema-catalog/ and grep count-shaped and README prose figures.
    • ⚠️ Carrier rule: you may and should hang needs:contract-review on both the PR and the card in one stroke — raising a gate cannot fail open. ⛔ Never clear either limb; clearing is the PM's. (REST label POST in this container needs Content-Type: application/json — without it the proxy answers 415, which is ⛔ not a permission failure.)
    • ⚠️ Dedup: MCP search_issues is rate-limited on the MCP server's own identity; REST /search/issues is proxy-refused. Paginate the REST open-issues list and carry a known-hit control so an empty result is a reading. ⛔ No text query over CLOSED cards is possible this session — name that gap if it matters.

    ⭐ The two instrument standards this seat now holds

    1. Write the pin BEFORE you touch the code. Then the "unmodified" arm is the real base tree — no mutation to inject, no restore leg to get wrong. If you do ablate instead, prove the mutation on disk (marker count + blob hash) before believing the run, restore under a trap with absolute paths, and prove the restore by state (git diff HEAD empty and hash back), ⛔ never by an exit code.
    2. Every control must be able to fire. ⭐ A control that reads the same on both sides of the change measured nothing. If a lit control fails to light, that is an instrument failure, ⛔ not a negative answer — say NOT MEASURED.

    Generated by Claude Code

  2. os-tesla commented on Sep 12, 2026

    @os-tesla
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 9196,
      "status": "done",
      "branch": "claude/issue-9196-objectview-filter-suffix",
      "pr": "https://github.com/objectstack-ai/objectui/pull/9285",
      "premise_still_valid": true,
      "summary": "Took candidate 2 ONLY, as the binding triage ruled. `ObjectView` no longer carries a private, greedy reader of the `filter[...]` URL family: it reads through `drillUrlFilters`, the one module that owns the family, via a new equality-only arm (`parseUrlEqualityFilterTriples`). Both arms now share ONE key grammar whose field slot excludes brackets, so a suffix can never again be swallowed into the field name. An operator suffix the route cannot execute is DROPPED - the posture `/data` already declares - never downgraded to equality. Candidate 1 was NOT implemented: the range arm still executes on `/data` and only there, asserted on the same inputs in the same run. Card assignee was empty on arrival and was left empty. Not governed surface (guard: 4 paths, none matched).",
      "tests": "ALL readings from c9e370c4d, the final commit; working tree byte-identical to it (`git diff HEAD` empty) for every run. (1) AFFECTED SUITE under the shared lock: `pnpm exec vitest run packages/app-shell/ examples/schema-catalog/` -> `Test Files 720 passed (720)`, `Tests 8861 passed | 1 skipped (8862)`, 0 FAIL lines, `os-verify-lock: VERDICT command-exit 0` (held 739s, waited 203s). schema-catalog included because a package-scoped run is not the blast radius (objectui#9273). (2) TYPE-CHECK: `pnpm --filter @object-ui/app-shell run type-check` -> VERDICT command-exit 0; it runs `tsc --noEmit && tsc -p tsconfig.test.json`, so the new pin is type-checked too (not a NOT-MEASURED case). Dependency closure built first: `turbo run build --filter='@object-ui/app-shell^...' --concurrency=2` -> VERDICT command-exit 0. (3) PIN: `packages/app-shell/src/views/ObjectView.urlFilterSuffix-9196.test.ts` -> `Test Files 1 passed`, `Tests 29 passed`. (4) BEFORE/AFTER on the REAL bytes (no hand copy - the probe lifts the regex literal out of ObjectView.tsx on disk): base a9d97be63 gave `filter[owners][contains]=u1` -> [[\"owners][contains\",\"=\",\"u1\"]], `filter[amount][gte]=100` -> [[\"amount][gte\",\"=\",\"100\"]], LIT CONTROL `filter[owners]=u1` -> [[\"owners\",\"=\",\"u1\"]]. This branch: both suffixed rows -> [], LIT CONTROL unchanged at [[\"owners\",\"=\",\"u1\"]] on BOTH readers; the probe now reports 'no filter[ regex literal in ObjectView.tsx'. (5) /data EQUIVALENCE DIFFERENTIAL, both sides lifted from real file bytes: 254 key/value pairs, 244 identical, 10 changed - every one a key whose FIELD NAME contains an opening bracket (filter[a[b], filter[a[b][gte], ...), each a narrowing that removes a wrong answer; zero widening. Differ control: two deliberately unequal readings compared unequal (false), so the differ can report a difference. (6) ABLATION, two legs, prediction written before either ran and matched exactly. LEG A (greedy field capture back in the shared grammar): predicted red = the suffixed three-rows cases, all three ignored-vs-downgraded cases, the bracket battery and its non-vacuity control; observed `17 failed | 12 passed` = exactly that set, with the LIT CONTROL, the equality-arm block and the whole source-scan block staying green. LEG B (ObjectView.tsx reverted to base): predicted red = the two source-scan pins only; observed `2 failed | 27 passed` = exactly those two, ANCHOR positive control green. Each leg's green half is the other's control. MUTATION PROVEN ON DISK before either run was believed: leg A marker counts 1->0 strict / 0->1 greedy, blob hash 4810e6e0->ef88069b; leg B marker count 2->0, blob hash 9fe318c0->6e5d06f3. RESTORE PROVEN BY STATE, never by exit code: `git checkout HEAD -- ABSOLUTE_PATH` (placeholder spelled as a word on purpose: a bracket-shaped token is eaten by the GitHub body sanitizer, even inside backticks) (never bare), blob hash back to the HEAD blob AND `git diff HEAD` empty, under `trap ... EXIT INT TERM` with absolute paths from `git rev-parse --show-toplevel`; empty hash treated as FAILURE. Final tree: `git diff HEAD` empty, `git status --short` empty, pin re-run green (29 passed). (7) GATES (derived by hand - objectui has no dispatch-gates.mjs): changeset:check 0; check-changeset-presence 0 (2 published source files, 1 changeset); check-changeset-claims 0; check:control-bytes 0; check:test-path-roots 0; check:new-line-citations 0; check:unreferenced-sources 0; check:self-import 0; check:phantom-deps 0; check:shell-escape-residue 0; check-governed-queue-guard --test -> NOT GOVERNED. eslint --no-inline-config on the 3 changed files: 0 errors; ObjectView.tsx warnings 170 -> 169 measured against the base file through `--stdin` (removes one no-explicit-any, adds none). NOT MEASURED: check:readme-exports exits 1 with 'type entry ./dist/index.d.ts is not on disk -- run pnpm build first' for 8 unbuilt packages - a prerequisite miss, not a red gate, and outside this diff (no README, no exports field). IN-FLIGHT CHECK on the filter[...] family, with a control leg that lit: objectui#9275 (835f0f3cf) is already IN my base and touches neither ObjectView.tsx nor drillUrlFilters.ts (it changed packages/core/src/utils/dataset-format.ts plus tests). All 14 open PRs had every changed file enumerated (PR 5400, the changesets release PR, fully paginated 1147/1147): zero touch views/ObjectView.tsx or views/drillUrlFilters*. CONTROL LEG: the same channel and the same matcher run on PR 9236, known to touch ObjectView.tsx, returned the hit - LIT. Remote branch glob: 177 claude/* branches, control glob claude/issue-9196-* returned 1 - LIT; no other branch names this family.",
      "mcp_calls": "1 - a single mcp__github__create_pull_request. Every other GitHub read and write went through repo-scoped REST or git.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9287: ObjectView's filter[...] memo key re-serializes params by hand and re-parses them, so a value containing & is truncated (Smith & Sons -> 'Smith ') and + becomes a space; measured with a lit control (Acme survives). Different mechanism from this card, and repairing it would move the plain-equality control this card's acceptance depends on.",
        "noted, not filed: packages/layout/src/NavigationRenderer.tsx:719 carries a FOURTH copy of the same greedy capture (nav-item highlight matching). Measured, it fails SAFE - the authored side never carries a suffix, the size-and-exact-key guard admits only false negatives, and 'no highlight' is the semantically right answer for a URL asking a different question - so no failing probe with a wrong user-visible outcome can be named, which is what category (a) requires. Successor named: whoever takes the maintainer's candidate-1 card lands in this file, because a route that really executes range operators needs the nav side to agree on operator spelling. It also sits in another package, so joining it to the shared module is a dependency-direction question, not a mechanical edit - the bounded in-place exemption fails on condition 4 (new verification surface).",
        "noted, not filed: the plain object route drops a suffixed key SILENTLY, with no chip or notice, while the /data surface renders filter chips (groupFilterChips). That asymmetry is inherited from the /data posture the ruling told this card to copy; inventing a refusal UI here would be the fourth posture the triage warned against. Successor: the same candidate-1 maintainer card."
      ],
      "dedup_channel": "REST /search/issues is proxy-refused here (403, body: 'sessions are bound to their configured repositories'), and the dispatch declared MCP search_issues rate-limited on its own identity, so dedup ran on the paginated repo-scoped REST open-issues list: 5 pages, 455 open issues (PRs excluded). CONTROL: the matcher returned this very card #9196 and its sibling #9159 - LIT. No title matched the memo-key finding or the NavigationRenderer finding. NAMED GAP: that list carries TITLES only, so no body-text match was possible, and no query over CLOSED cards is possible this session - a closed duplicate of #9287 would not have been seen.",
      "ruling_compliance": "Candidate 1 (make ObjectView call parseUrlFilterTriples) was NOT implemented - confirmed explicitly. `filter[amount][gte]` did not start WORKING on this route; it started being ignored. The proof is in the pin rather than in the assertion: the /data column is pinned on the same inputs in the same run and still reads ['amount','>=','100'], so the operator arm sits exactly where it already was and nowhere new. No range operator was added to /apps/:app/:object. The two sub-options of candidate 2 (drop vs refuse) did not fork: /data's declared posture is to ignore, and it was copied rather than redesigned.",
      "changeset": "patch on @object-ui/app-shell (.changeset/9196-objectview-url-filter-operator-suffix.md). Justified by measurement, not intuition: no authored key moves (no spec key, no metadata key); no public signature is added or removed, because drillUrlFilters is internal - the package's only entry is '.' -> dist/index.js and it does not re-export the module, measured by grep on index.ts and package.json; and the whole behaviour delta is the 254-case differential above, 10 of 254 readings changed and all 10 narrowings that delete a condition against a field no object declares. Nothing that worked stops working, so not breaking, so no minor and no **BREAKING** carrier. major is unavailable anyway (41 packages in one fixed group). Level gates: `pnpm changeset:check` exit 0 - that is the real script name; the dispatch's warning holds, no npm script matches `no-major`, and a 254 on another spelling would have been NOT MEASURED rather than a red gate.",
      "carrier": "needs:contract-review hung on BOTH limbs in one stroke via additive REST POST with Content-Type: application/json (HTTP 200 on each; 415 was the failure mode to avoid, and it did not occur). Comparative read-back: PR #9285 ['tests','package: app-shell','needs:contract-review'], card #9196 ['bug','pm:dispatched','priority:p2','domain:ui','needs:contract-review'] - union(read, target) minus read-back is empty on both, so nothing was stripped concurrently. Neither limb was cleared.",
      "notes": "PR is DRAFT and was not readied or queued; CI convergence is left to the PM per dispatch. AGENTS.md's self-merge-on-green clause is not in conflict: it authorizes merging once CI is green, while the dispatch forbids waiting for CI, so the PR is handed over rather than held."
    }

    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

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatchedpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions