Skip to content

Commit 916531a

Browse files
feat(spec): an action can show the reader already pressed it (pressed, a per-reader CEL predicate) (#28235)
domain:spec#2 · os-tesla · session_01L9qibx5r5nhDMwpRs4SL6D <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoC2hwmtQyR6xez7eocpWFkd)_ Fixes #27946 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up #28243 (skills/objectstack-ui/rules/actions.md, to land with the console half #28242) ## 维护者速读 - 改了什么:动作(action)新增一个可选键 `pressed`,是一条 CEL 条件(作用域同 `visible`:当前记录和当前读者),为真时按钮画成"已按下"(`aria-pressed`),用来告诉读者"你已经投过有用"。 - 为什么改:HotCRM 知识文章头部的「有用 / 没用」按钮无法显示读者自己已投的票,现有动作契约里没有任何"按读者状态"的键。 - 风险与回滚:只加一个可选键,不填时解析和行为完全不变;只影响显示,不改动作是否可见、可点、可执行。布尔字面量会被拒(常量不是读者状态)。回滚即 revert 本 PR。本 PR 只是契约这一半,控制台还不画按下态,liveness 记 `planned` 并对作者告警;画法是后续 ui 卡。 - 先看:`packages/spec/src/ui/action.zod.ts` 中紧跟 `disabledReason` 的 `pressed` 声明,`packages/lint/src/validate-expressions.ts` 的 `checkAction` 与 `SERVED_ROW_SITES`,`ui/packages/core/src/actions/actionKeys.ts` 唯一的一行清单。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/ui/action-pressed.test.ts`,预期 12 条全绿。 - 需要你做的:无,席位落地。 ## What changes A record page header action the reader toggles (HotCRM's `mark_article_helpful` / `mark_article_not_helpful`) could not tell the reader they already acted. This is the spec half of the card: the key, its expression lint, its docs and its liveness grade. - **Spec (`packages/spec/src/ui/action.zod.ts`).** `ActionSchema` declares `pressed: EvaluatedExpressionInputSchema.optional()`, placed after `disabledReason`. A CEL string normalizes to `{ dialect: 'cel', source }`; the envelope is accepted as written. TRUE = the surface draws the button pressed (`aria-pressed="true"`); FALSE or absent = a plain button. Display only: it never gates, greys or routes the action. `active`, `activeWhen` and `pressedWhen` are renamed onto it by the strict-object alias table. - **Lint (`packages/lint/src/validate-expressions.ts`).** `checkAction` judges `pressed` in the `record` scope exactly as it judges `visible` (a bare field reference is an error, unknown fields are refused), at the build (`os build` / `os validate`) and at the object save door. `'action pressed'` joins `SERVED_ROW_SITES`, so a block the object declares under `attachedOnRead` resolves there and a misspelt leaf is refused. - **ui inventory (`ui/packages/core/src/actions/actionKeys.ts`, one line, seat ruling A in claim amendment 6528737693).** `SPEC_ACTION_KEYS` lists `pressed`, inventory only, the `description` / `execution` precedent. `actionKeys.pin.test.ts` (Test UI) compares that array with the spec `ActionSchema` keys, so any new spec key reds Test UI without this line. `ActionDef` is untouched and nothing under `ui/` reads the key. - **Liveness.** `packages/spec/liveness/action.json` row `pressed`: `planned`, `evidenceScope: in-repo`, `authorWarn` with an `authorHint` (measured: `lintLivenessPropertiesFromLedgerDir` emits `liveness-planned-property` on an action that sets it). `liveness/object.json` `attachedOnRead` evidence and the `ObjectSchema.attachedOnRead` TSDoc name `pressed` among the served-row sites. - **Docs.** `content/docs/protocol/objectui/actions.mdx` gains "The reader's own state: `pressed`" beside `disabledReason`, with a callout that the console does not draw it yet. - **Ledgers held in step.** The expression-surface ledger (`packages/qa/dogfood/test/expression-conformance.ledger.ts`) gains `cel-action-pressed` (`experimental`, `unevaluated`, PARSE ONLY / NO EVALUATOR FOUND); the evaluated-slot census (`evaluated-slot-population.test.ts`) goes 31 → 32 positions; the metadata form reconciliation records `action.pressed` as a planned-not-rendered `omit`; the lint key-read pin (#5017 meta-test) lists `pressed`. - **Generated.** `content/docs/references/ui/action.mdx`, `data/object.mdx`, `kernel/metadata-plugin.mdx`, `authorable-surface/ui.json`, `liveness/state-counts/action.md`. - **Changesets.** `.changeset/27946-action-pressed.md` (`@objectstack/spec` minor, `@objectstack/lint` minor, both declaration lines) and `.changeset/27946-action-keys-pressed.md` (`@object-ui/core` patch, `Clause-②: no`, `Metadata-protocol: none`, the 26619 inventory precedent). ### The shape chosen, and the alternatives not taken - **Key name: `pressed`, not `active`.** `pressed` is the toggle-button word the reader of the key implements (`aria-pressed`), and it says nothing about access; `active` reads as a status or an enabled flag beside `visible` / `disabled`. `active` is kept as a rename so the card's other spelling lands on the key. - **No boolean arm (unlike `visible` / `disabled`).** A literal `true` draws every reader's button pressed on every record, which is a static style, not a reader's state; it is refused at parse with the evaluated-slot sentence. Alternative not taken: reuse `ActionConditionInputSchema` for symmetry. - **Where it is evaluated: on the client, by the action surface, against the row it fetched**, the same evaluator and scope as `visible`. Alternative not taken: a server-computed pressed flag on the action (a new wire field per action per row) — it would need a second contract for what the row-level block already carries. - **How it reads the reader's own vote: an `attachedOnRead` block, not a record field and not a related-record lookup.** Measured: the HotCRM vote is a per-user child row (`crm_article_feedback`, counted in `hotcrm` `src/service/actions/knowledge_article.actions.ts`), so no column of the article can hold it, and CEL in an action predicate has no related-record lookup. What it CAN read today: `ObjectSchema.attachedOnRead` declares a per-caller block on served rows, and an app `afterFind` hook may shape the rows it returns (`packages/objectql/src/verb-hook-result-shape.ts`: shaping stays legal), so the article can carry `viewer: { vote }` for the caller and the predicate reads `has(record.viewer) && record.viewer.vote == 'helpful'`. The lint half makes that read checkable. Alternative not taken: a CEL related-record function (a new evaluator capability on every surface, with its own fetch). ## Tests - `packages/spec/src/ui/action-pressed.test.ts` (new, 12 cases): a CEL string normalizes to the envelope; the envelope with `meta` is kept; absent stays absent; `true` / `false` refused on the key's path; a blank string, a blank `source` and an `ast`-only envelope refused with `EVALUATED_EXPRESSION_SOURCE_REQUIRED`; `active`, `activeWhen`, `pressedWhen` refused with a rename to `pressed`; an object declaring `attachedOnRead: { viewer: { vote } }` with a `pressed` action parses. - `packages/lint/src/validate-expressions.test.ts`: a bare-field `pressed` is one error located at `action '…' pressed`; the `record.`-qualified form passes. The #5017 key-read pin lists `pressed`. - `packages/lint/src/validate-expressions.attached-on-read.test.ts`: `pressed` is a served-row site — a declared leaf passes, a misspelt leaf is refused naming `reader.vot`, and with the block undeclared the read is the unknown field it is at run time. - `packages/spec/src/shared/evaluated-slot-population.test.ts`: `actionObject.pressed` joins the census (32 positions), refusing the three blank shapes with the one sentence. ## Acceptance notes - The console half is a follow-up `domain:ui` card, carried by the seat: `page:header` buttons and `DeclaredActionsBar` evaluate `pressed` and draw `aria-pressed` with a pressed face; `ActionDef` derives the key; the liveness row flips `planned` → `live` with `authorWarn` / `authorHint` dropped in the same edit; the expression ledger row is re-stated `enforced` with its fail face (an unevaluable predicate draws a plain button); the docs callout goes. - The HotCRM producer is a later card in that repo: declare `attachedOnRead: { viewer: { vote: 'text' } }` on `crm_knowledge_article`, attach it in an `afterFind` hook from the caller's `crm_article_feedback` row, and set `pressed` on both vote actions. Note for that card: the hook runs on every read path of the article (grid, search, lookups), so it should batch one feedback query per read. - The Studio action form does not offer the key yet, on purpose (the planned-not-rendered omit); the console card decides the row. ## Verification <details><summary>tests, typecheck, ablation</summary> - `packages/spec` (under the verify lock, before the merge of `origin/main`): `vitest run src/ui/ src/shared/evaluated-slot-population.test.ts src/system/metadata-form-zod-reconciliation.test.ts src/kernel src/data/object` → 235 files, 7705 passed, 1 failed (the form reconciliation asking for `action.pressed`); after the planned-not-rendered `omit` row, that file and `action-pressed.test.ts` → 2/2 files, 88/88 tests. - `packages/lint`: `vitest run src/validate-expressions src/lint-liveness-properties src/runtime-gate.object-action-predicate-writes.test.ts` → 19/19 files, 864/864 tests (first run red only on the #5017 key-read pin, now listing `pressed`). - `ui`: `pnpm exec vitest run packages/core/src/actions/__tests__/actionKeys.pin.test.ts` from `ui/` → 1/1 file, 19/19 tests, against the spec dist rebuilt with `pressed`. - `packages/qa/dogfood`: `test/expression-conformance.test.ts` → 1/1 file, 8/8 tests. - `pnpm --filter @objectstack/spec --filter @objectstack/lint typecheck` at `64d80fe0`: lock VERDICT command-exit 0. - Liveness author warning, measured with `lintLivenessPropertiesFromLedgerDir`: an action setting `pressed` gets one `liveness-planned-property` finding carrying the row's `authorHint`. - Ablation (`scripts/ablation-replace.mjs`, lint tests read `src`): deleting the `checkAction` line that judges `pressed`, anchor 1 → 0 on disk; `-t pressed` over both lint files → 2 failed, 456 skipped; restored, blob == HEAD and `git diff HEAD` empty. </details> <details><summary>generated artefacts and the merge</summary> - After a dts build of `./packages/**` + the dogfood closure (76/76 tasks), `check:generated` named `content/docs/references/**` and `liveness/state-counts/` stale; `--fix` regenerated both, re-check 14/14. - Merged `origin/main` `3739d8d6` (3 commits, no overlap with this diff, clean); the merge driver deferred `authorable-surface/ui.json` and `references/data/object.mdx`; after a second full build `check:generated --fix` rewrote them from the merged tree (main's `KanbanConfig.lanes` / `ReportChart.seriesBy` rows), commit `64d80fe0`, marker cleared. </details> <details><summary>derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `64d80fe0`: 107 families, each run with stdin from `/dev/null`, exit code recorded before any pipe (record: `27946-gates-ran-64d80fe0.list` in the seat's out folder). `check-plugin-teardown-shape --self-test`'s pinned control `621a4876` was fetched into the shallow clone first. - `--ran` reconciliation: 107 derived, 107 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvXVNub8x3B5XhGFrP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4159c70 commit 916531a

21 files changed

Lines changed: 305 additions & 19 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@object-ui/core': patch
3+
---
4+
5+
`SPEC_ACTION_KEYS` lists `pressed`, the per-reader pressed-state predicate `@objectstack/spec` added to `ActionSchema`. An action that carries it is no longer counted as having an unknown key, and the inventory pin stays green. No surface reads the key yet.
6+
7+
Clause-②: no
8+
Metadata-protocol: none

‎.changeset/27946-action-pressed.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec): an action can show the reader already pressed it — `pressed`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
`ActionSchema` gains one optional key, `pressed`: a CEL predicate over the same scope as `visible` (the record and the reader) that says this reader already acted, such as a "Helpful" vote on a knowledge article. While it evaluates TRUE an action surface draws the button pressed (`aria-pressed`). An action that does not declare it parses exactly as before.
12+
13+
```ts
14+
defineAction({
15+
name: 'mark_article_helpful',
16+
label: 'Helpful',
17+
objectName: 'kb_article',
18+
type: 'script',
19+
target: 'markHelpful',
20+
locations: ['record_header'],
21+
pressed: "has(record.viewer) && record.viewer.vote == 'helpful'",
22+
});
23+
```
24+
25+
- **A CEL predicate, not a boolean.** A string is normalized to the `{ dialect: 'cel', source }` envelope, and the envelope is accepted as written. `pressed: true` / `false` is refused at parse: a constant draws every reader's button the same way, which is a style and not the reader's state. The blank and `ast`-only shapes every evaluated slot refuses are refused here too. `active`, `activeWhen` and `pressedWhen` are renamed onto it.
26+
- **Display only.** It never gates, greys or routes the action. A pressed action stays runnable, and its own body decides what a second press does.
27+
- **The reader's own state.** A per-reader fact is usually a child row (one vote per user), not a column. Declare it as a block under the object's `attachedOnRead`, attach it on the serving side for the caller (a service, or an `afterFind` hook that shapes the rows it returns), and read `record.<block>.<leaf>` behind `has()`.
28+
- **Checked at build (`@objectstack/lint`).** `os build`, `os validate` and the object save door judge the predicate in the `record` scope, as they judge `visible`: a bare field reference is an error. An action's `pressed` is a served-row site, so a block the object declares under `attachedOnRead` resolves there and a misspelt leaf is refused.
29+
30+
Nothing draws the pressed face yet. The console's record header buttons and declared actions bar read the key in a follow-up change. Until then the liveness ledger marks it `planned` with `authorWarn`, so `os validate` warns an author who declares it that the button looks the same whatever the predicate answers.

‎content/docs/protocol/objectui/actions.mdx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ interface Action {
246246
requiresFeature?: string; // Public auth feature flag gate — lowered into `visible` at parse time
247247
disabled?: boolean | ExpressionInput; // Disabled when TRUE (CEL)
248248
disabledReason?: I18nLabel; // Why it is greyed — hover title + accessible description (display only)
249+
pressed?: ExpressionInput; // Drawn as already pressed for this reader when TRUE (CEL; display only)
249250

250251
// Bulk
251252
bulkEnabled?: boolean; // Apply to multiple selected records
@@ -349,6 +350,26 @@ disabledReason: An approved legal review is required before the contract enters
349350
- A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep the default.
350351
- It is **display only**. It does not change what `disabled` evaluates to, nothing reads it as a condition, and it is not access control: a call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text.
351352

353+
#### The reader's own state: `pressed`
354+
355+
An action the reader toggles, such as a "Helpful" vote on a knowledge article, says the reader **already acted** with `pressed`, a CEL predicate over the same scope as `visible`. While it is true the button is drawn pressed (`aria-pressed="true"`).
356+
357+
```yaml
358+
name: mark_article_helpful
359+
label: Helpful
360+
type: script
361+
locations: [record_header]
362+
pressed: "has(record.viewer) && record.viewer.vote == 'helpful'"
363+
```
364+
365+
- It is **display only**. A pressed action stays runnable, and the action's own body decides what a second press does (records the vote again, withdraws it, or answers "already").
366+
- A boolean literal is refused: `pressed: true` would draw the button pressed for every reader, which is a style rather than the reader's state.
367+
- A per-reader fact is usually not a column of the row (a vote is a child record per user). Declare it as a block under the object's `attachedOnRead`, have the serving side attach it to each row it returns for the caller (a service, or an `afterFind` hook that shapes the rows), and read it as `record.<block>.<leaf>`, guarded with `has()`. `os build` and `os validate` check the leaf against the declared block.
368+
369+
<Callout type="info">
370+
The spec, the build check and this contract are in place; the console's record header and declared actions bar do not draw the pressed face yet ([#28242](https://github.com/objectstack-ai/objectstack/issues/28242)), so today the button looks the same whatever the predicate answers.
371+
</Callout>
372+
352373
#### Evaluation context
353374

354375
Action predicates evaluate against the **record the action is attached to**. Both `record.<field>` and the bare field name resolve to the current record's value:

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,7 @@ const result = ApiMethod.parse(data);
503503
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
504504
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
505505
| **disabledReason** | `string \| Record<string, string>` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. |
506+
| **pressed** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Pressed-state predicate (CEL string or `{dialect, source}` envelope) over the same scope as `visible`: the action surface draws the button as already pressed for this reader (a toggle face, `aria-pressed`) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under `attachedOnRead`, as `has(record.viewer) && record.viewer.vote == 'helpful'`. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. |
506507
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side. |
507508
| **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record<string, object>; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. |
508509
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |

‎content/docs/references/kernel/metadata-plugin.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ const result = MetadataBulkResultSchema.parse(data);
347347
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
348348
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
349349
| **disabledReason** | `string \| Record<string, string>` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. |
350+
| **pressed** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Pressed-state predicate (CEL string or `{dialect, source}` envelope) over the same scope as `visible`: the action surface draws the button as already pressed for this reader (a toggle face, `aria-pressed`) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under `attachedOnRead`, as `has(record.viewer) && record.viewer.vote == 'helpful'`. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. |
350351
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side. |
351352
| **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record<string, object>; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. |
352353
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |

‎content/docs/references/ui/action.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const result = ActionSchema.parse(data);
5757
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
5858
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
5959
| **disabledReason** | `string \| Record<string, string>` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. |
60+
| **pressed** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Pressed-state predicate (CEL string or `{dialect, source}` envelope) over the same scope as `visible`: the action surface draws the button as already pressed for this reader (a toggle face, `aria-pressed`) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under `attachedOnRead`, as `has(record.viewer) && record.viewer.vote == 'helpful'`. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. |
6061
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side. |
6162
| **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record<string, object>; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. |
6263
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |

‎packages/lint/src/validate-expressions.attached-on-read.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*
77
* A read attachment is a block a service sets on each row it serves, computed
88
* per caller and never stored. At a served-row site (`SERVED_ROW_SITES`: an
9-
* action's `visible` and `disabled`), its block name joins the names
9+
* action's `visible`, `disabled` and `pressed`), its block name joins the names
1010
* `record.<x>` resolves to, and the second segment of `record.<block>.<leaf>`
1111
* is judged against the block's declared leaves — under the EXISTING
1212
* `unknown-field` refusal, with no new rule and nothing keyed on a block's
@@ -205,6 +205,28 @@ describe('attachedOnRead — record.<block>.<leaf> at a served-row site', () =>
205205
expect(issues[0].message).toContain('unknown field `viewer.can_actt` on `approval_probe`');
206206
});
207207

208+
it('an action `pressed` predicate is a served-row site too — the per-reader read it exists for', () => {
209+
// The worked case: a reader's own vote is a child row per user, so it
210+
// reaches the predicate only as a block the serving side attaches per
211+
// caller. `has()` guards a row fetched by another route.
212+
const VOTE = { vote: 'text' } as const;
213+
const pressedIssuesFor = (pressed: string, declared: boolean) => validateStackExpressions(
214+
specValid({
215+
objects: [{ ...probe(false), ...(declared ? { attachedOnRead: { reader: VOTE } } : {}) }],
216+
actions: [{ name: 'mark_helpful', label: 'Helpful', objectName: 'approval_probe', type: 'script', target: 'fn', pressed }],
217+
}),
218+
);
219+
expect(pressedIssuesFor("has(record.reader) && record.reader.vote == 'helpful'", true)).toEqual([]);
220+
const misspelt = pressedIssuesFor("has(record.reader) && record.reader.vot == 'helpful'", true);
221+
expect(misspelt).toHaveLength(1);
222+
expect(misspelt[0].where).toBe("stack · action 'mark_helpful' pressed");
223+
expect(misspelt[0].message).toContain('unknown field `reader.vot` on `approval_probe`');
224+
// Control: undeclared, the block is the unknown field it is at run time.
225+
const undeclared = pressedIssuesFor("has(record.reader) && record.reader.vote == 'helpful'", false);
226+
expect(undeclared.map((i) => [i.where, i.severity])).toContainEqual(["stack · action 'mark_helpful' pressed", 'error']);
227+
expect(undeclared.some((i) => i.message.includes('unknown field `reader` on `approval_probe`'))).toBe(true);
228+
});
229+
208230
it('an object without the key keeps today’s verdict (control)', () => {
209231
const issues = issuesFor('record.viewer.can_act', false);
210232
expect(issues).toHaveLength(1);

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,25 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
926926
expect(ok).toHaveLength(0);
927927
});
928928

929+
it('flags a bare-field `pressed` predicate and accepts the record-qualified form (#27946)', () => {
930+
// Same record scope and fault as `visible`: a bare `vote` throws at key
931+
// resolution, so the button would never be drawn pressed.
932+
const bad = validateStackExpressions({
933+
objects: [{ name: 'kb_article', fields: { my_vote: { type: 'text' } } }],
934+
actions: [{ name: 'mark_helpful', objectName: 'kb_article', type: 'script', target: 'fn', pressed: 'my_vote == "helpful"' }],
935+
});
936+
const pressedIssues = bad.filter(i => i.where.includes("action 'mark_helpful' pressed"));
937+
expect(pressedIssues).toHaveLength(1);
938+
expect(pressedIssues[0].severity).toBe('error');
939+
expect(pressedIssues[0].message).toMatch(/bare reference `my_vote`/);
940+
941+
const ok = validateStackExpressions({
942+
objects: [{ name: 'kb_article', fields: { my_vote: { type: 'text' } } }],
943+
actions: [{ name: 'mark_helpful', objectName: 'kb_article', type: 'script', target: 'fn', pressed: 'record.my_vote == "helpful"' }],
944+
});
945+
expect(ok).toHaveLength(0);
946+
});
947+
929948
it('never reads `disabledReason` as a predicate — it is display copy (#26619)', () => {
930949
// The same bare-field source that is an error on `disabled` above is a
931950
// plain sentence on `disabledReason`: nothing is checked, nothing flagged.
@@ -3390,7 +3409,7 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
33903409
// `object` is absent — the action schema's canonical key is `objectName`.
33913410
// `disabledReason` is absent by contract (#26619): display copy beside
33923411
// `disabled`, never read as a condition.
3393-
expected: ['disabled', 'name', 'objectName', 'visible'],
3412+
expected: ['disabled', 'name', 'objectName', 'pressed', 'visible'],
33943413
declaredBy: 'ObjectStackSchema.actions[]',
33953414
keys: () => shapeKeysOf(ObjectStackSchema.shape.actions),
33963415
},

0 commit comments

Comments
 (0)