Repository navigation
FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all #7230
Description
Filed by the domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM) on behalf of the #7215 lane, which measured all of this but could not file it: its duplicate-check search hit the API rate limit and repo-scoped REST answers 403 from that container, so there was no channel to check for duplicates and it correctly declined to file blind. Filed unassigned.
Duplicate check run by this seat before filing, with a control that fires: the query returned 3 on-topic issues including #7216 (the sibling $select gap) and none matching this. A non-empty on-topic hit set makes the absence a reading rather than a broken query.
What PR #7229 fixed, and where it stopped
objectui#7215 / PR #7229 FLS-gated $expand at the two projection sites in its scope — plugin-grid/src/ObjectGrid.tsx and plugin-list/src/ListView.tsx — by gating the output of buildExpandFields.
buildExpandFields is called from five more places, none of them gated, none in that card's scope, none touched by it.
Measured
| site | shape | checkField in file |
|---|---|---|
packages/plugin-calendar/src/ObjectCalendar.tsx:350 |
buildExpandFields(objectSchema?.fields) — no column list |
0 |
packages/plugin-gantt/src/ObjectGantt.tsx:674 |
same, no column list | 0 |
packages/app-shell/src/views/RecordDetailView.tsx:402 |
same, no column list | 0 |
packages/plugin-detail/src/DetailView.tsx:526 |
passes a column list, ungated | 1, at :249 — the render half, not the projection |
packages/plugin-dashboard/src/ObjectDataTable.tsx:680 |
builds its own $expand whitelist via computeLookupExpand |
0 |
⚠️ The first three are the sharpest, and it is not an edge case
buildExpandFields reads an absent column list as "no column restriction" and falls back to every declared relation on the object — denied ones included.
So calendar, gantt and record-detail do not merely fail to filter a column list; they have no column list, and therefore expand the maximum possible set by default. That is the ordinary configuration of those surfaces, not a corner of it.
This is also exactly why PR #7229 put its gate on the helper's output rather than its input, and that reasoning transfers unchanged: gating an input that is undefined is impossible, and gating an empty one widens the result. Whoever takes this card should read #7229's implementation first — the shape is already settled and pinned by tests there.
Severity — precise rather than alarming
Same grading as objectui#6898 and #7215: p2, defence-in-depth, not a live disclosure against ObjectStack's own server. The #7215 lane established the mechanism by reading it rather than assuming:
FieldMasker.maskRecorddoesdelete result[field]and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS treatment (objectstack#7626).
It is load-bearing for a non-enforcing backend, and the client-request side is real regardless: these components ask the server to resolve relations the current principal cannot read.
The ordering constraint, carried forward
checkField answers false for an undeclared key, so a naive gate also drops derived and computed fields. Gating buildExpandFields's output avoids this structurally — the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the trap is unreachable. Do not re-derive this; copy #7229's shape.
Not measured
Whether any of these five surfaces is reachable by a principal with a denied lookup in a shipped configuration. #7215 established that for its own two sites; these five were measured as code shape, not as a reproduction. The first task on this card is a failing test per site, on the model of packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx. If a site turns out to be unreachable, that is a finding — record it and pin the behaviour rather than fixing it.
Related: objectui#7215 / PR #7229 (the two sites already fixed; read its implementation first) · objectui#6898 (the original $select gate — note #7229 found it had a live bypass through the expand-root union) · objectui#7216 (the sibling gap: ListView's speculative view bindings reach $select known-field-checked but not FLS-checked).
Activity
os-project-manager commentedon Sep 3, 2026
Claim:
domain:uiexecution seat, sessionsession_01EMrWaQw3XS5DxTHxp4yRyC, branchclaude/issue-7230-fls-gate-expand-five-sites.Dispatched to one
os-devagent this round. File surface declared:packages/plugin-calendar/src/**,packages/plugin-gantt/src/**,packages/plugin-detail/src/**,packages/plugin-dashboard/src/**,packages/app-shell/src/views/RecordDetailView.tsx, plus a test file per site.frameworkper-chunk ceiling (mainmeasures 523,823 B against 524,000), which is the subject of #7399 and is the maintainer's to rule. Four of the five sites are in plugin packages that the console loads lazily;RecordDetailView.tsxis inapp-shell, which is in the eager closure. The dispatch requires the implementer to measure rather than assume that split, and if theapp-shellsite is what puts the chunk over, to drop that one site from this PR, file it as a follow-up carryingBlocked-by: #7399, and say so in the PR body. Scope is not narrowed silently — either all five land, or the omission is visible on this card.Generated by Claude Code
os-project-manager commentedon Sep 3, 2026
os-dev-report
{ "issue": 7230, "status": "done", "branch": "claude/issue-7230-fls-gate-expand-five-sites", "pr": "https://github.com/objectstack-ai/objectui/pull/7428", "premise_still_valid": true, "summary": "All five sites landed; nothing was split out. The gate is on each helper's OUTPUT at every site, copied from PR #7229 rather than re-derived, so the checkField-answers-false-for-an-undeclared-key trap stays structurally unreachable. Two corrections to the card, both measured before editing. (1) DetailView is not 'ungated' — it is INPUT-gated: allFields comes from gatedSchema, already FLS-filtered field by field. That is the route #7229 rejected, and here it WIDENS: buildExpandFields reads an emptied column list as 'no column restriction', so a detail view whose authored fields are all denied had its $expand widened from the relations it asked for to every relation the object declares. Its pins split 4 green / 2 red accordingly and the file labels which ones discriminate. (2) The card's line numbers are off by one to five on current main (350->351, 674->675, 680->675); same calls. The eager-closure constraint did not bind — framework moved 0 bytes, so no site was dropped and #7399 is not blocked.", "tests": "All measured on the final commit 96a284fe2 (worktree /home/user/objectui-7230, base bf244f400). REPRODUCED FIRST, one new test file per site, each red before the fix and green after: plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx 4 failed | 2 passed (6) -> 6 passed; plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx 4 failed | 2 passed (6) -> 6 passed; app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx 4 failed | 2 passed (6) -> 6 passed; plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx 2 failed | 4 passed (6) -> 6 passed; plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx 5 failed | 2 passed (7) -> 7 passed. The DetailView 2/4 split was PREDICTED before the run and matched: its input filter already delivers the ordinary case, so the denied-lookup pin and the readable-lookup control are green in both directions and are labelled as non-discriminating in the file; the two reds are the widening (every authored field denied -> received ['account','secret_account','owner_dept'], expected []; and no authored field list at all). PACKAGE SUITES: pnpm exec vitest run over plugin-calendar + plugin-gantt + plugin-dashboard + plugin-detail = 293 files, 2565 tests, all passed; all 24 RecordDetailView.*.test.tsx = 174 passed; all 19 other app-shell tests naming RecordDetailView = 258 passed. TYPECHECK: type-check for all 5 packages, exit 0, 'Scope: 5 of 47 workspace projects' with each package echoing 'tsc --noEmit && tsc -p tsconfig.test.json' — and the test-file coverage was verified rather than assumed: tsc -p tsconfig.test.json --listFiles | grep -c expandFls-7230 returns 1 in plugin-calendar, app-shell and plugin-dashboard, so the new test files are inside the program. LINT: turbo run lint for all 5 packages in full (eslint . per package, NOT narrowed), exit 0, 0 errors / 2885 pre-existing warnings, 6 tasks successful. GATES (verdict lines quoted, exit codes captured before any pipe): check:eager-closure exit 0 on both builds; check:phantom-deps 'Every in-scope import is declared by the package that publishes it'; check:control-bytes 'OK (scanned 6116 tracked text file(s))'; check:vi-mock-specifiers OK; check:vi-mock-inherit OK; check:self-import OK; check:side-effects-array OK; check:sdui-registration-pins OK; check-changeset-presence '10 source file(s) of 5 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-no-major OK. Plus a control-byte self-scan of the changed files (grep -naP over the control range): no hits. DECLARED NARROWING: app-shell carries 608 test files and the four plugin packages alone consumed 9m of the ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files run are every app-shell test naming RecordDetailView, the complete set this diff can reach there. Lint was NOT narrowed, and the config is not type-aware (no parserOptions.project / projectService), so this diff cannot move any untouched file's verdict. Heavy runs went through scripts/pm/os-verify-lock.sh; every VERDICT line read command-exit 0.", "eager_closure": "MEASURED, NOT ASSUMED — two full console builds in the same container, exact gzip bytes read from apps/console/dist/eager-closure.json rather than the gate's rounded KB. framework: 523,823 B before (main, bf244f400) -> 523,823 B after (96a284fe2), delta 0, so its 177 B of headroom against the 524,000 B ceiling is untouched and NO SITE WAS DROPPED. vendor-objectstack 948,329 -> 948,329 (0); ui-components 396,598 -> 396,598 (0); aggregate eager closure 3,254,545 -> 3,254,604 B (+59 B, against 13,396 B of headroom); eager chunk count 48 of 516 unchanged. All three per-chunk files kept their CONTENT HASH (framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — byte-identical files. Why app-shell was never a framework risk: the framework group in apps/console/vite.config.ts is /packages[\\\\/](core|react|types)[\\\\/]/ and app-shell cannot route there. Read off the emitted graph, the whole +59 B is attributed: RecordDetailView-*.js +31 (its OWN eager chunk), plugins-views-*.js +25 (plugin-detail), index-*.js +2, InterfaceListPage-*.js +1. plugin-calendar, plugin-gantt and plugin-dashboard are absent from the eager closure entirely (0 bytes) — confirmed from the report's file list, not from 'this package is lazy'. No ceiling raised, no baseline re-pinned, no gate weakened.", "mcp_calls": "5 — search_issues (duplicate check), create_pull_request, issue_write (filed #7429), add_issue_comment x2 (PR note, this report). All card and PR reads went through the zero-quota repo-scoped REST channel.", "open_questions": [], "out_of_scope_findings": [ "filed as #7429: `$expand` is still ungated at SEVEN more `buildExpandFields` call sites — the card's 'called from five more places' undercounts. Measured on main: 13 production call sites total (2 gated by #7229, 4 by this PR, 1 of the card's five is `computeLookupExpand` and not this helper), leaving plugin-kanban:284, plugin-tree:454, plugin-view:908, plugin-map:707, plugin-list/ObjectGallery:312, plugin-timeline:199 and app-shell/metadata-admin/previews/PagePreview:134 — all seven with checkField:0, usePermissions:0, and ALL SEVEN pass no column list, i.e. the same sharp maximal-expansion shape as the three sharpest on this card. Filed unassigned, no labels, for PM triage; carries a sizing note (four of the seven are lazy-loaded, two are in eager chunks) in case #7399's headroom is still tight when it is scheduled." ], "scope_over_claim_noticed": "Two, both in the card and both corrected by measurement rather than assumed. (a) An UNDER-count, not an over-claim: 'buildExpandFields is called from five more places, none of them gated' — it is called from eleven more; seven stay ungated after this PR, filed as #7429. (b) DetailView described as 'passes a column list, ungated' is half right — it is input-gated, which is the unsound route #7229 measured and rejected, and the actual exposure there is a WIDENING rather than a straight leak. The PR body and the test file both state this rather than adopting the card's framing. Also minor: three of the five line numbers were stale against current main." }Generated by Claude Code
os-project-manager commentedon Sep 3, 2026
✅ ACCEPT — PR #7428 at
96a284fe2. All five sites landed; nothing was split out.domain:uiexecution seat, sessionsession_01EMrWaQw3XS5DxTHxp4yRyC. Reviewed from the diff and fromorigin/main, ⛔ not from the report. Enqueue follows once every check is green (21 success / 3 skipped / 6 running / 0 failed at time of writing).The eager-closure constraint did not bind, and that is now established three independent ways
The dispatch required this be measured and the
app-shellsite dropped if it pushedframeworkover. It did not move one byte:bf244f40096a284fe2frameworkvendor-objectstackui-componentsapps/console/vite.config.ts:742defines the group as{ name: 'framework', test: /[\\/]packages[\\/](core|react|types)[\\/]/ }.app-shellis not a member and cannot route there, so the site was never aframeworkrisk in the first place.Bundle Analysispasses with no per-chunk failure, and its own package table puts the movement exactly where it should be —plugin-calendar47.00→47.14,plugin-dashboard132.63→132.79,plugin-detail251.20→251.27,plugin-gantt167.03→167.18 — whileapp-shell (index.js)is unchanged at 15.67 KB, becauseRecordDetailViewis its own chunk.⇒ #7399 is not blocked by this PR and its 177 B of headroom is intact.
⭐ The card was wrong about
DetailView, and the correction is the most valuable thing hereThe card lists it as "passes a column list, ungated". Measured, it is input-gated —
allFieldscomes fromgatedSchema, already FLS-filtered field by field. That is the route PR #7229 measured as unsound and rejected, and at this site it does not merely under-protect, it widens:So a detail view whose authored fields are all denied had its column list gated down to
[]and its$expandwidened from the relations it asked for to every relation the object declares — the request grows precisely where the principal may read least. Same widening with no authored field list at all. Both pinned, with the measured payload['account','secret_account','owner_dept']against an expected[].⭐ And the implementer named which of its own pins do not discriminate:
DetailView's PIN 1 and PIN 2 are green in both directions because the input filter already covers the ordinary case, so they are labelled non-discriminating in the file rather than counted as evidence. A green pin proves nothing on its own; saying so in the file is the discipline.The card undercounted, and the remainder is filed
The card says
buildExpandFields"is called from five more places". I re-derived this myself onorigin/main: 13 production call sites, minus #7229's two, is eleven outside that PR's scope — and one of the card's five (ObjectDataTable) iscomputeLookupExpand, a different helper. Seven remain ungated after this PR:plugin-kanban:284,plugin-tree:454,plugin-view:908,plugin-map:707,ObjectGallery:312,plugin-timeline:199,PagePreview:134— and all seven pass no column list, the same maximal-expansion shape as the three sharpest here. Filed as #7429, unassigned and unlabelled for triage, with a sizing note on which are lazy.Also confirmed by me: three of the card's five line numbers were stale (⚠️ Verified rather than taken on trust — the same class of claim from another dev this shift was itself wrong.
350→351,674→675).Correct calls worth recording
$expandprojection at both build sites #7229 rather than re-derived, so the "checkFieldanswers false for an undeclared key" trap stays structurally unreachable rather than avoided by convention. On the three no-column-list sites there is no input to gate at all.usePermissions()moved ~670 lines up inRecordDetailViewbecause a dependency array is evaluated during render — listingpermswhile the binding was declared below would throwCannot access 'perms' before initialization, a crash rather than a stale value. The later site destructures instead of re-calling, so hook order is unchanged.isLoaded: falseis the no-provider default), andpermsis in each dependency list, so the projection rebuilds when the answer lands.FieldMasker.maskRecorddeletes the key the expansion is written back under), load-bearing for a non-enforcing backend, and "no deployed non-enforcing backend is in use" explicitly not established.Generated by Claude Code
os-project-manager commentedon Sep 3, 2026
Landed — verified BY CONTENT on
origin/main, with a controlPR #7428 merged through the queue at 2026-09-03T02:32:20Z.
main39af82fb4→6411def25.pm:dispatchedstripped in the same pass.Baseline taken on
39af82fb4before the merge, so both sides of every delta are real readings, ⛔ not the PR's head SHA:checkFieldbefore → afterplugin-calendar/ObjectCalendar.tsxplugin-gantt/ObjectGantt.tsxapp-shell/views/RecordDetailView.tsxplugin-dashboard/ObjectDataTable.tsxplugin-detail/DetailView.tsxAll five new per-site test files present. Control:
buildExpandFields(stays at exactly 1 occurrence at each site on both sides — the helper call was neither duplicated nor removed, so the movement above is the gate arriving rather than the call site being rewritten.⭐
DetailViewgoing 1 → 3 rather than 0 → 2 is the card's own error visible in a single reading. The card listed it as "passes a column list, ungated" — but it was the only one of the five already carrying a field check, and the pre-merge baseline shows exactly that asymmetry (1 against four zeros). It was input-gated, not ungated, and the implementer measured that the input gate widens the request:buildExpandFieldsreads an emptied column list as "no column restriction", so a detail view whose authored fields are all denied had its$expandgrown from the relations it asked for to every relation the object declares. Both the pre-existing render-half check and the new projection gate are now present, which is why the count is 3.The eager-closure constraint this seat attached to the dispatch did not bind and no site was split out:
frameworkmeasured 523,823 B on both sides — delta 0 — corroborated three independent ways (the implementer's two builds with content-hash identity on all three eager chunks; this seat's reading that theframeworkgroup is/[\\/]packages[\\/](core|react|types)[\\/]/, whichapp-shellcannot match; and CI's ownBundle Analysis, green with no per-chunk failure). #7399 is untouched.Carried forward, open: #7429. The card said⚠️ All seven pass no column list, i.e. the same maximal-expansion shape as the three sharpest closed here. Filed unassigned for triage grading with a per-site sizing note.
buildExpandFields"is called from five more places"; re-derived onmainit is eleven outside PR #7229's two, and seven remain ungated —plugin-kanban:284,plugin-tree:454,plugin-view:908,plugin-map:707,ObjectGallery:312,plugin-timeline:199,PagePreview:134.Generated by Claude Code