Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/27946-action-keys-pressed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/core': patch
---

`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.

Clause-②: no
Metadata-protocol: none
30 changes: 30 additions & 0 deletions .changeset/27946-action-pressed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

feat(spec): an action can show the reader already pressed it — `pressed`

Clause-②: yes (widening)
Metadata-protocol: added

`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.

```ts
defineAction({
name: 'mark_article_helpful',
label: 'Helpful',
objectName: 'kb_article',
type: 'script',
target: 'markHelpful',
locations: ['record_header'],
pressed: "has(record.viewer) && record.viewer.vote == 'helpful'",
});
```

- **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.
- **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.
- **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()`.
- **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.

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.
21 changes: 21 additions & 0 deletions content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ interface Action {
requiresFeature?: string; // Public auth feature flag gate — lowered into `visible` at parse time
disabled?: boolean | ExpressionInput; // Disabled when TRUE (CEL)
disabledReason?: I18nLabel; // Why it is greyed — hover title + accessible description (display only)
pressed?: ExpressionInput; // Drawn as already pressed for this reader when TRUE (CEL; display only)

// Bulk
bulkEnabled?: boolean; // Apply to multiple selected records
Expand Down Expand Up @@ -349,6 +350,26 @@ disabledReason: An approved legal review is required before the contract enters
- A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep the default.
- 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.

#### The reader's own state: `pressed`

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"`).

```yaml
name: mark_article_helpful
label: Helpful
type: script
locations: [record_header]
pressed: "has(record.viewer) && record.viewer.vote == 'helpful'"
```

- 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").
- 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.
- 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.

<Callout type="info">
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.
</Callout>

#### Evaluation context

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:
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ const result = ApiMethod.parse(data);
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ const result = MetadataBulkResultSchema.parse(data);
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const result = ActionSchema.parse(data);
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |
Expand Down
24 changes: 23 additions & 1 deletion packages/lint/src/validate-expressions.attached-on-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*
* A read attachment is a block a service sets on each row it serves, computed
* per caller and never stored. At a served-row site (`SERVED_ROW_SITES`: an
* action's `visible` and `disabled`), its block name joins the names
* action's `visible`, `disabled` and `pressed`), its block name joins the names
* `record.<x>` resolves to, and the second segment of `record.<block>.<leaf>`
* is judged against the block's declared leaves — under the EXISTING
* `unknown-field` refusal, with no new rule and nothing keyed on a block's
Expand Down Expand Up @@ -205,6 +205,28 @@ describe('attachedOnRead — record.<block>.<leaf> at a served-row site', () =>
expect(issues[0].message).toContain('unknown field `viewer.can_actt` on `approval_probe`');
});

it('an action `pressed` predicate is a served-row site too — the per-reader read it exists for', () => {
// The worked case: a reader's own vote is a child row per user, so it
// reaches the predicate only as a block the serving side attaches per
// caller. `has()` guards a row fetched by another route.
const VOTE = { vote: 'text' } as const;
const pressedIssuesFor = (pressed: string, declared: boolean) => validateStackExpressions(
specValid({
objects: [{ ...probe(false), ...(declared ? { attachedOnRead: { reader: VOTE } } : {}) }],
actions: [{ name: 'mark_helpful', label: 'Helpful', objectName: 'approval_probe', type: 'script', target: 'fn', pressed }],
}),
);
expect(pressedIssuesFor("has(record.reader) && record.reader.vote == 'helpful'", true)).toEqual([]);
const misspelt = pressedIssuesFor("has(record.reader) && record.reader.vot == 'helpful'", true);
expect(misspelt).toHaveLength(1);
expect(misspelt[0].where).toBe("stack · action 'mark_helpful' pressed");
expect(misspelt[0].message).toContain('unknown field `reader.vot` on `approval_probe`');
// Control: undeclared, the block is the unknown field it is at run time.
const undeclared = pressedIssuesFor("has(record.reader) && record.reader.vote == 'helpful'", false);
expect(undeclared.map((i) => [i.where, i.severity])).toContainEqual(["stack · action 'mark_helpful' pressed", 'error']);
expect(undeclared.some((i) => i.message.includes('unknown field `reader` on `approval_probe`'))).toBe(true);
});

it('an object without the key keeps today’s verdict (control)', () => {
const issues = issuesFor('record.viewer.can_act', false);
expect(issues).toHaveLength(1);
Expand Down
21 changes: 20 additions & 1 deletion packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,25 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
expect(ok).toHaveLength(0);
});

it('flags a bare-field `pressed` predicate and accepts the record-qualified form (#27946)', () => {
// Same record scope and fault as `visible`: a bare `vote` throws at key
// resolution, so the button would never be drawn pressed.
const bad = validateStackExpressions({
objects: [{ name: 'kb_article', fields: { my_vote: { type: 'text' } } }],
actions: [{ name: 'mark_helpful', objectName: 'kb_article', type: 'script', target: 'fn', pressed: 'my_vote == "helpful"' }],
});
const pressedIssues = bad.filter(i => i.where.includes("action 'mark_helpful' pressed"));
expect(pressedIssues).toHaveLength(1);
expect(pressedIssues[0].severity).toBe('error');
expect(pressedIssues[0].message).toMatch(/bare reference `my_vote`/);

const ok = validateStackExpressions({
objects: [{ name: 'kb_article', fields: { my_vote: { type: 'text' } } }],
actions: [{ name: 'mark_helpful', objectName: 'kb_article', type: 'script', target: 'fn', pressed: 'record.my_vote == "helpful"' }],
});
expect(ok).toHaveLength(0);
});

it('never reads `disabledReason` as a predicate — it is display copy (#26619)', () => {
// The same bare-field source that is an error on `disabled` above is a
// plain sentence on `disabledReason`: nothing is checked, nothing flagged.
Expand Down Expand Up @@ -3390,7 +3409,7 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
// `object` is absent — the action schema's canonical key is `objectName`.
// `disabledReason` is absent by contract (#26619): display copy beside
// `disabled`, never read as a condition.
expected: ['disabled', 'name', 'objectName', 'visible'],
expected: ['disabled', 'name', 'objectName', 'pressed', 'visible'],
declaredBy: 'ObjectStackSchema.actions[]',
keys: () => shapeKeysOf(ObjectStackSchema.shape.actions),
},
Expand Down
Loading
Loading