Repository navigation
feat(app-shell,core,permissions): an action predicate can ask current_user.can(object, verb) once the permissions payload has loaded (objectui#4421) - #11208
Conversation
… into the predicate scope once permissions have loaded (objectui#4421) The acting subject carries the loaded /auth/me/permissions objects map under a symbol; evalFieldPredicate hands it to the engine as EvalContext.permissions. Not loaded: nothing is bound and the engine refuses can() loudly, so each surface applies its own fault policy. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…tes per surface family and document it as client-only UI gating (objectui#4421) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…s (objectui#4421) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…jectui#4421) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…e is measured, not inherited from warn-once (objectui#4421) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
|
changeset-claim-re-read
|
✅ Console Performance Budget
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
Size Limits
|
|
Generated by Claude Code |
Contract reviewServed-tier: Inputs read: card #4421 (body and all 10 comments, rulings ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged against the rulings and the engine it wires to.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
✅ Console Performance Budget
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
Size Limits
|
Contract reviewServed-tier: Delta record. This head is the GitHub update-branch merge of ① Derived judgmentsCarried over from record
So every judgment in ① of record ② Semver levelCarried over from record ③ Boundary flags
Implemented-by: VERDICT: PASS |
…ail closed while the permissions payload has not loaded (objectui#11212) (objectstack-ai#11237) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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) - `RelatedToolbarButton` gates on truthiness (`visiblePred && !isVisible`), so a toolbar action authored `visible: false` renders. A probe measured SHOWN. `RelatedRecordActionsBridge`'s `deriveActions` passes `visible` through unfiltered. This is the objectui#3812 declared-gate class, not this card's fault-policy class, so it is left untouched. Carrier: none. - `ActionRunner.execute`'s `disabled` gate comment says a fault "defaults to NOT-disabled". The code blocks, which is the direction this change rules for `disabled`. So only the comment is wrong. It was recorded as a neighbour on PR objectstack-ai#11208, and it is untouched here. - The runner bag gains `current_user` only through `useActionEngine`. Because that hook merges into a shared provider runner, a page with a `record:quick_actions` block also binds `current_user` for the provider's `execute` gates, the same way it already binds `record` / `recordId` / `objectName`. The ActionProvider-level binding stays open under the PR objectstack-ai#11208 neighbour note. --- _Generated by [Claude Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…sibleWhen refuses the submit, naming the field and the rule; a blank field rule is refused at authoring; blank gates are diagnosed (objectui#8069) (objectstack-ai#11233) Fixes objectstack-ai#8069 Clause-②: yes (narrowing) A field `visibleWhen` that cannot be evaluated now **refuses the submit**, naming the field and the rule, on the three submit paths the ruling names: the record form renderer (`form.tsx`, so every `ObjectForm` layout), the console's `FormPage` (`/forms/:name` and `/f/:slug`), and the wizard's cross-step gate at final submit. `requiredWhen` / `readonlyWhen` are unchanged on the client; the server's ADR-0137 D2 refusal of them lands beside the input. Both blank-gate silencers are diagnosed, with no verdict changed. Ruling: card comment 5910115531 (batch objectstack-ai#256 item 4) — Q1 = B, one judge per rule; Q2 = B as seat 1 answered it in 5908497249. Dispatch claim: 5912071659. ## Narrowing - **Accepted before:** a form whose field `visibleWhen` faulted (typo in a column, syntax error, unbound root) or was stored BLANK drew the field (fail-open) and submitted as if the rule had said "show"; and objectui's `FormFieldSchema` accepted a blank `visibleWhen` / `readonlyWhen` / `requiredWhen` at parse. - **Refused now, at submit (ADR-0137 D2):** the same submit, with `form.visibleWhenFaulted` naming the field(s) and the rule — a stored BLANK `visibleWhen` included ("A blank predicate takes this path too, wherever one is already stored"). The field is still drawn (D3 unchanged). Nothing is written. - **Refused now, at authoring (ADR-0137 D1) — `@object-ui/types` (minor):** `FormFieldSchema`'s field-rule triad refuses a predicate whose text is blank after trimming, with the spec's own sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`), as the spec already does on `FieldSchema`. The accepted SHAPE is unchanged (see the `ExpressionWireSchema` paragraph); gate keys keep the plain wire, and a blank gate stays "no gate" plus a one-time diagnostic. - **Why:** ADR-0137 D2 ("at submit time, a field-rule predicate that cannot be evaluated refuses the write and names the field and the rule"), as ruled: the client refuses only the rule no server evaluates, because its fail-open render direction is otherwise a silent grant. `requiredWhen` / `readonlyWhen` faults — a stored blank one included — are refused by objectql's D2 and stay the server's. ADR-0137 refuses "blank means no rule" for the triad (Alternatives), so a blank is refused at the first place that can see it: authoring (D1, objectui's own wire handled in the same round, as the ruling's execution text asks) or submit (D2). Applied after contract review 5915380177, finding 1. - **Accepted residuals (pinned, not worked around):** a `visibleWhen` reading `previous` cannot be evaluated on a CREATE form, so such a form is refused on every submit (ruled); and the wizard's cross-step gate binds no `previous` in either mode, so the same rule is refused at an EDIT wizard's final submit too (accepted by the seat under the ruling's refusal of option D; `persistedRecord` is deliberately not bound). - **Widenings in the same diff (published surface added):** - `@object-ui/core` (minor): `resolveFieldRuleState` returns a new `faults` member beside its three verdicts, and its type `FieldRuleFaults` is exported from the package root. - `@object-ui/i18n` (minor): the new key `form.visibleWhenFaulted` in all ten packs, so the exported `TranslationKeys` type gains a member. ## What changed **Core — the report a submit path reads.** `resolveFieldRuleState` fills `faults` from the `onFault` passback of the very `evalFieldPredicate` calls that draw the verdicts, so no refusal evaluates a predicate a second time. It reports all three rule kinds; which ones a path refuses on is the path's ruling. Left out, by construction: a rule the verdict did not need (short-circuited, never ran). A stored BLANK rule is IN it, with its `[blank]` reason — ADR-0137 D2's third state, not a spelling of "no rule". The stale "open question" prose in `fieldRules.ts` now points at ADR-0137 D2 / D3 / D5. Edited regions stay clear of PR objectstack-ai#11208's two hunks in that file (its import line and the `evalFieldPredicate` evaluate call); `git merge-tree` of this branch with objectstack-ai#11208's head `947b8d38b` is clean. **The three submit paths**, each reading `faults.visibleWhen` off the call it already makes: - `form.tsx`: the `conditionallyHiddenFieldNames` memo (every declared field's `visibleWhen`, the `6a449fc49` clear-on-hide input) now also returns the faulted names. `handleSubmit` refuses BEFORE react-hook-form validation (nothing the user types clears a broken rule), on the in-form banner and the outcome toast, and marks the tabs holding the fields. Every declared field is judged, not only drawn ones: a faulted `visibleWhen` never clears on hide, so its value reaches the payload whatever hides it. A `section-divider` row's `visibleWhen` (its section's layout gate) is excluded. - `FormPage`: `findMissingRequired` became `findSubmitRefusals`, one `resolveRowState` pass returning both the faulted rows and the missing ones; the fault is refused first. Hidden sections stay skipped, as the required check already skipped them: on this page visibility decides only what is drawn, so a row a hidden section keeps off screen cannot be shown by its own broken rule. - `WizardForm`: `missingRequiredByStep` became `gateFinalSubmit`, the same pass returning both; a fault lands the wizard on the first step holding such a field. **Q2 — the two blank-gate silencers, diagnosed (ADR-0137 D4), verdicts unchanged:** 1. `ExpressionEvaluator.evaluateCelCondition` answered `true` for a blank CEL source before anything could say so. It now reports through `evalFieldPredicate`'s `[blank]` channel (the caller's `onFault` when given, else the deduped built-in warning) and still answers `true` in every mode, `throwOnError` included. 2. `hasDeclaredPredicate` folded blank text to "no gate". It still does (objectui#3850 / objectstack-ai#3960), and now reports once through the same channel. Its locator is fixed (the question is key-neutral), so the dedupe is per blank spelling across the app — stated in its docblock. Two existing pins asserted the silence and now pin the diagnosis instead, verdicts untouched: `SchemaRenderer.disabledGateFaultDiagnostic.test.tsx` (objectui#3862 empty shapes) and `ActionParamDialog.test.tsx` (blank param `visible`). **`ExpressionWireSchema` — D1 on objectui's own form wire, and no second wire type.** `FormFieldSchema`'s `visibleWhen` / `readonlyWhen` / `requiredWhen` are `ExpressionWireSchema.superRefine(...)` (module-private `FieldRulePredicateWireSchema` in `form.zod.ts`): the check hands the predicate's TEXT (the string, or an envelope's `source`) to `stripImportedDefaults(EvaluatedExpressionInputSchema)` from `@objectstack/spec/shared` and refuses with that schema's verdict and sentence, so neither the blank rule nor its message is restated. Only the text is judged, never the envelope, whose spec `dialect` enum is narrower than the wire. The refined union's arms ARE `ExpressionWireSchema.options`, by reference, and its parse output is the authored value unchanged — so the accepted SHAPE is the one wire type objectui#7530 ruled, and only a blank VALUE is taken out. That closes the submit-refusal trap: a blank cannot be authored, and one already stored is refused at submit. `BaseSchema`'s `visible` / `hidden` / `disabled`, a field's view-level `visibleOn` and an option's `visibleWhen` keep the plain const. Two test instruments followed: `imported-defaults-8317.test.ts` lists the new spec crossing, and `terminal-unknown-key-refusal-11073.test.ts` rebuilds its OPEN twin from the union's own def (its `z.union(options)` dropped the union's check and read the triad's blank refusal as an accept-set move). **Docs:** `content/docs/guide/metadata-diagnostics.md` and `content/docs/plugins/plugin-form.mdx` said a broken predicate never blocks a submit; both now describe the refusal, and `metadata-diagnostics.md` adds that a blank field rule is refused (at authoring by the form schema, at submit when stored) while a blank gate stays "no gate". ## Pins - `packages/core/src/evaluator/__tests__/fieldRuleFaults-8069.test.ts` — the report per rule kind, the create-form `previous` residual with its edit-form control, short-circuited rules, a stored blank rule reported as `[blank]` for each rule kind with an absent-rule control, and both silencers with their controls (fresh module graph per case, since the unit project runs `isolate: false`). - `packages/components/src/renderers/form/__tests__/visibleWhen-fault-refuses-submit-8069.test.tsx` — the refusal, its ordering before validation, the healthy control, a stored blank `visibleWhen` refused, a blank view-level `visibleOn` NOT refused (the gate control), the divider exclusion, the residual and its edit control, and **the end-to-end server pin**: a faulted `requiredWhen` / `readonlyWhen` is let through by the client and the server's D2 envelope (`VALIDATION_FAILED`, `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, transcribed from objectql at the spec 17.5.0 tag `0f6dcac5e9`) lands inside that field's `data-field` wrapper. - `apps/console/src/components/FormPage.visibleWhenFault-8069.test.tsx` and `packages/plugin-form/src/wizardVisibleWhenFault-8069.test.tsx` — the same cases on the page (read off the stubbed write) and on the gate (a skipped step, so only the gate can see the field). - `visiblewhen-clear-on-hide-6958.test.tsx` BOUNDARY pin restated for D2: a broken predicate clears nothing AND writes nothing — the verbatim constraint "a broken predicate must NEVER silently null a stored column" now holds in both halves. - `packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts` — re-pinned to what objectui#7530 protects, ONE wire shape: `BaseSchema`'s gates and the form's gate legs still carry `ExpressionWireSchema` itself; the triad carries a refined clone whose arms are `ExpressionWireSchema.options` by reference; each triad key refuses `''`, whitespace, `{ source: '' }` and a whitespace CEL envelope at the key with `EVALUATED_EXPRESSION_SOURCE_REQUIRED`, accepts a non-blank string, a CEL envelope and a dialect-less envelope with the value unchanged, and still refuses junk; the control: a blank gate on `BaseSchema`, `visibleOn` and an option's `visibleWhen` still parses. The comment cites ADR-0137 D1 and this ruling. ## Verification All heavy runs went through `os-verify-lock.sh` (slot `issue-8069`); verdicts read from its `VERDICT` line and from the pass counts, never a bare exit code. Base `0389650f3`. **Rework round (contract review 5915380177: finding 1 as option B, finding 2 accepted), final head `277476aaf`:** - At `277476aaf`: `apps/console/src/components/` 36 files, 437 passed; the twelve pin files (the four new ones, `fieldRules.test.ts`, the objectstack-ai#6958 file, `wizardPredicateScope`, `ActionParamDialog`, `SchemaRenderer.disabledGateFaultDiagnostic`, and the types pins `base-schema-predicate-envelope-7530`, `imported-defaults-8317`, `terminal-unknown-key-refusal-11073`) 12 files, 469 passed; `@object-ui/console` type-check EXIT 0. - At `90d406183` (the one later commit changes only the console pin file, re-run above): `turbo run build --filter='@object-ui/console^...' --concurrency=2` EXIT 0; `type-check` EXIT 0 for `@object-ui/types`, `@object-ui/core`, `@object-ui/i18n`, `@object-ui/components`, `@object-ui/plugin-form`, `@object-ui/console`, 0 `error TS`; `packages/types/` 289 files, 6915 passed; `packages/core/` 191 files, 3768 passed, 27 skipped; `packages/components/src/renderers/form/` 69 files, 475 passed, 17 skipped; `packages/plugin-form/` in three chunks, 50 + 47 + 57 files, 310 + 514 + 953 passed, 1 skipped; root `scripts/__tests__/` 177 files, 5371 passed. - `eslint` on the 12 code files this round changed: 0 errors, per-file warning counts unchanged (0 on the new and changed pins). Light gates at `90d406183`, all EXIT 0: the list below plus `check:spec-symbols` and `check:phantom-deps`; `check-governed-queue-guard --test` over 34 paths NOT GOVERNED. - Ablations at `277476aaf`, same discipline as below: baseline 5 files, 112 passed. **D — drop the triad's blank refusal** (the `superRefine`'s issue branch made unreachable): 12 failed / 50 passed, exactly the twelve blank cases (three keys, four spellings); every accept, shape-identity, junk and gate control green; blob `53a98c5b` to `c748e2ba` and back. **E — take a stored blank out of the fault report again**: 7 failed / 43 passed, exactly the seven stored-blank cases (core four, form, FormPage, wizard); blob `4d2bb278` to `01b3a2c2` and back. The first D attempt, at `90d406183`, was an anchor miss the tool refused (exit 3, nothing ran): its anchor was the pre-`superRefine` spelling. **First round, at `ff6d7775b`:** - `type-check` (each after `turbo run build --filter='@object-ui/console^...' --concurrency=2`, EXIT 0): `@object-ui/core`, `@object-ui/i18n`, `@object-ui/components`, `@object-ui/plugin-form`, `@object-ui/console` — all EXIT 0, 0 `error TS`. Each package's script also compiles its tests (`tsconfig.test.json`, and the console's own config), which is where the first round found twelve TS7006 and one TS2550 in the new pins, all fixed. - `pnpm exec vitest run packages/components/src/renderers/form/`: 69 files, 474 passed, 17 skipped. - The eight pin files (the four new ones, `fieldRules.test.ts`, the objectstack-ai#6958 file, `ActionParamDialog.test.tsx`, `SchemaRenderer.disabledGateFaultDiagnostic.test.tsx`): 8 files, 189 passed. - `eslint` on the 27 changed code files: 0 errors; every file's warning count equals its count at base (new files 0) — population is the changed-file list, counts from `--format json`, and type-aware linting is not enabled in `eslint.config.js` (no `project` / `projectService`), so the diff cannot move a verdict on an untouched file. - Light gates, all EXIT 0: `check-changeset-presence`, `check-control-bytes`, `check:new-line-citations` (0 new), `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`, `check-changeset-claims`, `check-changeset-no-major`, `check:test-path-roots`, `check-vi-mock-specifiers`, `check-unreferenced-sources`, `markdown-test-inputs.mjs --audit`. A control-byte scan of every changed file finds none. - `check-readme-exports`: NOT MEASURED in full — it exits 1 on two packages outside this diff that the build closure does not include (`@object-ui/cli`, `@object-ui/plugin-ai`, "run pnpm build first"); over the 35 built packages it judged 545 self-imports real, 0 wrong-path, 0 fabricated. **Earlier heads (the later commits only touched test typing, two pin files re-run above, and one `(field as any).label` becoming `field.label`):** - `packages/core/` 191 files, 3767 passed, 27 skipped · `packages/i18n/` 74 files, 1216 passed · `apps/console/src/components/` 36 files, 436 passed (all at `5800ae995`). - `packages/plugin-form/` in three chunks: 50 + 47 + 57 files, 310 + 514 + 952 passed, 1 skipped · `packages/react/` 104 files · `packages/components/src/renderers/action/` 29 files, 475 passed · 18 consumer files of `hasDeclaredPredicate` / `resolveFieldRuleState` in app-shell, plugin-detail, plugin-grid and components · root `scripts/__tests__/` 177 files, 5371 passed (all at `2dc06d4e2`). The react and consumer runs each had ONE red, both a pin asserting a blank gate stays SILENT — the behaviour Q2 = B reverses; both pins now assert the diagnosis and are green in the final run. - 20 more files that watch `console.warn` near a blank value, found by grep across other packages: 22 files, 773 passed (at `cfd6676a9`). **Ablations** (at `cfd6676a9`, each leg through `ablation-replace.mjs`: the anchor hit exactly once, the write proven on disk by anchor count and blob hash, the restore proven by blob hash equal to `HEAD` and an empty `git diff HEAD`; the package is aliased to `src` in the vitest config, so no `dist` leg applies): - Baseline, unmutated: 6 files, 104 passed. - **A — drop the `visibleWhen` refusal** at its one source (the fault report records nothing): 13 failed / 91 passed, red by name on every refusal and report pin across all six files (form, objectstack-ai#6958 boundary, wizard, FormPage, core); every CONTROL, blank, `requiredWhen` / `readonlyWhen` and edit-form case stayed green. Blob `2e74e4cc` to `dc5beddab` and back to `2e74e4cc`. - **B — restore the silent blank fold** (`hasDeclaredPredicate` without its report): 4 failed / 20 passed, exactly the four silencer-2 diagnosis cases; its controls stayed green. Blob `748c4f17` to `0fb13924` and back. - **C — restore the silent `evaluateCelCondition` guard:** the first attempt was a no-op the tool refused (its replacement contained the anchor, so the anchor count did not drop; exit 1 before anything ran). Re-run with a different anchor: 2 failed / 22 passed, exactly the two silencer-1 diagnosis cases; the `throwOnError`-no-throw case and the control stayed green. Blob `bcf875ee` to `24a1178d` and back. ## Acceptance notes - **Other submit paths** (dispatch item 3): `ObjectForm` and its drawer, modal, split and tabbed layouts, each wizard step, and `MasterDetailForm`'s header `ObjectForm` all render a `type: 'form'` node, so they submit through `form.tsx` and inherit the refusal; a wizard step whose own field faulted therefore refuses its Next as well. `RecordFormPage` delegates to `ObjectForm`. Not judged: `ScreenView` (flow screens — not a record write), `GridField` (grid-column rules are presentation-only in the spec) and `plugin-kanban`'s `requiredWhenPrompt` (`requiredWhen` only, which the server refuses). - **`FormPage` shows a server D2 refusal as page-level text, not inline** — it has no `extractFieldErrors` path. The server's message still names the field and the rule. No change here. - **Not refused, by the ruling's scope:** the view-level field predicate (`visibleOn`, and a form view's own `visibleWhen`, which `sectionFields` routes there) and SECTION predicates are layout gates, not field rules; they stay fail-open and warned. - **Binding gaps the ruling declined to plumb (option D), each measured with zero producers in objectstack `examples/`:** a field `visibleWhen` reading `parent` outside a grid, and one reading a column the create form does not render or seed, are unevaluable there and so refused. - **Accepted residuals, side by side:** a `visibleWhen` reading `previous` is refused on a CREATE form (ruled), and at the final submit of an EDIT wizard, whose cross-step gate binds no `previous` in either mode (accepted by the seat under the ruling's refusal of option D; `persistedRecord` is deliberately not bound). - **Two more blank silencers outside the two named ones, left as they are:** `evaluateCondition`'s legacy path returns `true` for a bare `''` / whitespace string, and `evalRowPredicate` returns its fallback for a bare blank string, both without a diagnostic. A third silent path, also a gate and also untouched: a blank VIEW-level field predicate is dropped by section normalization (`sectionFields`' `attachVisibility`) before any evaluator sees it, on both form chains — "no gate", never refused, but not diagnosed either. - **A published skill sentence is now false and is a governed surface:** `skills/objectui/guides/schema-expressions.md` says field-rule evaluation "never blocks submit". It is not edited here, so this PR stays off the governed surface; it needs its own PR. ## Overlap PR objectstack-ai#11208 (objectui#4421) edits `fieldRules.ts` (its import block and the `evalFieldPredicate` evaluate call) and `RecordFormPage.tsx`'s evaluator memo. This PR touches neither hunk and does not touch `RecordFormPage.tsx`; the textual merge is clean. PR objectstack-ai#11208 landed first (`9cebfca5a`); the merge queue's merge group runs this PR's CI over `main`, which carries it. Session: `https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec` --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #4421 — the objectui half: the binding, its pins and the Rider 2 wording. What stays open on the card is Rider 1 on the fail-soft legs measured below (a decision for the seat, see "Rider 1 — measured per leg"). Server parity is objectstack#18783's.
Clause-②: yes
What this does
An action's
visible/disabledpredicate can now askcurrent_user.can(object, verb)and get the caller's object-permission verdict from the loaded/auth/me/permissionspayload — the card's case: an app turns the built-in Delete off, ships a logical delete in its place, and gates it withvisible: current_user.can('account', 'delete').The engine half landed as
@objectstack/formula@17.5.0(dyn.can(dyn, dyn), answered fromEvalContext.permissions, receiver compared by identity with the boundcurrent_user, loud refusal when the context carries no permission data). This PR is the wiring, through ONE seam:@object-ui/permissions— the permission context carrieseffectiveObjects: the response'sobjectsmap verbatim, orundefinedwhen the provider holds no such response (the role-basedPermissionProvider, or no provider).undefinedand{}stay distinct.@object-ui/core—evalFieldPredicate, the one place every action surface reaches the CEL engine, hands the acting subject's permissions toExpressionEngine.evaluateaspermissions. The map rides on the subject object under a symbol (bindSubjectPermissions/subjectPermissionsOf, new fileevaluator/subjectPermissions.ts): every key of the scope bag is a CEL root and the engine keeps this map out of the variable namespace, while the subject is the one object every bag already carries by reference under all four aliases, through every spread andExpressionContext. No surface got its own copy of the hand-off.@object-ui/app-shell—ExpressionProviderreadsusePermissions()itself and binds the map only whileisLoadedis true (useExpressionPermissions, which adapts the payload through the formula package'stoEvalPermissionsonce per payload object, outside React). One subject object undercurrent_user/user/ctx.user/os.user, the one carrying the map. The two imperative field-visibility evaluators (AppContent's record-form modal andRecordFormPage) take the same input, so one predicate answers the same under the provider and beside it (objectui#6493's one-bag rule).@objectstack/formulafloor raised to^17.5.0in@object-ui/coreand@object-ui/app-shell— app-shell now callstoEvalPermissions, which does not exist before 17.5.0. The lockfile moves only the twospecifierlines; the resolved version was already 17.5.0.content/docs/layout/page-header.mdxgains "Gating an action on the caller's object permissions", ending with the scope sentence: client-side UI gating only, not an authorization boundary, the server still answers 403; server-side evaluation is objectstack#18783..changeset/4421-current-user-can-binding.md(minor on the three packages).Zone 2 — the PM's mechanism assumptions, measured
node_modules/@objectstack/formula(17.5.0dist):registerPermissionPredicateregistersdyn.can(dyn, dyn): bool; subject =buildScope(ctx).current_user(whichextracan supply); verbs via the spec'sresolveObjectPermissionVerb; nopermissionsin the context ⇒ throws (ok: false,kind: 'runtime', message names the missing input). A probe throughextrawith a symbol-bearing subject: grantedtrue, deniedfalse, missing map ⇒ fault, all four aliases identical, a COPY of the subject as receiver ⇒ refused. Holds.MePermissionsProviderholds it but did NOT expose theobjectsmap (only verdict functions). Falsified in part: the producer neededeffectiveObjects; theisLoadedgate is applied as assumed.usePermissionsdefault — the binding never callsusePermissions().can; it readseffectiveObjects, which the no-provider answer does not carry. Measured in the render pin: with no provider,usePermissions().can('account','delete')answerstruewhile the bound action is hidden. Changing the global default would move no binding verdict, so Rider 1 does not need it. Left untouched (usePermissions.tsis not in this diff); the ruling-text conflict is recorded in the report for the seat.visiblepredicates and the identity scope (page-header.mdx); the section also covers the row menu.Rider 1 — measured per leg
Three states on one verb (
delete): not loaded (no permission provider — the state a mount outsideMePermissionsProvideris in permanently), loaded-granted, loaded-denied. RealMePermissionsProvider+ realExpressionProvider+ the real surface.visible(evalRowPredicate,fallback: false)visible(page:header)action:buttonvisible(useCondition,throwOnError)action:buttondisabled: !can(…)(fail-soft, fallbacktrue)record:quick_actions/ActionEnginecontextaction:groupinlinevisible(fail-soft)action:iconvisible(fail-soft)visible(RelatedToolbarButton, fail-soft)disabled: !can(…)(fallback: false)action:group/action:iconlive inpackages/components/src/renderers/action/**, serial behind objectui#11185; the others would need a fault-policy change for every faulting predicate on the leg, wider than "fail closed on the no-permissions error". Neither was done here.MePermissionsProvidershows its loading screen until the first answer, and after itisLoadedstays true (its refetch effect keys on values that do not change). It is reachable where a predicate scope is published outside that provider: the/forms/:nameroute (InternalFormRoutemounts its ownExpressionProvider), and standalone embeds.ActionEnginerow: its context is theActionProvidercontext, which bindsuser/ctx.userbut nevercurrent_user, socanis refused in every state — it never leaks, and it never answers. Out of this card's claimed surface; reported.Tests
Head of the runs:
d4bf561239. Commands from the worktree root, each through the shared verify lock; verdicts are the tools' own lines.pnpm exec vitest run packages/app-shell/src/providers/__tests__/currentUserCan-4421.render.test.tsx packages/core/src/evaluator/__tests__/subjectPermissions-4421.test.ts—Tests 25 passed (25).pnpm exec vitest run packages/permissions/ packages/core/—Test Files 201 passed (201),Tests 3882 passed | 27 skipped (3909).pnpm exec vitest run packages/app-shell/in shards — see the report for the per-shard verdicts and which shards ran.pnpm --filter @object-ui/core type-check/@object-ui/permissions/@object-ui/app-shell— exit 0 each (afterturbo run build --filter="@object-ui/app-shell^...", 28 tasks successful).node scripts/check-changeset-presence.mjs— exit 0, "12 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)".pnpm check:control-bytes— OK.pnpm check:doc-types— "Every documented component type is registered."pnpm check:doc-snippets— "679 of 679 block(s) judged, 0 failed" (after its scoped build).pnpm check:new-line-citations— "0 new citation(s)".console.erroris a developer diagnostic), so the i18n gates were not run.Reverse verification (ablation), direction predicted in the pin's docblock before running. Committed first; the mutation went through
ablation-replace.mjs(anchor...(permissions !== undefined ? { permissions } : {}),deleted fromfieldRules.ts; "ok mutation landed: anchor 1 to 0, blob 4028931a8fdc to da7c89d41dab"). Result:Tests 14 failed | 11 passed (25)— every GRANTED arm and every DENIED arm red (the denied arms through their "nothing reported" assertion; hidden either way), every NOT-LOADED arm green, as predicted. Restore: "ok restored: blob == HEAD (4028931a8fdc) andgit diff HEADis empty"; the pins re-ran green after (Tests 25 passed (25)).The first ablation run disagreed with the prediction on the DENIED arms, and the reason is worth keeping: every fault report on these surfaces is warn-once per (locator, predicate), so with one action name across arms the not-loaded arm's report silenced the same report in the denied arm, and its "nothing reported" assertion passed for a reason unrelated to the verdict. The fix was one action name per arm; the second run is the one quoted above.
Surface notes
RecordFormPage.tsx(the same builder's second imperative caller, one-bag rule),packages/permissions/src/{PermissionContext,MePermissionsProvider,PermissionProvider}(the producer of the map — the claim said "if the real producer is elsewhere, fix it there"), and the twopackage.jsonfloors plus two lockfilespecifierlines.components/renderers/action/**) and objectui#11186 (app-shell/src/hooks/**,layout/**) — this diff touches neither set.Acceptance notes
useConditionleg reports only "its predicate threw — CEL predicate failed to evaluate: SOURCE", dropping the engine's reason (here "carries no permission data"); and its line printsPredicate: [object Object]for an envelope. TheevalRowPredicatefamily forwards the engine reason. Diagnostics only; no verdict moves.objectPermissionGrants:=== true, absent object ⇒false, super-user bits folded,exportneedsallowExport), while the client'sMePermissionsProvider.checkreads!== false, falls back to a'*'entry, and mapsexporttoallowRead. On a payload that materialises every registered object with explicit booleans the two agree; on a hand-built payload they can differ. Which one the built-in buttons should follow is outside this card.isLoadedis the gate, as the dispatch assumed: during a refetchMePermissionsProviderstill holds its previous map withisLoadedfalse, so acan-gated action hides for that window while the built-in buttons keep answering from the old map. In the console that refetch does not occur today.Generated by Claude Code