Skip to content

fix(core): harvest a row action's defaultFromRow seed field and {field} target tokens into $select - #10386

Merged
os-litant merged 5 commits into
mainfrom
claude/issue-10277-harvest-defaultfromrow-target-tokens
Sep 24, 2026
Merged

os-litant merged 5 commits into
mainfrom
claude/issue-10277-harvest-defaultfromrow-target-tokens

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #10277

Clause-②: no

What was wrong

listViewPredicates in @object-ui/core (packages/core/src/utils/predicate-fields.ts) is the harvest ListView, ObjectGrid and RelatedList's authored path use to decide which fields a projected row must carry. It read an action's visible / disabled, its recordIdField and the object's userActions visibleWhen / disabledWhen, but not two other row keys a row action reads:

  • the field a defaultFromRow param seeds from;
  • the {field} tokens of the action's target.

On a projected row those keys are absent. resolveActionParam seeds a param only when the row owns the key, so the param dialog opened blank; the console api handler filled the token with an empty string. Nothing said so.

Measured first (triage rule 1), on origin/main = ba0b61a60

Temporary probes, deleted before commit. Fixture: object team_member with fields name, team_id, user_id; view columns ['name']; one row action shaped like objectstack sys_team_member.remove_team_member (params teamId / userId bound to field: 'team_id' / 'user_id', defaultFromRow: true); a data source that returns only the keys $select asks for.

Probe $select row handed on result
real ObjectGrid, real kebab click, captured _rowRecord fed to the real resolveActionParams ["id","name"] {"id":"tm_1","name":"Ada"} defaults teamId: null, userId: null
control: same grid, columns name, team_id, user_id ["id","name","team_id","user_id"] carries both keys defaults team_42, user_7
real ListView (child grid stubbed) ["id","name"] rows carry id, name only
real console apiHandler, target /api/v1/teams/{team_id}/members/{user_id}, the projected row URL /api/v1/teams//members/
control: a row carrying both keys URL /api/v1/teams/team_42/members/user_7

The premise holds. After the fix the same probes read: grid $select ["id","name","team_id","user_id"] and resolveActionParams defaults team_42 / user_7 with columns ['name']; ListView, whose source is untouched, sends $select ["id","name","team_id","user_id"] and hands the grid rows carrying both keys.

The change

listViewPredicates now also pushes, for each def on the row, bulk and object action lists:

  • record.KEY for every param whose defaultFromRow is set, where KEY is field ?? name and a bare identifier (the recordIdField gate);
  • record.TOKEN for every {TOKEN} in a string target, using the console handler's own pattern.

Both flow through the existing collectPredicateFieldRefs and then each consumer's existing gates. No consumer source changes.

Mechanism assumptions, measured

  • A1, the param-to-field key. The runtime reads row[field ?? name] (rowValueKey in app-shell's resolveActionParams) under an own-property check. The installed spec (@objectstack/spec 17.4.0, ActionParamSchema) documents the key as "the resolved field name", name defaulting to field. The harvest reads exactly field ?? name, with the same truthiness test on defaultFromRow the seeding makes. carryOver needs no arm of its own: the spec refuses it without defaultFromRow: true, and its value is that same seed.

  • A2, the token grammar. The console api handler (useConsoleActionRuntime) substitutes the pattern \{([a-z_][a-z0-9_]*)\} (flags gi) from the row record; RecordDetailView carries the same pattern for record pages. The harvest uses it verbatim, so it harvests exactly what the handler fills. Skipped, and pinned as skipped: {owner.name} (dotted path), {a + b} (expression), ${param.token} / ${ctx.recordId} (the runner's own interpolateTarget scopes, which read the param bag and the runner context, not the row), {1st} (not an identifier). The harvest is not narrowed by action type, for the asymmetry the harvest already documents: an extra column costs bytes, a missing one breaks silently.

  • A3, FLS. Each consumer filters AFTER the harvest, on the harvested names:

    • ObjectGrid: collectPredicateFieldRefs(listViewPredicates(...)), then .filter(isProjectableField), then .filter(passesProjectionGate), which asks perms.checkField(objectName, f, 'read') for a declared field.
    • ListView: each harvested name goes through addPredicateField and addSpeculative, whose known-field gate is followed by perms.checkField(schema.objectName, f, 'read') for a declared, non-platform field.
    • RelatedList: each operand must be declared (either container shape) or a platform column, and a declared one must pass readable(field), which asks perms.checkField(relatedObjectName, field, 'read').

    Pinned with the real PermissionProvider: user_id, named by a defaultFromRow param AND a target token and denied by policy, is in no RelatedList request (last $select is ['id','name','team_id']); the control with the denial lifted asks for it.

  • A4, serial constraint. ListView.tsx is untouched. Its projection changes through the shared harvest alone; that was read by the temporary probe above and is not pinned in this PR.

  • A5, pins. Below.

Pins, and the ablation that shows each can fail

New:

  • packages/core/src/utils/__tests__/predicate-fields.test.ts: 8 cases in a new describe. The named producer's shape harvests team_id / user_id, not teamId; name fallback; only seeding params; a non-identifier param key dropped; target tokens harvested; non-identifier tokens skipped; all three action lists; a value-bag params and malformed entries read nothing. Each negative case carries a same-fixture positive control, so it cannot pass on a harvest that never ran.
  • packages/plugin-grid/src/__tests__/defaultFromRowProjection-10277.test.tsx: 5 cases. Object actions; rowActionDefs and bulkActionDefs; target tokens; PIN 4, through a real kebab click on a data source that honours $select, the row handed to the param-collection handler OWNS team_id / user_id (the own-property check resolveActionParam makes) with their stored values; the undeclared-key guard.
  • packages/plugin-detail/src/__tests__/RelatedList.defaultFromRowSelect-10277.test.tsx: the FLS pin and its control (A3).

The 19 pre-existing cases in predicate-fields.test.ts are the unchanged-behaviour pins; they stay as written and green.

Ablation, commit-first: predicate-fields.ts checked out from ba0b61a60 and hash-verified equal to the base blob, the three files run, then restored from HEAD with the hash equal to the HEAD blob and git diff HEAD empty, all under an exit trap. Ablated: 15 failed, 19 passed (34), which is every new case red and every pre-existing case green. On HEAD: 34 passed (34). Last run on the final head 756592af8.

Two edits worth naming

  1. packages/core/src/utils/__tests__/column-identity.ratchet.test.ts (inside the claimed test directory). The 列身份双读家族:field ?? name 两种优先序并存于 15+ 处——ingestion 归一 + 单键消费 + 禁新增闸门(objectstack#4115) #3104 dual-read scanner counts the new field ?? name read, and the whole core run went red on it. It is the row-key half of resolveActionParams' two-layer pair, so it is recorded with verdict two-layer and a reason, total 11 to 12. columnIdentity() was not used: it also reads fieldName and treats '' as absent, so it would harvest keys the runtime never reads.
  2. .changeset/10186-related-list-fls-select.md, OUTSIDE the claimed file surface. That pending changeset (@object-ui/plugin-detail: patch) ends by saying a defaultFromRow param or a {field} URL token only gets its field when the projection already carries it. This PR makes that false in the same release, and a pending changeset publishes verbatim, so a supersession note is appended (the objectui#9542 precedent) rather than a rewrite; the declared package set is unchanged. check-changeset-overwrite reports it (report-only; its case 2, a prose correction). It is its own commit, d877573da, so it can be dropped alone if the seat wants another route.

Local verification

  • pnpm exec vitest run packages/core/ plus the harvest consumer pins (defaultFromRowProjection-10277, recordIdFieldProjection, projectionFls-6898, gridNonAuthorKeys, RelatedList.defaultFromRowSelect-10277, RelatedList.selectFls-10186) at 756592af8: Test Files 170 passed (170), Tests 3496 passed (3496).
  • packages/plugin-grid/ whole package at b77a55bc1: Test Files 151 passed (151), Tests 1453 passed (1453). The only later commit changes types in the plugin-detail pin.
  • plugin-list: the 13 suites that read $select / listViewPredicates / rowActionDefs, at b77a55bc1: 13 files, 270 tests passed.
  • plugin-detail: the 33 RelatedList* suites at b77a55bc1: 33 files, 176 tests passed; the changed pin re-run at 756592af8: 2 passed.
  • Type-check (tsc --noEmit && tsc -p tsconfig.test.json) of @object-ui/core, @object-ui/plugin-grid, @object-ui/plugin-detail: green; --listFiles shows each test program compiles the new or edited test file.
  • Exit 0 at 756592af8: check-changeset-presence (1 changeset declared), check-changeset-no-major, check-changeset-fixed, check-changeset-claims (no pending changeset names a touched file; self-contradiction reading clean), check-pending-changeset-literals, check:new-line-citations (0 new citation(s)), check:control-bytes, check-vi-mock-specifiers, check-vi-mock-inherit, check-vi-mock-override-shape, check-test-path-roots, and check-changeset-overwrite with the report-only finding above.
  • Lint, narrowed and declared as such: eslint --no-inline-config --format json over the 5 touched .ts / .tsx files at 756592af8: 5 files, 0 errors, 0 warnings. Population: the root eslint.config.js block files: ['**/*.{ts,tsx}'], and all 5 appear in the JSON output (none ignored). Invariance: the config sets no parserOptions.project / projectService, so there is no type-aware linting, and no rule under eslint-rules/ reads another file; this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.
  • NOT MEASURED locally: the rest of plugin-list and plugin-detail, and every other package; CI runs them.

Acceptance notes

  • Two mirrors are unenforced. TARGET_ROW_TOKEN copies the console api handler's pattern and defaultFromRowKey copies rowValueKey's precedence. Both cite their source by symbol; nothing re-derives the parity, which is said here rather than left implied (AGENTS.md 完善设计器的每一个细节 #9). Hoisting the pattern and the row-key reader into core, where app-shell and the harvest could share one spelling, would close it.
  • Out-of-scope finding, handed to the seat in the report on objectui#10277 and left alone here: an undoable operation: 'update' row action records null as the prior value of a written field the projection left out, so Undo would clear that field. Measured with a temporary probe; the fix has two readings (harvest the written keys, or refuse Undo when the row lacks one), so it needs its own card.

Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C (dispatched by the domain:ui#4 seat).


Generated by Claude Code

…d} target tokens into $select

`listViewPredicates` decides which fields a projected row must carry for
ListView, ObjectGrid and RelatedList's authored path. It read an action's
visible / disabled predicates and its recordIdField, but not the row key a
`defaultFromRow` param seeds from (`field ?? name`, the precedence
resolveActionParam reads) or the `{field}` tokens the console's api handler
fills from the row. On a projected row those keys were absent: the param
dialog opened blank and the URL got an empty segment, without a word.

Both are now harvested, bare identifiers only, and still pass each
consumer's declared-field and FLS gates.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
… positive control

An assertion that only reads an absence passes on a harvest that never runs.
Each negative case now carries a sibling that must be harvested, so every
new pin can fail against the tree before the fix.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…romRow caveat is narrowed in the same release

The pending plugin-detail changeset warns that a `defaultFromRow` param or a
`{field}` target token only gets its field when the projection already carries
it. The harvest now names both, so the paragraph would publish a gap the same
release closes. Appended a supersession note rather than rewriting another
seat's text; the declared package set is unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…olumn-identity ratchet

`defaultFromRowKey` spells `field ?? name`, which the #3104 dual-read scanner
counts. It is the row-key half of resolveActionParams' two-layer pair, mirrored
so the projection asks for the key the runtime reads; `columnIdentity()` would
also read `fieldName` and drop an empty string, so it is not the same key.
Listed with that verdict, total 11 -> 12.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3042.8 KB 3104.5 KB
Main entry chunk (gzip) 148.3 KB 350 KB
Entry file index-BmhM38mh.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.68KB 6.20KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 542.11KB 129.60KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 224.12KB 62.26KB
fields (index.js) 254.86KB 64.50KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 34.99KB 11.45KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.44KB 14.62KB
plugin-charts (index.js) 72.58KB 20.29KB
plugin-chatbot (index.js) 198.27KB 47.18KB
plugin-dashboard (index.js) 133.19KB 35.25KB
plugin-designer (index.js) 216.12KB 44.37KB
plugin-detail (index.js) 260.94KB 67.93KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 146.34KB 37.47KB
plugin-gantt (index.js) 168.31KB 41.45KB
plugin-grid (index.js) 215.22KB 58.85KB
plugin-kanban (index.js) 49.30KB 15.39KB
plugin-list (index.js) 114.17KB 28.16KB
plugin-map (index.js) 21.74KB 7.07KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.72KB 8.96KB
plugin-tree (index.js) 10.74KB 3.76KB
plugin-view (index.js) 85.79KB 21.34KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 114.58KB 37.60KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 756592af8b107cae8e8ae0b6597d7355e0e52e3e

Rendered by an isolated review subagent spawned by the domain:ui#4 seat; its served tier was checked against its transcript stamps (112 of 112 model turns at the review tier; one further entry is a synthetic error stub written when the subagent's session hit a usage limit after it had delivered this report). Adopted by this seat.

① Derived judgments

  • (a) FLS — no bypass on any of the three consumers; the harvest adds CANDIDATES only, and every candidate meets the same gate a predicate operand meets. Read at the head sha, no consumer source changed (the 7-file diff has no consumer file):
    • ObjectGrid.tsx: collectPredicateFieldRefs(listViewPredicates({ rowActionDefs, bulkActionDefs, objectActions, userActions })) then a .filter on isProjectableField(f, declared) then a .filter on passesProjectionGate(f); passesProjectionGate returns perms.checkField(objectName, fieldName, 'read') for a declared field once perms.isLoaded. isProjectableField (core) is "declared, or one of PLATFORM_RECORD_COLUMNS". Harvested names take exactly this path because they come out of the same collectPredicateFieldRefs call.
    • ListView.tsx (untouched): for (const f of collectPredicateFieldRefs(listViewPredicates({...}))) addPredicateField(f); addPredicateField adds a platform column directly, otherwise addSpeculative(f), which drops an unknown field (knownObjectFields) and then, when perms.isLoaded and the field is known and not a platform column, drops it on !perms.checkField(schema.objectName, f, 'read').
    • RelatedList.tsx: operands from collectPredicateFieldRefs(listViewPredicates({ rowActionDefs: rowActions, objectActions, userActions })); each must be declared (parentRelationshipFieldDef(fields, field) !== undefined, either container shape) or a platform column, and if (declared && !readable(field)) continue; where readable is perms.checkField(relatedObjectName, field, 'read') once loaded.
    • The one path where a denied field can ride a request is the pre-existing !perms.isLoaded deferral shared by columns and predicate operands on all three surfaces (documented in the 10186 changeset as "the same deferral ObjectGrid has"); the harvest neither introduces nor widens it, and in RelatedList the harvest only runs after the child schema has landed. The plugin-detail pin asserts over EVERY row fetch that the denied user_id (named by a param AND a token) is absent, with the last $select exactly ['id', 'name', 'team_id'], using the real PermissionProvider with fieldPermissions: [{ field: 'user_id', read: false }]; its control lifts the denial and gets ['id', 'name', 'team_id', 'user_id']. Triage rule 3 and the claim's FLS carry-over are met. No FAIL here.
  • (b) Mirrors are exact. resolveActionParams.ts: function rowValueKey(param) { return param.field ?? param.name; } and the seed param.defaultFromRow && ctx.row && rowKey != null && Object.prototype.hasOwnProperty.call(ctx.row, rowKey) ? ctx.row[rowKey] : undefined. Harvest: if (!p.defaultFromRow) return undefined; return p.field ?? p.name; — same truthiness test on defaultFromRow, same field ?? name precedence, then the existing BARE_IDENTIFIER gate (/^[A-Za-z_][A-Za-z0-9_]*$/), the very gate recordIdField takes two lines above. Console handler (useConsoleActionRuntime.tsx): resolvedTarget.replace(/\{([a-z_][a-z0-9_]*)\}/gi, ...) with a callback reading const v = rowRecord[k]; return v == null ? '' : encodeURIComponent(String(v));; RecordDetailView.tsx carries the identical pattern. Harvest: const TARGET_ROW_TOKEN = /\{([a-z_][a-z0-9_]*)\}/gi; — byte-identical, lastIndex reset before each loop, so the harvest reads exactly the tokens the handler fills (case-insensitive match, key used verbatim, on both sides). The runner's own scopes are /\$\{(param|ctx)\.([\w.]+)\}/ in ActionRunner.interpolateTarget and the metadata-admin copy: dotted by construction, so they fall outside the {ident} grammar on both the handler and the harvest. Dotted paths, expressions, ${param.X} / ${ctx.X} and {1st} are pinned as skipped in the core test ("skips a target token that is not a bare identifier"), and a non-identifier param key is pinned as dropped with a same-fixture control. Spec (packages/spec/src/ui/action.zod.ts, read from the objectstack source since objectui's node_modules is not installed locally; objectui pins ^17.3.0): name — "Request-body key. Defaults to field when field is set."; field is SnakeCaseIdentifierSchema.optional(); defaultFromRow — "key = the resolved field name"; carryOver is refused without defaultFromRow === true (a .refine whose predicate is !p.carryOver || p.defaultFromRow === true), so the PR's A1 statement that carryOver needs no arm of its own holds. The named producer sys-team-member.object.ts remove_team_member has params { name: 'teamId', field: 'team_id', required: true, defaultFromRow: true } / { name: 'userId', field: 'user_id', ... }, exactly the fixture shape used in all three pin files.
  • (c) Ratchet re-baseline is legitimate under the file's own rule. The ratchet header says: "This file freezes that state: a new dual read in a new file fails, and an extra one in a listed file fails." and, on how the inventory may grow: "If this fails because you added a read: don't. Call columnIdentity() from @object-ui/core — it resolves canonical-first, so it agrees with the data request instead of racing it. If you genuinely need a new one, add it here WITH a verdict and say why in the PR." The PR did exactly that: a new INVENTORY entry for core/src/utils/predicate-fields.ts with count: 1, verdict: 'two-layer' and a why, the section header (5) → (6), the total assertion 11 → 12 with its comment extended ("12 again since objectui#10277 mirrored that file's row-key reader into the $select harvest"), and the reasoning stated in the PR body and the commit b77a55bc1. The scanner (ACCESS on .field / .name in a line with ??) hits exactly one line at head, return p.field ?? p.name; (I grepped every || / ?? line in the file; the docblock's backticked field ?? name has no property access, so it does not match). Two-layer is the correct verdict: field is the object field key, name is the param's payload name, and the read picks the ROW key — the identical pair the inventory already records as two-layer for app-shell/src/utils/resolveActionParams.ts (rowValueKey). Not using columnIdentity() is justified from source: LEGACY_COLUMN_IDENTITY_KEYS = ['name', 'fieldName'] and asIdentity returns undefined for '', so it would harvest fieldName and drop an empty-string key, neither of which the runtime does. The family-at-zero assertion (verdict === 'column-identity' sums to 0) is unchanged. This is recording a genuine read, not raising a ratchet to fit code. No FAIL.
  • (d) The 10186 changeset note. The edit is append-only (8 +, 0 -; the base text is verbatim intact). Appending a supersession note is the repo's established practice: objectui#9542 (43c0d1710) appended "⇒ It did not outlive it, and the paragraph above is superseded in this same release. ... (Noted here by the objectui#9542 seat, because this body publishes verbatim into the CHANGELOG ...)" to .changeset/8648-ui-action-four-undeclared-keys.md; .changeset/7064-empty-section-default.md and .changeset/6469-gantt-block-precedence.md carry the same kind of note. scripts/check-changeset-overwrite.mjs names the case explicitly and grades it: "2. You are CORRECTING a declaration on purpose — a bump level after review, a wrong package name, prose that no longer matches the change. Legitimate, and the reason this gate reports instead of failing." Read together, both are true: the untouched paragraph's general clause (a row action reading a field "through something other than a visible / disabled predicate or recordIdField" gets it only if the projection already carries it) still holds for reads the harvest does not cover (e.g. the patch keys the dev's out-of-scope Undo finding names), and its two examples are corrected immediately below by "⇒ Narrowed in this same release by objectui#10277: the harvest now also names the row key a defaultFromRow parameter seeds from and every bare-identifier {field} token in an action's target, through the same declared-field and FLS gates, so on all three surfaces those two no longer rely on the projection already carrying the field." — verified against source: all three consumers pass their row/object action lists to listViewPredicates, and the FLS qualification is stated. The declared package set ('@object-ui/plugin-detail': patch) is unchanged. The edit is OUTSIDE the claimed surface; I judge it acceptable as a correction the change itself forced (a pending changeset publishes verbatim into the CHANGELOG in the same release), it was declared in the PR body, the report and the claim-level rules were not otherwise breached, and it sits in its own commit d877573da so the seat can drop it alone. Declared: out-of-surface, accepted.
  • (e) .changeset/10277-harvest-defaultfromrow-target-tokens.md — every sentence checked true against source. Frontmatter '@object-ui/core': patch (only core source changed). "harvest ListView, ObjectGrid and RelatedList's authored path use" — the three call sites above. "read an action's visible / disabled predicates and its recordIdField" — base listViewPredicates. "a param with defaultFromRow: true ... opened the console's param dialog blank, because the param is seeded only when the row has the key" — the hasOwnProperty seed. "filled with an empty string by the console's api handler" — v == null ? '' : .... "For each action on the view's row, bulk and object action lists" — the [rowActionDefs, bulkActionDefs, objectActions] loop. "its field when it declares one, its name otherwise" — p.field ?? p.name. "every {field} token in target whose content is a bare identifier, the grammar the console's api handler fills from the row" — identical regex. "A dotted path, an expression and the runner's ${param.X} / ${ctx.X} tokens are not filled from the row that way, and are not harvested" — true of the grammar and pinned. "A param key that is not a bare identifier is dropped, as a recordIdField is" — same BARE_IDENTIFIER gate. "the check against the object's declared fields and the platform columns every record carries, and field-level security on a declared field once the permission answer has loaded" — matches all three gates in (a).
  • (f) Pins are real and able to fail; the ablation arithmetic is consistent. Core: it( count 19 at base → 27 at head, so +8 in the new describe, each asserting a harvested name is PRESENT (toEqual([...]) with at least one name), so each is red on a harvest that does not run; the four negative cases carry a same-fixture positive control (seeded, ok_field, ok, valid_seed). Grid: 5 cases; PIN 1–3 assert $select contains team_id / user_id (and not teamId); PIN 4 clicks the real row-action-trigger and row-action-remove_team_member (both data-testids exist in RowActionMenu.tsx), on a data source that returns only the $select keys, and asserts hasOwnProperty on the _rowRecord handed to onParamCollection (ObjectGrid attaches dispatch.params = { _rowRecord: r }); PIN 5 asserts toEqual(['id', 'name', 'user_id']), red on base (['id', 'name']). Detail: 2 cases, both wait for team_id / user_id to appear in a $select, so both time out red on base. 8 + 5 + 2 = 15 new, 19 pre-existing, total 34 — matches the declared "15 failed | 19 passed (34)" and "34 passed (34)". Blob hashes match the diff index line (a47abe5d2 base, dab57595a head). I did not re-run any suite (forbidden); the check-runs below are CI's confirmation.

② Semver level

Appropriate: patch on @object-ui/core, the only package whose published source changed. objectui AGENTS.md (版本号策略) rules: "minor/patch 独立演进——objectstack 没动时不必跟发;objectui 自己的改动照常用 changeset 推进" and "推论:changeset 里不要声明 major —— fixed 组任一 major 都会把全组推上去、脱离 objectstack 的节奏 ... objectui 自身的破坏性变更也标 minor(在正文里写清 breaking 语义即可)". This is a bug fix widening a $select harvest; no declared key, schema or export moves (claim: Clause-②: no), so patch, not minor, and never major. check-changeset-no-major and check-changeset-fixed are reported exit 0 by the dev and the changeset-guard checks are among the 40 successes. The plugin-grid / plugin-detail edits are test files only, and the presence rule asks for one declaration per change ("要的是'声明一次'"), which the 10277 changeset provides. The appended note on the 10186 changeset leaves its '@object-ui/plugin-detail': patch unchanged.

③ Boundary flags

Implemented-by: claude/issue-10277-harvest-defaultfromrow-target-tokens
Reviewed-by: session_01BP8CMtACxTdLjqR6rhd33C

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 24, 2026 22:55
@os-litant
os-litant added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 961ceaa Sep 24, 2026
45 checks passed
@os-litant
os-litant deleted the claude/issue-10277-harvest-defaultfromrow-target-tokens branch September 24, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants