Skip to content

feat(plugin-detail): record:related_list reads its declared actions; host-bridge actions stay the default (objectui#11163) - #11263

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11163-related-list-actions
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11163-related-list-actions

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11163

Clause-②: yes

What this does

record:related_list now reads its declared actions key and renders the actions it names. The key is RecordRelatedListProps.actions, described as "Action IDs available for related records". This carries out the maintainer's ENFORCE ruling on objectstack-ai/objectstack#20665 (batch #247, 「业务上需要支持,ENFORCE」). Until now the key was declared, published and parsed, but no renderer read it.

The composition rule (stated here, pinned in the new test file)

actions What the related list renders
absent The host bridge's actions, unchanged: the child object's list_toolbar actions as header buttons and its list_item actions in each row's menu, in registration order. No metadata lookup is made for the key.
authored ids The authored list, in authored order. It replaces the bridge default rather than adding to it. Each id resolves against the related object's registered actions and is placed by that action's own locations: list_toolbar goes to the header, list_item goes to the row menu. The host still runs it.
[] No actions. This is the author's choice.
an id that cannot be placed Named in a role="status" notice on the list. The entries that do resolve still render.

The built-in New / Edit / Delete / View affordances are not action ids, because the runtime ships no built-in action names. They stay governed by the child object's userActions and the user's permissions, whatever actions says. actions: [] keeps New, and the pin asserts it.

Measurements taken before building

These cover the card's caveat and the dispatch's mechanism assumptions.

  1. Nothing read the key. The premise holds. At ae0b9d3905 the renderer never touched schema.actions, and the parity ledger exempted record:related_list.actions as NO_READ_SITE_TO_PIN.
    • Red first: the new pin file, run against the unmodified renderer, reported Tests 10 failed | 2 passed (12).
    • A typical failure: expected [ 'invite', 'export_csv' ] to deeply equal [ 'export_csv', 'invite' ]. The bridge default was drawn and the authored order was ignored.
  2. The registry. An authored id resolves through useMetadataItem('object', childObject) plus resolveDeclaredActionIds from @object-ui/types.
    • This is the same lookup that record:quick_actions.actionNames, page:header.actions and record:alert resolve through, over the same MetadataProvider the bridge's objects come from. There is no second registry.
    • Only the ids arm is accepted. The inline-object arm is page:header's transition tolerance, and record:alert made the same choice for its CTA.
  3. The host bridge has no per-list configuration. RelatedRecordActionsValue.resolve takes { objectName, relationshipField, parentId }. RelatedRecordActionsBridge.deriveActions keys on the child object's list_item / list_toolbar locations, so two lists of one child object get one set. actions is therefore the per-list channel, not a duplicate of the bridge's. The bridge is not modified.
  4. Registration inputs. record:related_list already declares { name: 'actions', type: 'array', of: 'string' }, so packages/plugin-detail/src/index.tsx is untouched.
  5. objectui#11244 (RelatedToolbarButton's visible truthiness gate). Authored toolbar actions do pass through that button. No pin fixture declares visible, so the defect does not affect any pin here. It stays with that card.

Where the refusal is, and why there

The refusal is drawn in the block, where the lookup answers. It is an inline role="status" notice above the list, naming each refused entry and the reason. There are three reasons:

  • the id names no action of the related object;
  • the id's action declares neither list_toolbar nor list_item. Naming an action does not bypass location filtering: the platform's action docs make a list view's selection bar the only surface where naming an action places it;
  • the array is not all action ids.

Three properties keep the notice truthful:

  • nothing is refused while the lookup is still in flight;
  • the verdict comes from the registry, not from the host, so it also shows when there is no host;
  • the entries that resolve still render.

This is the smallest loud refusal the measurement supports. An authoring-time refusal would belong to objectstack's lint (see Acceptance notes).

Running an authored action, and the no-host case

An authored action is handed to the bridge's own executor, onToolbarAction or onRowAction. That executor already runs the child object's actions against the clicked row through the page's ActionRunner. An authored surface is passed down only together with that executor, so no dead button is ever drawn. With no host (the Studio designer, a standalone embed) the list stays read-only, as it always has for every affordance. A bad id is still refused there.

Parity ledger

record:related_list.actions moves from MEMBER_PIN_EXEMPTIONS to MEMBER_PINS, pointing at the new pin file. NO_READ_SITE_TO_PIN is deleted together with its one entry. A note is left where it stood, the way AWAITING_A_PIN was retired.

⚠️ MEMBER_PIN_EXEMPTION_CEILING goes 5 → 4, not 1 → 0 as the card was written. The objectui#11111 decision 3 = B bookings (four element:* units owed to objectui#11168) landed between the card and this change. Those entries are untouched and move only with that card. The ceiling's docblock records this.

Files

  • packages/plugin-detail/src/renderers/record-related-list.tsx: reads schema.actions, looks it up, composes it with the host, and draws the notice.
  • packages/plugin-detail/src/renderers/relatedListActions.ts (new): the pure resolve-and-place rule and the notice text. It is a separate module so the renderer file keeps exporting components only.
  • packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.authoredActions-11163.test.tsx (new): the pins, 12 cases, through the real SchemaRenderer, this block's registration, the real RelatedList and the real data table.
  • apps/console/src/__tests__/registry-inputs-spec-parity.test.ts: the exemption becomes a member pin, and the ceiling goes 5 → 4.
  • content/docs/guide/slotted-pages.md: a section on naming a related list's actions. This is outside the claim's file list and is added under AGENTS.md commandment Add automated testing infrastructure and CI/CD workflows #2 (docs-driven). No package README is touched, because PR objectui#11246 owns plugin-detail's.
  • .changeset/11163-related-list-actions.md: @object-ui/plugin-detail minor. A page that already authors actions on a related list now shows exactly those actions.

Tests

The runs below are at head 95a8cd520e, which is this branch after merging main at af9e9572c7 (PR objectui#11246 landed while this was in flight). Heavy runs went through the shared verification lock. Each result below is the gate's own verdict line.

  • Build: pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build gave VERDICT command-exit 0.
  • Type-check: pnpm --filter @object-ui/plugin-detail type-check (its output echoes the script name type-check for @object-ui/plugin-detail@17.6.0) gave VERDICT command-exit 0. tsc -p tsconfig.test.json --listFiles lists the new test file, the new module and the renderer.
  • pnpm exec vitest run packages/plugin-detail/src/__tests__/: Test Files 144 passed | 1 skipped (145), Tests 1296 passed | 8 skipped (1304).
  • pnpm exec vitest run over the other 83 packages/plugin-detail test files: Test Files 83 passed (83), Tests 962 passed (962).
  • Every suite outside plugin-detail that git grep -l 'record:related_list' finds, 25 files including registry-inputs-spec-parity.test.ts: Test Files 25 passed (25), Tests 1020 passed (1020).
  • Ablation (committed fix, then ablation-replace.mjs): removing the in-flight guard in record-related-list.tsx made exactly CONTROL: no refusal is drawn while the lookup is still in flight fail (Tests 1 failed | 11 passed (12)). The file was restored to blob 35704b30774d, equal to HEAD, and git diff HEAD was empty.
  • Gates, each exit 0:
    • check:action-forward-parity
    • check:component-surface-parity (report-only, no record:related_list row)
    • check:handler-key-reads
    • check:new-line-citations (0 new citation(s))
    • check:control-bytes
    • check:doc-fences
    • docs:check-links
    • node scripts/check-changeset-presence.mjs
    • node scripts/markdown-test-inputs.mjs --audit
    • check:doc-types
    • check:doc-example-ids
    • check:docs-route-closure
    • check:doc-example-readers
    • check:changeset-claims
    • check:pending-changeset-literals
    • check:test-path-roots
    • check:unreferenced-sources
    • check:spec-symbols
    • check:phantom-deps
    • check:element-data-source-declaration
    • check:action-ref-convention
    • check:self-import
  • Lint, narrowed to the four changed TypeScript files (eslint --no-inline-config): 0 errors. The 17 warnings all sit on lines this diff does not add.
  • NOT MEASURED, with reasons:
    • check:doc-snippets and check:doc-examples exited 2 (PRECONDITION NOT MET, 34 packages unbuilt). They read TypeScript fences and @example blocks, and this diff adds a json fence.
    • check:sdui-registration-pins exited 2 (no console build). No registration changes here.
    • The apps/console type-check and repo-wide pnpm lint are left to CI.

Acceptance notes

Observations, not filed:

  • objectstack's action-name-undefined lint (objectstack#20105) deliberately does not walk record:related_list.actions; its header says "actions is declared separately on record:related_list". Now that the key renders, a misspelled id is refused only at runtime, by the notice. Extending that walk would add the authoring-time refusal. Carrier: the seat that settles spec(ui): record:related_list.actions is declared ("Action IDs available for related records") and read by no renderer — ENFORCE or RETIRE under ruling A′ ④ (the remainder of objectui#8071) objectstack#20665 and flips its liveness row.
  • The record_related location. The spec's ACTION_LOCATIONS describes it as "actions on a related list section inside a record", and the platform's action docs list it as "Related-list sections". This list, like the bridge before it, places actions by list_item / list_toolbar. An action that declares only record_related and is named in actions is refused by a notice that names the two list locations. This was not measured through a public door. Carrier: none.
  • The bridge supplies onRowAction / onToolbarAction only when its own derived list for that surface is non-empty. Authored placement uses the same location rule over the same registry, so a placeable authored action implies a non-empty derived list whenever the bridge's objects and the MetadataProvider agree. If they ever disagree, the authored surface is withheld rather than drawn dead. Carrier: none.
  • The registration's actions description is still the spec's describe text verbatim and does not state the composition rule. The docs guide now does. Carrier: none.

The session that produced this change is https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ.


Generated by Claude Code

…, host-bridge actions stay the default (objectui#11163)

The maintainer ruled ENFORCE on objectstack-ai/objectstack#20665:
`record:related_list.actions` ("Action IDs available for related records")
was declared, published and parsed, and no renderer read it.

The renderer now reads it. Absent, the host bridge's actions render
exactly as before and no lookup is made. Authored, the authored list is
what renders, in authored order: each id resolves against the related
object's registered actions (useMetadataItem + resolveDeclaredActionIds,
the lookup record:quick_actions.actionNames uses) and is placed by its own
locations (list_toolbar -> header button, list_item -> row menu), run by
the host's executor. `[]` draws no actions. An id that does not resolve,
or whose action declares no list location, is named in a role="status"
notice on the list instead of being dropped.

The parity ledger's NO_READ_SITE_TO_PIN exemption becomes a member pin;
MEMBER_PIN_EXEMPTION_CEILING 5 -> 4 (the four remaining units are the
objectui#11111 bookings owed to objectui#11168).

Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ
Co-authored-by: Claude <noreply@anthropic.com>
…(objectui#11163)

The slotted-pages guide gains a section on naming a related list's
actions: the host default, the authored list replacing it in authored
order, placement by each action's own locations, `[]`, the built-in
affordances the key does not govern, and the refusal notice.

Changeset: @object-ui/plugin-detail minor, since a related list that
already authors `actions` now shows exactly those actions.

Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation plugin apps tests labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 4 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/7298-discussion-explicit-composition.md

  • names content/docs/guide/slotted-pages.md → content/docs/guide/slotted-pages.md — edited by this change

    Docs: content/docs/guide/slotted-pages.md.

.changeset/8067-component-input-member-kind.md

  • names apps/console/src/__tests__/registry-inputs-spec-parity.test.ts → apps/console/src/__tests__/registry-inputs-spec-parity.test.ts — edited by this change

    A registration's type: 'array' said a value was a list and stopped there, so a member that drifted from @objectstack/spec was invisible to every layer that reads a declaration. page:header.actions is the measured cost: the contract declares z.array(z.string()) ("Action IDs"), the renderer read the members as ActionDef objects, and the repo-wide parity gate in apps/console/src/__tests__/registry-inputs-spec-parity.test.ts stayed green for the whole life of the drift because both sides carried the key and neither could say what was inside it. What settled it was a maintainer ruling, not a test — and even after the fix, "these are ids" survived only as English in the registration's description.

.changeset/8649-detail-renderer-undeclared-keys.md

  • names record-related-list.tsx → packages/plugin-detail/src/renderers/record-related-list.tsx — edited by this change

    @object-ui/plugin-detail — the annotation-erasing destructure default is gone from three renderers. record-details.tsx, record-highlights.tsx and record-related-list.tsx each annotated schema correctly and then wrote schema = {} as any. A destructuring default's type joins the annotated property type at the binding, so any erased the annotation for every read site in the file — declared keys and undeclared ones alike read any. No published surface moves: the exported annotations were always correct.

.changeset/9964-related-list-picker-filter-mirror.md

  • names record-related-list.tsx → packages/plugin-detail/src/renderers/record-related-list.tsx — edited by this change

    So one key carried two declarations, with the looser one on the face an author — or an AI writing metadata — reads: unknown offers no shape guidance for a key whose consumer demands a specific shape. Nothing could report the divergence either, because record-related-list.tsx reached the block through four (schema as any).add reads; a cast unwraps the declaration at its own read site, so no compiler ever compared the two.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 54a78308a (merge-base with origin/main): 5 file(s) changed outside .changeset/, read against 1837 pending declaration(s) that publish a body (2449 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3583.3 KB 3607.4 KB
Main entry chunk (gzip) 149.8 KB 350 KB
Entry file index-CfXT7qq_.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.88KB 6.25KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
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.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
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) 27.13KB 7.95KB
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) 570.05KB 136.37KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 228.91KB 63.54KB
fields (index.js) 261.24KB 66.29KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
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) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.35KB 9.18KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 40.88KB 11.46KB
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.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
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) 52.17KB 15.06KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 138.73KB 37.05KB
plugin-designer (index.js) 216.32KB 44.56KB
plugin-detail (index.js) 244.74KB 64.46KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 173.69KB 44.62KB
plugin-gantt (index.js) 173.03KB 43.07KB
plugin-grid (index.js) 231.37KB 63.54KB
plugin-kanban (index.js) 48.46KB 15.12KB
plugin-list (index.js) 116.42KB 28.89KB
plugin-map (index.js) 23.50KB 7.82KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 33.05KB 9.67KB
plugin-tree (index.js) 11.20KB 3.89KB
plugin-view (index.js) 90.32KB 22.76KB
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.81KB 3.58KB
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) 119.55KB 39.23KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.06KB 2.68KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 21.42KB 7.05KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 3.19KB 1.62KB
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.26KB
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) 2.52KB 1.31KB
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) 19.82KB 7.15KB
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: 95a8cd520edaeceaeef5e39007610379312ba69c
Local-runs: none

Inputs: card objectui#11163 (body, claim 5917731712, dev report 5919004096), the ruling record on objectstack#20665 (5902374623, batch #247, ENFORCE), PR objectui#11263 (body, 6-file list, net diff at merge base af9e9572c7), the check-runs on the head, and the spec source at objectstack 0f6dcac5e9 (packages/spec/src/ui/component.zod.ts, actions: z.array(z.string()).optional(), "Action IDs available for related records").

① Derived judgments

Accept set — unchanged, right. The spec key is untouched (the ruling keeps it declared). The registration's inputs already carried { name: 'actions', type: 'array', of: 'string', description: 'Action IDs available for related records' } (packages/plugin-detail/src/index.tsx:648) and RecordRelatedListComponentProps.actions?: string[] (packages/types/src/record-components.ts:473); neither file is in the diff. Nothing an author could write before is refused at parse now.

Public surface — record:related_list honours a published key it ignored, right, and the composition rule is stated and pinned.

  • Absent: relatedListActionsNeedLookup(undefined) is false, so useMetadataItem('object', null) is the documented no-op (AppShellContext.tsx:116-139, no getItem call); placedActions is null, and toolbarActions / rowActions fall through to handlers.toolbarActions / handlers.rowActions — the bridge default byte-for-byte. Pinned by the CONTROL case: registration order ['invite','export_csv'] / ['send_welcome','deactivate'], no notice, getItem never called with ('object','contact').
  • Authored: useMetadataItem('object', objectName) + resolveDeclaredActionIds(authored, relatedObjectMeta.actions) — the same pair record:quick_actions (record-quick-actions.tsx:187-192) and record:alert (record-alert.tsx:266-289) resolve through, over the same MetadataProvider; no second registry. Placement is actionRendersAt(action, 'list_toolbar' | 'list_item'), the one predicate the bridge's own deriveActions applies (RelatedRecordActionsBridge.tsx:187-200). The authored list REPLACES the default (a subset drops the unnamed toolbar action — pinned), renders in authored order on both surfaces from one list (pinned against a registry whose order differs from every authored case), and runs through the bridge's executor with the resolved, localized def (pinned: onToolbarAction receives { name: 'invite', label: 'Invite', type: 'api' }).
  • []: classifyDeclaredActions([]) is the empty objects arm (ui-action.ts:210), so no lookup, nothing placed, nothing refused; built-in New stays because it is not an action id. Pinned (related-list-new present, no toolbar, no row menu, no notice).
  • Only the ids arm is accepted; the inline-object arm (page:header's transition tolerance) is refused whole. Right for this key's z.array(z.string()) contract.

Loud refusal — author-visible, never a silently dropped button. Right. describeRelatedListActionRefusals names each unresolved id ("is not an action of X", or "could not be resolved: no metadata for X was found" when the object itself never arrived), each unplaced id (naming both list_item and list_toolbar), and a non-array / inline-object / mixed array (kind: 'array'), in one role="status" aria-live="polite" notice beside the list. The in-flight guard (needsActionLookup && relatedObjectMetaLoading → nothing drawn, nothing refused) is sound because useMetadataItem starts at loading: !!name, so the first paint is guarded; the resolvable entries still render because toolbar / row are populated alongside refused. Five pins: unresolved id (notice + role=status + ['invite'] still drawn), unplaced header_only, inline-object array refused whole, in-flight CONTROL, no-host CONTROL (the refusal is the registry's, not the host's). One branch is code-only: a bare non-array value hits the first guard in placeAuthoredRelatedListActions with no pin; the registration declares type: 'array', so this is defence in depth, not a gap.

Parity ledger — right, and the 5 → 4 account verified against main. At merge base af9e9572c7, MEMBER_PIN_EXEMPTIONS held the four owedEntries units (element:definition-list.items; element:repeater.fields, .filter, .sort, all owed to objectui#11168 under objectui#11111 decision 3 = B) plus 'record:related_list.actions': NO_READ_SITE_TO_PIN — five entries, ceiling 5. At head: the four owed entries remain, ceiling 4, the NO_READ_SITE_TO_PIN constant is deleted with its one entry (a comment stands where it was, the AWAITING_A_PIN pattern), and MEMBER_PINS['record:related_list.actions'] names the new pin file. The bound is toBeLessThanOrEqual(MEMBER_PIN_EXEMPTION_CEILING) plus "no exemption may also be a pin", both satisfied. History of the constant on main: 1 at fe41dc76a5 (slice 18, 2026-09-29T14:25Z) → 7 at 81f849852a (objectui#11073, the 17.5.0 bump, 2026-09-30T03:45Z) → 5 at 3cc4fe567b (objectui#11168 slice 1, 2026-09-30T12:52Z) → 4 here. The card (filed 2026-09-30T01:36Z) wrote "1 → 0" against the pre-bump ledger; the one rise since the objectui#8176 correction is objectui#11073's, a ruling on main before this branch, and this diff only lowers. The dev's deviation is exact.

Measured, not changed — confirmed. RelatedRecordActionsValue.resolve takes { objectName, relationshipField, parentId } and deriveActions(childDef, …, location) is keyed on the child object (RelatedRecordActionsBridge.tsx:334-447): no per-list channel exists, so actions is the per-list channel rather than a duplicate. The net diff at merge base is exactly the six files the PR lists; index.tsx, RelatedRecordActionsBridge.tsx and RelatedList.tsx are not among them.

Docs (content/docs/guide/slotted-pages.md:153-184) — match the diff. Default = child object's list_toolbar / list_item actions; authored = ids, replaces, authored order; placement by each action's own locations; [] = none while New / Edit / Delete / open-row follow userActions and permissions; unresolved and unplaced ids named in a notice. The json example is a valid node. The inline-object refusal is not described; the heading says the key "holds IDS", so that is an omission of a refusal branch, not a wrong statement. Sits beside the file's existing related-list paragraphs (limit, columns).

Check-runs on the head, at review time: 32 success (Type Check, Lint, Build & E2E, Test (dist pins), the five Changeset gates, every doc gate, Line Citation Gate, Governed Surface Queue Guard, Spec-free gates), 3 skipped (coverage ×2, dependabot), and 7 in_progress — not concluded: Spec Main Shape Gate, Test (shard 1/8), Test (shard 2/8), Test (shard 3/8), Test (shard 5/8), Test (shard 7/8), Test (shard 8/8). Shards 4/8 and 6/8 are success. No failure on the head.

② Semver level

.changeset/11163-related-list-actions.md: '@object-ui/plugin-detail': minor. Right. @object-ui/plugin-detail is the only published package whose source changes (apps/console is the private ledger app; content/docs is docs). Clause-②: yes is on the claim and at the head of the PR body, and it is the right word: a published block starts honouring a declared key, which widens what an author can effect through the public face; yes takes at least minor (objectstack AGENTS.md, the Clause-② rule), and no (narrowing) arm applies because the spec, the registration and the types are untouched and nothing previously accepted is refused at parse. The render-time change for pages that already author actions is the ruling's stated consequence ("today an authored actions silently does nothing; enforced, it renders") and the changeset carries it under ⚠️ in the text that ships to CHANGELOG.md. The changeset body's claims (same lookup as record:quick_actions.actionNames, placement by locations, [] = none, the three refusal reasons, no extra request when absent) each correspond to code in the diff. Changeset Bump Policy, Declaration, Claim Re-read, Fixed Group Check and Overwrite Report: success.

③ Boundary flags

Dev deviations, each answered:

  1. Ceiling 5 → 4, not 1 → 0 — verified above against fe41dc76a5 / 81f849852a / 3cc4fe567b; accepted.
  2. slotted-pages.md outside the claim's file list — docs-driven addition, accurate to the diff; the plugin-detail README left to PR objectui#11246's surface. Accepted.
  3. New module relatedListActions.ts beside the renderer — pure resolve-and-place, imported by the renderer only; keeps the renderer exporting components. Accepted.
  4. Merged main at af9e9572c7 into the branch — the net diff at that merge base is the six files; nothing else rides. Accepted.
  5. Commit trailer form — not a contract matter. Noted.
  6. Bridge, RelatedList.tsx, index.tsx untouched — confirmed in ①.

open_questions: none.

Out-of-scope findings, judged:

  • objectstack's action-name-undefined lint does not walk record:related_list.actions (packages/lint/src/validate-action-name-refs.ts:27-30, "actions is declared separately on record:related_list"). That exclusion was written when the key was dead; from this head the key is an action-id reference list exactly like record:quick_actions.actionNames, so a misspelled id is refused only at runtime. Public-door, objectstack-side, reach = every page authoring the key. Fileable: the seat should file it on objectstack (or bind it to the card that closes objectstack#20665 as a filed item, not a note). Not blocking here.
  • record_related location — a declared-not-honoured gap on the LOCATION, not on this key. record:related_list.actions carries no location semantics and this PR honours it. But the reach is measured and real: objectstack examples/app-showcase/src/ui/actions/index.ts:215 (showcase_log_time, locations: ['record_header', 'record_related', 'record_section']), docs/qa/platform-checklist/areas/records-forms.json:1523 ("record_related — showcase_log_time on the related-list section"), content/docs/ui/actions.mdx:276 ("Related-list sections") and content/docs/protocol/objectui/actions.mdx:269, packages/runtime/src/action-execution.ts:1163; in objectui there is no read of record_related in any source, test, example or doc. Naming showcase_log_time in a related list's actions is now refused by the notice with the two list locations named — this PR makes the gap author-visible for the first time. Fileable with that reach: an ADR-0049 enforce-or-remove question on record_related owned by the spec, with an objectui consumer (the related list placing it) if enforced. Not blocking this PR; the placement rule chosen here is the bridge's existing one.
  • Bridge supplies onRowAction / onToolbarAction only for a non-empty derived surface (RelatedRecordActionsBridge.tsx:431-447) — both channels derive from one object definition with one location predicate, so a placeable authored action implies a non-empty bridge surface whenever objects and the MetadataProvider agree; on disagreement the list is the read-only list it already was for that child. Acceptable; no carrier owed.
  • RelatedToolbarButton's visible gate (RelatedList.tsx:439-450) — objectui#11244's; authored toolbar actions flow through it, no pin fixture declares visible. Right to leave.

Also noted, not blocking: the registration's actions description still reads the spec text verbatim and does not state the composition rule (the guide does); the PR's own acceptance note says so.

Implemented-by: claude/issue-11163-related-list-actions
Reviewed-by: session_0122Knsowci76D2rBWReCzzZ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 20:36
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit f4ed238 Sep 30, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11163-related-list-actions branch September 30, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

apps documentation Improvements or additions to documentation plugin tests

Projects

None yet

2 participants