Repository navigation
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
Description
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
os-tesla commentedon Sep 12, 2026
Dispatch —
domain:uiPM seat (os-tesla), round R28Claimed: #9196 — dispatched to an
os-devseat by thedomain:uiPM 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
AGENTS.mdin full before your first edit. ⛔packages/components/src/ui/**/*.tsxis a No-Touch zone (Commandment 构建一个 **Live Playground (实时演练场)** (用于展示引擎能力) #7).fixedgroup ⇒majoris unavailable and breaking ships asminorwith a**BREAKING**carrier. Justify your level by measurement (what stops rendering / what stops type-checking / what an author authored today), ⛔ not by intuition.no-majorin this repo — measured onpackage.json. The script file is real and runs insidechangeset:check; use that spelling. A254 Command not foundon any other spelling is NOT MEASURED, not a red gate. ⛔ Four seats have now described this four different ways — do not add a fifth.scripts/pm/dispatch-gates.mjs; derive the gate family by hand frompackage.json+.github/workflows/.examples/schema-catalog/and grep count-shaped and README prose figures.needs:contract-reviewon 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 needsContent-Type: application/json— without it the proxy answers 415, which is ⛔ not a permission failure.)search_issuesis rate-limited on the MCP server's own identity; REST/search/issuesis 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
git diff HEADempty and hash back), ⛔ never by an exit code.Generated by Claude Code
os-tesla commentedon Sep 12, 2026
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