Repository navigation
Commit ca426cb
feat(formula): current_user.hasCapability(name) — a page predicate can ask whether the viewer holds a system capability (#24478)
Fixes #24414
Clause-②: yes (widening)
Dev session `session_01AQxxbsMRiqgkrsU3Xd54yE`, dispatched by the
`domain:engine` seat 1 PM (`session_01JtSQQ3EWsbNPzyYMug1tXy`, claim
6259485251). Owes one `CONTRACT_REVIEW_TIER` review of record before the
queue.
## What changed
- **`packages/formula` standard library:
`current_user.hasCapability(name)`**, registered beside `can`,
receiver-only (a bare `hasCapability(name)` names no subject and does
not resolve, exactly as a bare `can` does). It answers from the acting
subject's held system capabilities: the `systemPermissions` list `GET
/api/v1/auth/me/permissions` returns, which is the list the action gate
checks `requiredPermissions` against (ADR-0066 D4).
- held: `true`; not held: `false`; empty list: `false` (a real answer).
- no list on the context: **throws** (`ok: false`, `kind: 'runtime'`),
the way `can()` does. Also throws on a non-list, a non-string or empty
name, and a receiver that is not the acting subject.
- a name no action declares is just a string. No registry lookup.
- `can(object, verb)` with an out-of-vocabulary verb still throws; its
message now also names `current_user.hasCapability('NAME')` for a
capability question. No new error code.
- **The subject contract.** `EvalContext` gains `systemPermissions?:
EvalSystemPermissions` (a list of names, the published field verbatim),
and `PermissionBinding` gains the same field (optional, so existing
constructors still compile). `celEngine.evaluate` pins it into the
binding beside `permissions`. Like `permissions`, it is not mounted as a
CEL variable.
- **The save door's lint.** No code change was needed:
`validateVisibilityPredicates`' unknown-function arm asks formula's
`firstUnknownFunctionCall`, whose oracle is the evaluation environment
itself (ruling on #13594, never a name list). Registering the function
admits it. Pinned on a page component `visibleWhen`, beside a control
(`hasCapabilities`, still refused as
`visibility-predicate-unknown-function`).
- **Docs.** The page component `visibleWhen` describe
(`packages/spec/src/ui/page.zod.ts`) names
`current_user.hasCapability('NAME')` as the way to gate a component on a
capability, says it reads the list the action gate reads (never a
`can(...)` proxy), says a host that binds no list makes it fault (and a
faulting predicate renders the component), and keeps ADR-0066 D4:
`visibleWhen` hides, `requiredPermissions` refuses.
`content/docs/references/ui/page.mdx` regenerated by `check:generated
--fix` (only artifact it proved stale).
- **Changeset** `.changeset/24414-capability-predicate.md`:
`@objectstack/formula` minor, `@objectstack/spec` patch (describe text
only), carrying the `Clause-②` line.
### Where the subject contract really lives (measured, so the file
surface moved)
The claim's `packages/core/src/evaluator/subjectPermissions.ts` does not
exist on `origin/main` (`git ls-files | grep -i subjectPerm` = 0 hits;
`packages/core/src` has no `evaluator/` directory). The evaluator
subject is `EvalContext` (`packages/formula/src/types.ts`) plus
`PermissionBinding` (`packages/formula/src/stdlib.ts`), so the field
landed there. `@objectstack/core` is untouched, so it gets no changeset.
### Server-side binders of the subject, and which supply the list today
Every server-side binder that passes object permissions into
`EvalContext` is in `@objectstack/objectql`: `engine.ts`
`permissionResolution` feeds CEL `defaultValue`s, option `visibleWhen`
gates and formula fields, and `validation/rule-validator.ts` feeds
validation rules (`permissions:` on `evaluateValidationRules`). **None
supplies `systemPermissions` today**, so `hasCapability` in a
server-evaluated predicate throws loudly until a binder supplies it. The
page surface's binder is objectui's (the objectui card the ruling
filed), not this PR.
## Verification
All on head `d347ecd10` (after merging `origin/main` at `ec951e29e`)
unless noted.
- `pnpm --filter @objectstack/formula test`: 48 files, 1344 tests
passed. New file `src/capability-predicate.test.ts` pins held / not held
/ empty list / list absent (throws) / undeclared name is just a string /
every ADR-0068 alias / off-shape list / wrong receiver / non-string name
/ bare call / existence oracle (with an invented-sibling control) /
`validateExpression('predicate', …)` ok / the `can` message pointer.
- `pnpm --filter @objectstack/lint test`: 145 files, 6867 tests passed
(the new page-predicate pin included).
- typecheck exit 0: `@objectstack/formula`, `@objectstack/lint`,
`@objectstack/spec`, and the subject type's consumers
`@objectstack/core`, `@objectstack/metadata-protocol`,
`@objectstack/objectql`, `@objectstack/plugin-security`.
- Reverse verification of the cross-package type (consumer reads the
rebuilt `.d.ts`): a scratch file in `packages/objectql/src` with
`systemPermissions: [1]` gave `TS2322: Type 'number' is not assignable
to type 'string'`, the string form compiled; file removed, tree clean.
- Ablation (lint pin reads formula through `dist/`): the registered
signature renamed to `hasCapabilityZZ` via
`scripts/ablation-replace.mjs` (anchor 1 to 0, blob `83bc57b0907c` to
`463e76c9182b`), formula rebuilt, `ablation-dist-preflight` marker
present in 2 built files, lint pin **red** (1 failed / 195). Restored
(blob equal to HEAD, `git diff HEAD` empty), rebuilt, preflight
`--absent` over 6 built files, pin green (196 passed).
- `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up
to date.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived **112** commands on
`d347ecd10`; every line run, exit captured before any pipe; **112 exit
0**. `--ran` reconciliation: "112 derived, 112 run, 0 NOT-MEASURED, 0
UNRUN" (a derived zero, every line carried its exit code). Two families
first exited 3 (prerequisite) on the earlier head and were built for and
re-run, not left unmeasured: `check:skill-examples` (built
`@objectstack/client-react`) and `check:dual-build-cjs-loads` (full
`turbo run build`). History deepened first
(`--shallow-since=2026-07-13`).
- Not run locally: `test:repo` (CI's `Test Core`); the repo-wide `pnpm
lint` (CI).
## Acceptance notes
- **The runtime page save door does not run the visibility rule.**
`validateVisibilityPredicates` declares `runtimeTypes: ['view']`, so
`runtimeAuthoringRulesFor('page')` is `validatePresetComparands,
validateComponentProps, validateComponentTypes,
validatePrintPageBlocks`; a lint-level probe of those four on a page
`visibleWhen` of `totallyBogusFn(1)` drew no function-existence finding.
So the door this PR admits the predicate at is the CLI one (`os
validate` / `os lint` / `os build`, `commands: ALL`), which is what the
pin measures. Not measured through a booted HTTP save.
- `skills/objectstack-formula/SKILL.md` is not edited (it documents
bare-callable catalog functions; `can` is not in it either, and
`skills/**` is a Tier H surface). `hasCapability` is receiver-only, so
the catalog drift pin (bare-callables) does not move.
- objectui's binding of `systemPermissions` into page predicates is the
objectui card the ruling filed; until it lands, a page `visibleWhen`
calling `hasCapability` faults and the component renders (fail-open,
stated in the describe).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01AQxxbsMRiqgkrsU3Xd54yE
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AQxxbsMRiqgkrsU3Xd54yE)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7e58e0a commit ca426cb
9 files changed
Lines changed: 313 additions & 17 deletions
File tree
- .changeset
- content/docs/references/ui
- packages
- formula/src
- lint/src
- spec/src/ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
| 314 | + | |
315 | 315 | | |
316 | 316 | | |
317 | 317 | | |
| |||
483 | 483 | | |
484 | 484 | | |
485 | 485 | | |
486 | | - | |
| 486 | + | |
487 | 487 | | |
488 | 488 | | |
489 | 489 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
0 commit comments