Skip to content

fix(components,plugin-detail,react): permission-shaped action gates fail closed while the permissions payload has not loaded (objectui#11212) - #11237

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11212-rider1-fail-soft-legs
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11212-rider1-fail-soft-legs

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11212
Clause-②: yes

Rider 1 of objectui#4421 on the legs that still answered SHOWN or ENABLED while the permissions payload had not loaded. The ruling, verbatim: "permission-shaped bindings are fail-closed while the permissions payload has not loaded — the opposite of the predicate default — or the first-paint leak reappears. Pin this."

What changed

Measured through the real MePermissionsProvider → ExpressionProvider → ActionProvider → real renderers, one verb (delete), three states. Reproduced red on origin/main (dded788ada) before any source edit.

leg before: not loaded / granted / denied after
action:group inline member visible SHOWN / shown / hidden hidden / shown / hidden
action:group dropdown member visible SHOWN / shown / hidden hidden / shown / hidden
action:group host visible SHOWN / shown / hidden hidden / shown / hidden
action:icon visible SHOWN / shown / hidden hidden / shown / hidden
related-list toolbar (RelatedToolbarButton) visible SHOWN / shown / hidden hidden / shown / hidden
record:quick_actions visible (ActionRunner bag) hidden / HIDDEN / hidden, faulting in all three hidden / shown / hidden
page:header disabled: !current_user.can(...) ENABLED / enabled / disabled DISABLED / enabled / disabled
  • action:group (@object-ui/components): both leaves read one same-file hook, useMemberVisible, which evaluates visible with throwOnError, as action:menu's useMenuActionVisible does. The group's own visible takes the same option. A predicate that faults hides the action and is reported once, naming it.
  • action:icon (@object-ui/components): visible gets throwOnError, as on action:button. The auto-trigger follows the same verdict, and its comment now names the fail-closed policy.
  • RelatedToolbarButton (@object-ui/plugin-detail): visible gets throwOnError. The header comment on permittedToolbarActions already called this CEL "fail-CLOSED"; before this change that comment was wrong, and now it is right.
  • page:header disabled (@object-ui/components, containers.tsx): evalHeaderPredicate takes the fault fallback per key. visible and hidden stay false. disabled is now true, so a disabled predicate that faults renders the button disabled.
  • useActionEngine (@object-ui/react): the hook binds the predicate scope's subject (usePredicateScope().current_user) as current_user on the runner bag that getActionsForLocation filters against. It is the same object ExpressionProvider publishes, not a copy, because the engine answers can only for a receiver identical to the bound current_user. It is bound on the standalone runner and on a shared provider runner, with or without per-render keys. The memo is keyed on the subject's serialisable fields and on its permissions map, not on the subject's identity (AGENTS.md [WIP] Enhance every detail of the designer #10). The runner's user / ctx.user / os.user stay the host's object, because that object carries the systemPermissions the runner's capability gate reads.

The policy is per key, not a can() special case. A visible that faults on an unbound root (nope.deep == 1) is hidden on every leg above, and a header disabled that faults is disabled. The pin checks both.

The disabled decision and its blast radius

Rider 1 says the gate is closed while the payload has not loaded. For a disabled gate, closed means DISABLED. Measured facts behind the decision:

  • The record header's disabled fail direction is not decided in evalRowPredicate (listConditional.ts). That function returns whatever fallback its caller passes. The header's evalHeaderPredicate passed false for all three keys, and on disabled that value means ENABLED. The fix is in the caller, and listConditional.ts is untouched.
  • Only page:header's resolveDisabled goes through that leg: the inline buttons and the ⋯ overflow items of authored header actions. Built-in sys_edit / sys_delete arrive with a boolean disabled that RecordDetailView computes itself, so this change does not touch them.
  • Blast radius: every authored page:header action whose disabled predicate faults, whatever the cause. That includes a misspelled field, a retired data.* or bare spelling, an unbound root, and can() before load. All of these now render disabled instead of enabled. This matches what the other surfaces already did. action:button, action:menu, action:group, action:icon and record:quick_actions evaluate disabled fail-soft to true, which on this key is DISABLED. ActionRunner.execute refuses a faulting disabled with "Action is disabled". So on the header, a faulting disabled rendered an enabled button that the runner then refused.
  • Not widened here: the row menu item's disabled (RowActionMenu, measured ENABLED / enabled / disabled for disabled: !current_user.can(...)), the data-table row action's disabled, and the built-in disabledWhen predicates (documented fail-soft since PR fix(plugin-detail): the record detail header honors userActions predicates (#4419) #4515). These are other packages or other keys, so they are reported as a fork in the dev report, not changed here.

Mechanism notes (PM assumptions measured)

  • The card's table still held on dded788ada after PR fix(components): action:menu and action:group take the host's disabled by name (objectui#11182) #11221: red first, 14 of 28 arms.
  • throwOnError hides and warns once per (label, predicate), as assumed. action:group got a small same-file hook instead of two copies of the option, which mirrors action:menu.
  • Dashboard header actions have no visible leg. DashboardRenderer registers defs that carry only name / type / target / label, and @objectstack/spec's dashboard header action is a strict object of label / actionUrl / actionType / icon. So current_user.can(...) cannot be authored there, and record:quick_actions is the only visible surface on the ActionRunner bag. The binding still reaches the dashboard's runner, where it gates nothing today.
  • ActionRunner.ts is untouched. The binding happens where React can read the predicate scope, in useActionEngine.

Reach

On the console's app routes the not-loaded state does not render, because MePermissionsProvider holds a loading screen. /forms/:name is a sibling route in App.tsx, outside that provider. A throwaway probe (not committed) rendered the real App at /forms/showcase_task.edit, with FormPage replaced by an action:icon gated on current_user.can('account', 'delete') and an ungated companion. It read isLoaded=false subjectCarriesPermissions=false gatedIcon=hidden companion=shown at this branch's head. With the action:icon fix ablated, the same probe read gatedIcon=SHOWN.

Tests

  • New pin packages/app-shell/src/providers/__tests__/currentUserCan-failClosed-11212.render.test.tsx: 7 legs × 3 states, plus 7 per-key unbound-root arms, 28 tests. Every arm has an ungated companion and its own action name, because the fault reports are warn-once per locator.
  • Pins that recorded the old fail-soft answers are flipped:
    • action-record-predicate-root.test.tsx: the action:icon and action:group visible fault and retired-spelling cases, which now read hidden.
    • page-header-predicate-dialect.test.tsx: a faulting disabled now reads DISABLED, reported once.
    • action-template-predicate-gate.test.tsx: only the failClosed site flags and labels changed; the assertions did not.
    • related-toolbar-visible.test.tsx: new faulting-predicate case.
  • Reverse verification, at 8d44406f79 with the fix committed first. Mutations went through ablation-replace.mjs: throwOnError: true → false in action-group.tsx (×2), action-icon.tsx and RelatedList.tsx; the header disabled fallback true → false; useActionEngine binding nothing. Predicted before the run: 25 red. Result: Tests 25 failed | 123 passed (148). The 25 red were:
    • 14 in the new pin: the NOT LOADED and unbound arms of the six policy legs, plus record:quick_actions GRANTED, and DENIED through its silence assertion.
    • 9 in action-record-predicate-root.
    • 1 in page-header-predicate-dialect.
    • 1 in related-toolbar-visible.
    • Every file restored to its HEAD blob, and git diff HEAD was empty.
  • Head b8d647a3c4. The union ran after the last commit on a clean tree. Heavy runs went through the shared verify lock, and the verdicts quoted are the tools' own lines:
    • pnpm exec vitest run packages/components/ packages/react/: Test Files 447 passed | 1 skipped (448), Tests 4758 passed | 24 skipped, exit 0.
    • pnpm exec vitest run packages/plugin-detail/ packages/app-shell/src/providers/__tests__/ packages/core/src/actions/ packages/core/src/evaluator/ plus the objectui#4421 permissions pin, plus 98 consumer test files outside those trees: Test Files 384 passed | 1 skipped (385), Tests 6177 passed | 35 skipped, exit 0. The consumer files are every test outside components / react / plugin-detail that names page:header, action:group, action:icon, RelatedList, quick_actions or useActionEngine.
    • Build: turbo run build --filter='@object-ui/app-shell^...' --concurrency=2, Tasks: 28 successful, 28 total.
    • type-check for @object-ui/components, @object-ui/react, @object-ui/plugin-detail and @object-ui/app-shell exited 0 on all four. tsc -p tsconfig.test.json --listFilesOnly confirms the five edited or new test files are in those programs.
    • Gates:
      • node scripts/check-changeset-presence.mjs exit 0 (11 source files of 4 released packages, 1 changeset).
      • pnpm check:control-bytes exit 0.
      • pnpm check:action-forward-parity exit 0.
      • pnpm check:new-line-citations exit 0, 0 new citations.
      • pnpm check:doc-types exit 0.
      • pnpm check:changeset-claims exit 0 (report-only). It named 7 pending changesets that cite the touched files; I read each paragraph, and none describes a fault policy.
    • Lint: CI's per-package eslint . over the 11 touched files. Errors are 0. Warnings per file equal the base counts; the one exception is the new pin, which has 2.

Acceptance notes (not filed)


Generated by Claude Code

…ail closed while the permissions payload has not loaded (objectui#11212)

Rider 1 of objectui#4421 on the legs that failed soft:

- action:group (inline member, dropdown member, the group's own gate),
  action:icon and the related-list toolbar evaluate `visible` with
  `throwOnError`, as action:button / action:menu / action:bar already did:
  a predicate that faults hides the action and is reported once. Per key,
  not a can() special case.
- page:header resolves a faulting `disabled` to DISABLED (fallback true);
  `visible` and `hidden` keep their fallbacks.
- useActionEngine binds the predicate scope's subject as `current_user` on
  the runner bag it filters `visible` against, so record:quick_actions
  answers current_user.can(...) granted / denied / not loaded as shown /
  hidden / hidden instead of hidden in every state.

Three-state pin: app-shell currentUserCan-failClosed-11212.render.test.tsx.
The pins that recorded the old fail-soft answers are flipped.

Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ
Co-authored-by: Claude <noreply@anthropic.com>
…e added

`useMemberVisible` takes the record context as `Record<string, unknown>`,
and the shared-runner bind passes `bound` uncast (it is already a
`Partial<ActionContext>`). Lint warnings per touched file are back to the
base counts.

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

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 7 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/6306-action-icon-type-resolution.md

  • names action-icon.tsx → packages/components/src/renderers/action/action-icon.tsx — edited by this change

    Scope is this one renderer. type: schema appears in exactly two files under renderers/action/ — action-button.tsx (already correct) and action-icon.tsx; action:group and action:menu compose their members differently and are untouched.

.changeset/6771-retire-body-child-list-dialect.md

  • names renderers/layout/containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    Non-rendering readers keep their arm on the same rule, and none of them renders anything: while a renderer still reaches stored body content, a reader that must see the SAME content keeps its arm, or the renderer draws what the reader cannot find. Those are the two tab-subtree walkers in renderers/layout/containers.tsx, app-shell's pageSchemaIntrospect (CONTAINER_KEYS) and PageBlockInspector (STRUCTURAL_PROP_KEYS, the inspector half of a stored properties.body), and the CLI's OBJECTUI_STRUCTURAL_KEYS — a file-IDENTIFICATION marker, where keeping body is what lets an old file still be recognised as an ObjectUI node and therefore refused, instead of silently not judged.

.changeset/7182-declared-action-ids-one-rule.md

  • names containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    New on @object-ui/types, beside actionRendersAt: the pure resolveDeclaredActionIds(elements, registeredActions), with the DeclaredActionsResolution / DeclaredActionsRefusal result types (the shape classifier stays module-internal: called with no registry, the function already returns the registry-independent verdict a renderer needs before its lookup). Both renderers call it; the whole-array switch in record-quick-actions.tsx and the per-element normalisation in containers.tsx are gone. The rule is closed: a string is an id, a non-null non-array object is an inline definition, and any other element (null, a number, a nested array) is refused at its index too. An all-id array resolves by name in authored order, first registration winning on a duplicate name; ids that name nothing are reported back with their index for the caller to warn about once its lookup has settled.

.changeset/8155-app-root-residue-swept.md

  • names containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    Swept as a class, not as two coordinates. Every other place in this tree that stated app was a bound expression-scope root is corrected in the same change — the diagnostic copy and its byte-pin, the ambient-scope docblocks in @object-ui/react (SchemaRenderer, useExpression), @object-ui/core (ActionRunner.ParamDef.visible, RowPredicateOptions.scope), @object-ui/components (form.tsx, containers.tsx), @object-ui/plugin-detail, @object-ui/plugin-form (docblock and README), @object-ui/app-shell and the console app, plus fourteen test fixtures that transcribed the old bag with an app key. The fixtures in @object-ui/app-shell now call buildExpressionScope instead of transcribing it, so that pair cannot drift again.

.changeset/9174-interpolate-fastpath-trim.md

  • names packages/components/src/renderers/layout/containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    At this change page:header's title/subtitle (and the record-title titleFormat) all went through interpolate() in packages/components/src/renderers/layout/containers.tsx. When the template contained a {token} the function collapsed and trimmed whitespace before returning; when it contained no { at all it returned the raw string untouched. A whitespace-only authored title (e.g. ' ') has no token, so it came back unchanged — truthy — and PageHeaderRenderer's {explicitTitle && ANGLE-BRACKETS(h1)} gate drew a blank h1. Because literalTitleText (page.tsx), which decides whether PageRenderer delegates its own heading to the authored header, already trimmed and correctly read "no title", PageRenderer also drew its own implicit heading — two ANGLE-BRACKETS(h1) elements on one document, the broken outline objectui#3434 closed, arriving through a different door.

.changeset/plugin-detail-8937-parent-scope-residue.md

  • names RelatedList.tsx → packages/plugin-detail/src/RelatedList.tsx — edited by this change

    • packages/plugin-detail/README.md (it is in files[], so it ships). It said the node's filter is AND-combined with { [relationshipField]: parentId }, full stop. Since objectui#7299 the parent condition is compiled to match the relationship field's arity, so a multi-valued relationship gets { [relationshipField]: { $contains: parentId } } instead. The paragraph now states both spellings and names the arbiter (@objectstack/spec/data's isMultiValueField). - The claim that the SQL driver decides arity on that same predicate. It does not: driver-sql gates the equality family on its own storage question, which reads multiple as truthy on ANY type. The two rules therefore disagree for a type outside MULTI_CAPABLE_TYPES carrying multiple: true. objectui#9184 moved the arity compiler into @object-ui/core's parent-scope seam and carried the claim with it, so the correction is recorded there — the seam now states the driver's measured rule, records the divergence as a divergence, and points at the upstream card that owns which of the two rules is right (objectstack#17469). RelatedList.tsx's pointer comment and the objectui#7299 test header carried the same sentence and are corrected to match.

.changeset/record-picker-label-placeholder-i18n-5637.md

  • names renderers/layout/containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    KNOWN GAP, unchanged by this release: the sibling label read sites in renderers/layout/containers.tsx compose translateLabel(pickLocalized(…), language), and that second helper is not applied here — translateLabel and its KNOWN_LABEL_DICT are module-private to that file. Only the locale-map resolution lands in this change; a plain-English string label is still rendered verbatim in every language, exactly as before.

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.

Angle-bracketed names in the quoted prose above are rewritten as ANGLE-BRACKETS(name): GitHub deletes tag-shaped fragments from a stored body, and a quote that silently loses the identifier it is about is worse than a visible repair.

Compared the checked-out tree with cb2f6fb5b (merge-base with origin/main): 12 file(s) changed outside .changeset/, read against 1824 pending declaration(s) that publish a body (2434 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3576.8 KB 3607.4 KB
Main entry chunk (gzip) 149.7 KB 350 KB
Entry file index-4AFSmV5v.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) 569.34KB 136.09KB
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.19KB 66.27KB
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.08KB 44.49KB
plugin-detail (index.js) 242.11KB 63.60KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 172.30KB 44.19KB
plugin-gantt (index.js) 172.43KB 42.85KB
plugin-grid (index.js) 230.79KB 63.39KB
plugin-kanban (index.js) 48.46KB 15.12KB
plugin-list (index.js) 116.28KB 28.88KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 32.26KB 9.42KB
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: b8d647a3c4684f1fb594664527f9b59920657d8f
Local-runs: none

Inputs: card objectui#11212 (body and its 4 comments — the added leg 5911534880, the re-point 5912428017, the claim 5913814153, the dev report 5915507971), the ruling objectui#4421 5321072032 (Rider 1, verbatim in the card), PR objectui#11237 (body, the 13-file list, the diff from the merge-base dded788ada to the head; main is 5 commits past that merge-base and none of them touches the 13 files, packages/core/src/evaluator, packages/core/src/actions, app-shell/src/providers or packages/permissions, so the reviewed diff is the net diff against main), and the head's check-runs: 43 runs, 40 success, 3 skipped (dependabot and the two coverage jobs), 0 failure, none unconcluded at this read — Lint, Type Check, Spec Main Shape Gate, all 8 test shards, Test (dist pins), Build & E2E and every Changeset job among the successes. Nothing built, run or re-run here.

① Derived judgments

  1. action:group (inline member, dropdown member, host), action:icon and RelatedToolbarButton — visible fails CLOSED on a fault: right. Each leg now passes throwOnError and a locator label to useCondition, whose throwing branch returns false and warns once per (label, predicate) — the branch action:button, action:menu, action:bar and DeclaredActionsBar already take (the throwOnError: true sites at this head). Both action:group leaves read one same-file, unexported useMemberVisible, so a member cannot differ by display mode. Accept-set change: a visible that faults, for ANY cause, moves from SHOWN to hidden on these surfaces; a holding or denied predicate answers as before. That widening past "permission-shaped" is the card body's adopted direction (option A: one fail-closed policy per key, ⛔ not a can() special case — a special case would need the evaluator to classify fault causes, which the card ruled out), and the unbound-root arms of the new pin hold it. It is stated in the changeset ("hidden too, where it used to be shown") and in page-header.mdx. Within the ruling as the seat adopted it: right. RelatedToolbarButton passes undefined as the row (a list-level action binds no row), so only the policy moved, not the binding.
  2. page:header disabled fallback false → true in containers.tsx, with listConditional.ts untouched: right. evalRowPredicate returns opts.fallback ?? false on a fault (and on an absent or blank predicate), so the direction is the caller's; the header's local evalHeaderPredicate now takes fallback per key (visible false, hidden false, disabled true) and has exactly those three call sites. Flipping the default in listConditional.ts would have moved every caller (row menu, data-table rows, the bulk fold) — the fork ③ carries. Blast radius, verified: every AUTHORED header action whose disabled CEL faults (a misspelled field, a retired data.* or bare spelling, an unbound root, can() before load) now renders disabled rather than enabled; the built-ins are unaffected because RecordDetailView hands sys_delete a boolean disabled: true or no key at all, and resolveDisabled returns a boolean before the evaluator is reached. This puts the header where its family already was: evaluateCelCondition without throwOnError returns fail-soft true, which on disabled is DISABLED for action:button / action:menu / action:group / action:icon, and ActionRunner.execute refuses a faulting disabled — the header was the one leg of that family that failed open. Stated in the changeset's second paragraph and the doc: right.
  3. useActionEngine binds the predicate-scope subject as current_user — the same object, not a copy: right, and required. subjectPermissions.ts carries the permissions map ON the subject under a symbol, subjectPermissionsOf(scope.current_user) reads it, and the engine's can refuses any receiver that is not the bound subject (receiver !== subject), so only the scope's own object can answer. ActionRunner.updateContext merges, and withIdentityAlias derives os.user from user only, so nothing on the runner path overwrites the binding. The memo re-keys on JSON.stringify(subject) and on the map's identity (the payload object the upstream adapter caches outside React): AGENTS.md [WIP] Enhance every detail of the designer #10 holds — a stale-but-equal bound subject is self-consistent (receiver and bound current_user are the same bag entry and the map is read off it), and a discard recomputes to the live one. The standalone path with no context and no subject now seeds {} rather than undefined; both constructors default to {}, so no change. The runner's user / ctx.user / os.user stay the host's object (it carries the systemPermissions the capability gate reads), so on the runner bag user.can(…) still faults while current_user.can(…) answers — an alias asymmetry the predicate scope does not have. The PR body says so; the changeset and the doc do not. Non-blocking; an author note for a later doc pass.
    Reach through the shared provider runner: record:quick_actions, record:alert and DashboardRenderer all call useActionEngine; any one mounted under an ExpressionProvider writes current_user onto the provider's runner, so every execute gate on that page (a header action's disabled re-check, params[].visible) now answers current_user, while a page without such a consumer still faults. An improvement where it applies, no regression elsewhere, and page-dependent: the dev's fourth out-of-scope note, answered in ③.
  4. Premise correction (no dashboard header visible leg): right. DashboardRenderer builds its header defs as name / type / target / label only, so no visible reaches the engine from that surface whatever the spec allows; the binding reaches the dashboard's runner and gates nothing there. record:quick_actions is the only visible surface on that bag, and its three-state row (hidden / HIDDEN / hidden, faulting in every state → hidden / shown / hidden) is pinned through the real RecordQuickActionsRenderer.
  5. Tests: right. The new pin drives 7 legs × 3 states plus 7 unbound-root arms through the real MePermissionsProvider → ExpressionProvider → ActionProvider → registered renderers, one action name per arm (the reports are warn-once per locator) and an ungated companion on every arm. The four edited pins flip exactly the assertions the policy change inverts (action-record-predicate-root: shown → hidden on the action:icon and both action:group arms; page-header-predicate-dialect: enabled → disabled plus a one-report assertion; action-template-predicate-gate: only the failClosed site flags and describe labels; related-toolbar-visible: one added faulting case). No assertion weakened; each flip cites the card.
  6. Docs against AGENTS.md Add automated testing infrastructure and CI/CD workflows #2: satisfied. The one published sentence the change made false ("a disabled predicate that faults leaves the action enabled", page-header.mdx) is replaced by the new direction, with the renderers that hide a faulting visible named — true at this head. No package README states these legs' fault policy; guide/architecture.md's "this surface fails soft" is the plain button node's generic visible, untouched; guide/layout.md names no fault policy. The useExpression.ts edit updates a pre-existing same-file enumeration of the throwing legs (it now also names action:bar, which it had missed); accurate at this head. Residual, not owed here: packages/core/src/evaluator/predicateInput.ts's objectui#3871 narrative still lists action:group / action:icon / RelatedList among the "fail-soft callers", in the past tense — historically true, now a former state; core is untouched and [WIP] Update documentation for project #11 asks for that repair when the file is touched.
  7. Pending changesets (the re-read bot's 7): none states a fault policy for these legs. The nearest, 4421-current-user-can-binding.md, says a surface that evaluates visible fail-closed does not render — still true. Right.

② Semver level

  • Frontmatter @object-ui/components, @object-ui/plugin-detail, @object-ui/react at minor, with Clause-②: yes on the PR body: right. Three visible legs and one disabled leg change the answer an authored key gives on a fault, and the ActionRunner bag gains a root (current_user) — an accept-set change on authorable metadata, so yes, and yes takes at least minor. Changeset Bump Policy (check-changeset-no-major), Changeset Declaration, Changeset Fixed Group Check and Changeset Overwrite Report are all success on the head.
  • @object-ui/app-shell owes no line: only a test file changed, nothing it ships moved, and the presence gate asks for a changeset per range (its verdict reads "declares 1 changeset(s)"), not a line per package. @object-ui/core owes none: subjectPermissionsOf was already exported at the merge-base and core is untouched.
  • Body, line by line: paragraph 1 (the three visible surfaces, the reach on /forms/:name and embeds, warn-once, per key not per can) — right. Paragraph 2 (page:header disabled, other faults included, visible / hidden unchanged) — right. Paragraph 3 (record:quick_actions faulted in every state; useActionEngine binds the subject) — right.
  • The headline is wrong, and it ships verbatim: "stays hidden (or disabled) until the permissions payload has loaded, on every action surface". For disabled that is not what this diff publishes: the row-menu item's disabled: !current_user.can(…) reads ENABLED while not loaded (the dev's own measurement, open_questions[0]), the data-table row action's disabled fails soft by its own comment ("not disabled"), and the built-in disabledWhen legs keep their documented fail-open posture. The body names page:header for disabled and never says "only", so the claim survives a full read. An author who writes a disabled permission gate on a row menu on the strength of this line gets the exact first-paint leak Rider 1 exists to close. Fix: scope the headline — "stays hidden until the permissions payload has loaded on every action visible surface, and a page:header action gated through disabled stays disabled" — or drop "on every action surface" and let paragraphs 1 and 2 carry the scope. One clause; nothing else in ② moves. This is the blocking finding.

③ Boundary flags

  • Surface vs claim (fix in containers.tsx; listConditional.ts and ActionRunner.ts untouched; four pins, the doc and a comment added): accepted — the claim made listConditional.ts conditional on measurement, and ① item 2 confirms the direction is the caller's. Every extra file is named in the PR body and the report.
  • useMemberVisible instead of two copies of the option: accepted; unexported, mirrors action:menu.
  • Dashboard half falsified: accepted (① item 4).
  • Changeset packages: answered in ②.
  • open_questions[0] — Rider 1 on the remaining disabled legs (row-menu item, data-table row action; the built-in disabledWhen behind them): for the seat. My reading: it does not block THIS PR. The card's table named only the record header among the disabled legs, the card body reserves the other disabled legs to their own merits, and the row menu lives in plugin-grid, outside the claim's file surface. It does bear on closing objectui#4421: the parent closes when this card lands, and the open legs are Rider 1 residue by the same reading that produced this card, so the seat decides — a sub-issue of Action visible CEL cannot see the caller's object permissions — a custom action replacing a built-in CRUD button has no way to express the gate it replaced #4421, or a recorded acceptance of the split — before the parent closes. On the merits I read A as the consistent answer (page-header.mdx tells authors one declaration gates the header and the row menu, and today that declaration reads DISABLED on one and ENABLED on the other while the payload loads), but that is the seat's call, not this record's. Whichever way it goes, the changeset headline must describe THIS diff (②).
  • Out-of-scope 2 — RelatedToolbarButton renders an action authored visible: false (visiblePred && !isVisible gates on truthiness; deriveActions passes visible through): carrier none, not filed. Escalated to the seat: authored metadata reaching the wrong render on the line this PR edits, the objectui#3812 declared-gate class (hasDeclaredVisibilityGate is the one-token fix). Not blocking here — a different defect class, recorded — but I read it as deserving a carrier rather than none.
  • Out-of-scope 3 — ActionRunner.execute's disabled-gate comment says a fault "defaults to NOT-disabled" while the code blocks: comment-only, core untouched, and this change rules DISABLED as the direction, so the code is right and the comment wrong. Non-blocking; ride the next core touch.
  • Out-of-scope 4 — the provider runner binds current_user only through useActionEngine: non-blocking, and it should get a carrier. Before this diff the execute gates faulted on current_user uniformly; after it they answer on a page with a record:quick_actions / record:alert / dashboard block and fault on one without — a page-shaped fault where there was a uniform one. ActionProvider is in the same package and can read usePredicateScope(); binding there would make the two paths one bag on every page. Seat's call whether it files under objectui#4421 or the PR objectui#11208 neighbour note.
  • Incident (an unquoted heredoc ran backticked text): no residue — the diff is the 13 files the report lists, no stray file, and the branch's two commits are the fix and the any removal.
  • Throwaway probes: none in the diff.
  • PR footer and commit-trailer form: outside this record's sections; the head commit carries the trailers the report states.
  • Draft: the PR is still a draft; the seat marks it ready after a passing record.
  • CI convergence: concluded at this read — 40 success, 3 skipped, 0 failure, nothing in progress or queued.

Implemented-by: claude/issue-11212-rider1-fail-soft-legs
Reviewed-by: session_0122Knsowci76D2rBWReCzzZ

VERDICT: FAIL

…ublishes

The headline said the gated action stays hidden or disabled "on every
action surface". For `disabled` that overclaims: only `page:header`'s
disabled leg moved. The headline now names the `visible` surfaces and the
`page:header` `disabled` leg, which are the two the diff changes.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3576.8 KB 3607.4 KB
Main entry chunk (gzip) 149.8 KB 350 KB
Entry file index-4SbEv5O_.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) 569.34KB 136.09KB
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.19KB 66.27KB
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) 242.11KB 63.60KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 172.30KB 44.19KB
plugin-gantt (index.js) 172.43KB 42.85KB
plugin-grid (index.js) 230.79KB 63.39KB
plugin-kanban (index.js) 48.46KB 15.12KB
plugin-list (index.js) 116.28KB 28.88KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 32.26KB 9.42KB
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: cb561e78bc46f35cbfd46619600df116f10830ef
Local-runs: none

Delta record. The record of record on the previous head is 5915761337 (head b8d647a3c4684f1fb594664527f9b59920657d8f): FAIL on exactly one blocking finding, in ②, the changeset headline's "(or disabled) … on every action surface". Everything else in that record was judged right, and this record re-judges only what the patch round moved. Inputs: that record; the patch-round dev report 5915791474 on card objectui#11212 (patch_round: 1, head cb561e78bc); the PR (head cb561e78bc, 3 commits, 13 files, still a draft); git log / git diff between the two heads and against the merge-base dded788ada; the changeset file at both heads; and the head's check-runs. Nothing built, run or re-run here.

① Derived judgments

  1. What moved between the heads: exactly one commit, exactly one line. git log b8d647a3c4..cb561e78bc lists one commit, cb561e78bc, whose first parent is b8d647a3c4 (a fast-forward, as the report says; the branch tip claude/issue-11212-rider1-fail-soft-legs is cb561e78bc, and the PR's commit list reads 8d44406f79, b8d647a3c4, cb561e78bc). Its diff is .changeset/11212-permission-gates-fail-closed.md, 1 insertion, 1 deletion, the headline line:

    -fix: an action gated on `current_user.can(object, verb)` stays hidden (or disabled) until the permissions payload has loaded, on every action surface, and `record:quick_actions` can answer `current_user` at all
    +fix: an action gated on `current_user.can(object, verb)` stays hidden until the permissions payload has loaded on every action `visible` surface, a `page:header` action gated through `disabled` stays disabled, and `record:quick_actions` can answer `current_user` at all
  2. Nothing else moved. The file list against the merge-base is the same 13 files at both heads (1 changeset, 1 doc, 4 edited pins, 1 new pin, 6 source files). The diff from dded788ada to each head with the changeset file excluded hashes identical (sha256 fbf8f097…47701 at both), so the source, test and doc diff this PR publishes is byte-for-byte the diff 5915761337 judged. The changeset's frontmatter and its three body paragraphs are unchanged line for line.

  3. Carry-over. Because the reviewed source, tests and doc are identical, ① items 1 to 7 of 5915761337 (fail-closed visible on action:group ×3, action:icon and RelatedToolbarButton; the page:header disabled fallback moved in the caller with listConditional.ts untouched and the blast radius bounded to authored header actions; useActionEngine binding the same subject object as current_user; the dashboard premise correction; the 28-arm pin and the four flipped pins; the docs under AGENTS.md Add automated testing infrastructure and CI/CD workflows #2; the 7 pending changesets) hold at this head as written there, and are adopted here without re-derivation.

② Semver level

  • Frontmatter unchanged: @object-ui/components, @object-ui/plugin-detail, @object-ui/react at minor, Clause-②: yes on the PR body. Carried from 5915761337: right. Changeset Bump Policy, Changeset Declaration, Changeset Fixed Group Check, Changeset Overwrite Report and Changeset Claim Re-read are all success on this head.
  • The blocking finding is closed. The new headline makes three claims, each checked against the diff at this head:
    1. "stays hidden until the permissions payload has loaded on every action visible surface". The diff moves action:group (inline member, dropdown member, host), action:icon and RelatedToolbarButton to throwOnError, joining action:button, action:menu, action:bar and DeclaredActionsBar, which already had it; page:header's visible goes through evalHeaderPredicate with fallback false; the row menu (RowActionMenu.tsx), the data-table row action (data-table.tsx) and the related-record create predicate (RelatedRecordActionsBridge.tsx) each call evalRowPredicate with fallback: false; record:quick_actions is the one visible surface on the ActionRunner bag and reads hidden while the payload has not loaded (paragraph 3, and the pin's not-loaded arm). useRowPredicate, whose default fallback is true, has no non-test caller at this head, so it reaches no action visible leg. No action visible surface at this head shows on a fault: "every action visible surface" is true, and it is the key the surfaces share, not a can() special case, as paragraph 1 says.
    2. "a page:header action gated through disabled stays disabled". Exactly the one disabled leg the diff changes (containers.tsx, fallback true on that key), stated as a single surface. The row-menu item, the data-table row action and the built-in disabledWhen legs, which the earlier record named as still fail-open on disabled, are no longer covered by the headline; the body never named them, and paragraph 2 still scopes the disabled change to page:header. The overclaim is gone.
    3. "record:quick_actions can answer current_user at all". Unchanged wording, judged right before (① item 3 and 4 of 5915761337), unchanged diff.
  • Body, re-read in full at this head for any sentence that still overclaims: paragraph 1 names action:group (both display modes and the group's own visible), action:icon and the related list's toolbar, and states the per-key rule with the unbound-root example; paragraph 2 scopes disabled to page:header, extends it to any fault cause, and says visible and hidden keep their directions; paragraph 3 describes the record:quick_actions fault and the useActionEngine binding. Each sentence describes what this diff publishes; none reaches past it. The non-blocking author note from the earlier record (the runner's user / os.user alias asymmetry is stated in the PR body but not in the changeset or the doc) stands as a note and does not block.
  • ② passes at this head.

③ Boundary flags

  • Every flag in ③ of 5915761337 carries over unchanged, the diff it judged being byte-identical at this head: the surface-vs-claim acceptance (containers.tsx over listConditional.ts); useMemberVisible; the dashboard half falsified; open_questions[0] (Rider 1 on the remaining disabled legs, for the seat before objectui#4421 closes, not blocking this PR); out-of-scope 2 (RelatedToolbarButton renders an authored visible: false, escalated for a carrier); out-of-scope 3 (ActionRunner.execute's disabled comment); out-of-scope 4 (the provider runner binds current_user only through useActionEngine, a carrier suggested); the heredoc incident with no residue; no throwaway probes in the diff.
  • Patch-round report against the head: the report's diff line ("1 file, 1 line … the headline line only … pushed without force") matches what git shows (① item 1 and 2). Its tests list is two gates plus a control-byte self-scan and says no test was re-run; for a one-line prose change in a changeset that is the right scope, and CI runs the suites (below). open_questions and out_of_scope_findings empty: consistent, nothing new was opened by a headline edit. The commit message went through a quoted heredoc per the report's deviation note, and the commit body reads as intended.
  • Draft: the PR is still a draft; the seat marks it ready after this record.
  • CI convergence: not concluded at this read (2026-09-30T16:57:51Z). 42 check-runs on cb561e78bc: 26 success (among them Build & E2E, Test (dist pins), Live E2E, Control Byte Scan, Shell Escape Residue Scan, Line Citation Gate, Governed Surface Queue Guard, every Changeset job and every Doc check that has finished), 3 skipped (dependabot and the two coverage jobs, as on the previous head), 0 failure, and 13 still in_progress: Lint, Type Check, Spec Main Shape Gate, README Export Check, Skill Example Check and Test shards 1/8 through 8/8. Those 13 are neither passed nor failed. This record does not wait on them: the only change since a head on which all of them were success is one prose line in a changeset, so a failure among them would be new information, and the seat reads the head's check-runs before the merge, as it does for any head.

Implemented-by: claude/issue-11212-rider1-fail-soft-legs
Reviewed-by: session_0122Knsowci76D2rBWReCzzZ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 17:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 8bab157 Sep 30, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11212-rider1-fail-soft-legs branch September 30, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants