diff --git a/.changeset/27946-action-keys-pressed.md b/.changeset/27946-action-keys-pressed.md new file mode 100644 index 00000000000..b8c57cdcf83 --- /dev/null +++ b/.changeset/27946-action-keys-pressed.md @@ -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 diff --git a/.changeset/27946-action-pressed.md b/.changeset/27946-action-pressed.md new file mode 100644 index 00000000000..8d57ed63411 --- /dev/null +++ b/.changeset/27946-action-pressed.md @@ -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..` 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. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 43222d3d850..7a92822ea6b 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -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 @@ -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..`, guarded with `has()`. `os build` and `os validate` check the leaf against the declared block. + + +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. + + #### Evaluation context Action predicates evaluate against the **record the action is attached to**. Both `record.` and the bare field name resolve to the current record's value: diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index d1cd1ad59a5..48f1ad80b82 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -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` | 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; … }` | 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. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 851a7e3cbfa..0e75b3dc09a 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -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` | 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; … }` | 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. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 94749085a6d..413e06843da 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -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` | 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; … }` | 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. | diff --git a/packages/lint/src/validate-expressions.attached-on-read.test.ts b/packages/lint/src/validate-expressions.attached-on-read.test.ts index c29777e2885..2097f15e423 100644 --- a/packages/lint/src/validate-expressions.attached-on-read.test.ts +++ b/packages/lint/src/validate-expressions.attached-on-read.test.ts @@ -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.` resolves to, and the second segment of `record..` * 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 @@ -205,6 +205,28 @@ describe('attachedOnRead — record.. 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); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 0b89a6a369e..1a4c1cefc51 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -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. @@ -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), }, diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 92208b4529b..4752918c9c5 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -278,8 +278,8 @@ function buildAttachedOnReadIndex(objects: AnyRec[]): Map` resolves to); packages/formula/src/validate.ts#checkAttachedLeaf (the second segment of `record..` judged against the block's declared leaves, under the existing `unknown-field` refusal)", + "evidence": "packages/lint/src/validate-expressions.ts#attachedOnReadOf (the read of `obj.attachedOnRead`: block name → its leaf keys); packages/lint/src/validate-expressions.ts#SERVED_ROW_SITES (the sites whose `record` is a row a service serves, an action's `visible`, `disabled` and `pressed`: the only sites where each declared block name joins the names `record.` resolves to); packages/formula/src/validate.ts#checkAttachedLeaf (the second segment of `record..` judged against the block's declared leaves, under the existing `unknown-field` refusal)", "producer": "packages/lint/src/validate-expressions.ts#runStackExpressionPasses (threads the per-object index into the field-existence set and the shared validator's `attachedOnRead` hint at the served-row sites only: the predicate `check` closure does so for a call that names a `SERVED_ROW_SITES` entry, which only the action pass does; every other site, the field-formula judge included, resolves the object's columns alone)", - "note": "Maintainer ruling record 6070963704. `live` on an AUTHORING consumer, the `action.execution` / `dashboard.widgets.suppressWarnings` precedent: the key provisions nothing and changes no dispatch by itself — it is the declaration the shared build validator (`@objectstack/lint` over `@objectstack/formula`) judges `record.` and `record..` against, so `os build` / `os validate` and every door running the shared validator accept a declared leaf and refuse a misspelt one; deleting the key would delete that verdict. The ruling defines a block as attached to the rows a service SERVES, computed per caller and never stored, so the validator reads it only where `record` is such a row: an action's `visible` and `disabled` (`SERVED_ROW_SITES`). A flow condition, a validation rule, a field-rule slot, an option `visibleWhen`, a field formula, a sharing rule and a hook bind the stored row, which never carries a block, and there `record.` is refused as an unknown field — the fault it is at run time. Two other field-existence builders read `fields` only and not this key — the MCP expression tool (packages/mcp) and service-automation's flow-registration resolver; the second binds a flow's stored row, where the fields-only verdict is the served-row reading's own. By the ruling NOT a field: no driver, form, list view, export, write path or translation bundle reads it, and the schema refuses a block that repeats a declared field name. The leaf TYPES (the four `Field.returnType` value types) are not read by this consumer, which judges names only: their reader is the ruling's second reader, plugin-approvals' conformance test (packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts), which pins that the keys `attachViewers` emits, and the runtime type of each emitted value, equal what `sys_approval_request` declares under `attachedOnRead.viewer` (three `boolean`s), for every caller shape it serves." + "note": "Maintainer ruling record 6070963704. `live` on an AUTHORING consumer, the `action.execution` / `dashboard.widgets.suppressWarnings` precedent: the key provisions nothing and changes no dispatch by itself — it is the declaration the shared build validator (`@objectstack/lint` over `@objectstack/formula`) judges `record.` and `record..` against, so `os build` / `os validate` and every door running the shared validator accept a declared leaf and refuse a misspelt one; deleting the key would delete that verdict. The ruling defines a block as attached to the rows a service SERVES, computed per caller and never stored, so the validator reads it only where `record` is such a row: an action's `visible`, `disabled` and `pressed` (`SERVED_ROW_SITES`). A flow condition, a validation rule, a field-rule slot, an option `visibleWhen`, a field formula, a sharing rule and a hook bind the stored row, which never carries a block, and there `record.` is refused as an unknown field — the fault it is at run time. Two other field-existence builders read `fields` only and not this key — the MCP expression tool (packages/mcp) and service-automation's flow-registration resolver; the second binds a flow's stored row, where the fields-only verdict is the served-row reading's own. By the ruling NOT a field: no driver, form, list view, export, write path or translation bundle reads it, and the schema refuses a block that repeats a declared field name. The leaf TYPES (the four `Field.returnType` value types) are not read by this consumer, which judges names only: their reader is the ruling's second reader, plugin-approvals' conformance test (packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts), which pins that the keys `attachViewers` emits, and the runtime type of each emitted value, equal what `sys_approval_request` declares under `attachedOnRead.viewer` (three `boolean`s), for every caller shape it serves." }, "datasource": { "status": "live", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 8e8f2660248..c19eb3cb863 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 48 | 0 | 0 | 1 | 0 | 49 | +| `action` | 48 | 0 | 0 | 1 | 1 | 50 | diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 3e50383e5b5..db16fd8f724 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2187,8 +2187,8 @@ const ATTACHED_ON_READ_NAME = /^[a-z_][a-z0-9_]*$/; * `@objectstack/formula`, the pair `os build` / `os validate` run, and no other * field-existence check. A block is on `record` only where `record` is a row * the declaring service SERVED, so the validator reads this key only at those - * sites: an action's `visible` and `disabled` predicates (`SERVED_ROW_SITES` - * in `@objectstack/lint`). There each declared block name joins the names + * sites: an action's `visible`, `disabled` and `pressed` predicates + * (`SERVED_ROW_SITES` in `@objectstack/lint`). There each declared block name joins the names * `record.` may resolve to, and `@objectstack/formula`'s field-existence * pass judges the SECOND segment of `record..` against the * block's declared leaves, under its existing `unknown-field` refusal — so diff --git a/packages/spec/src/shared/evaluated-slot-population.test.ts b/packages/spec/src/shared/evaluated-slot-population.test.ts index af98ad42cf3..d84c71a2aa9 100644 --- a/packages/spec/src/shared/evaluated-slot-population.test.ts +++ b/packages/spec/src/shared/evaluated-slot-population.test.ts @@ -191,11 +191,15 @@ function element(schema: unknown, ...path: string[]): z.ZodType { } /** - * The 31 declaring positions, keyed the way the ADR-0058 D7 conformance ledger + * The 32 declaring positions, keyed the way the ADR-0058 D7 conformance ledger * keys them (`file:Schema.field`). Re-derived by identity on this tree, ⛔ not - * inherited from the card: 29 declaring source lines, two of which are + * inherited from the card: 30 declaring source lines, two of which are * file-local alias consts mounting two slots each. * + * [#27946] It was 31 over 29 lines. `ui/action.zod.ts:actionObject.pressed` + * joined: the action's per-reader pressed-state predicate, a new evaluated + * position rather than a moved one. + * * [#18118] It was 36 over 34 lines. Two union members left with the CEL arms * they mounted — `system/metrics.zod.ts:ServiceLevelIndicatorSchema.successCriteria` * and `system/tracing.zod.ts:TraceSamplingConfigSchema.condition` — retired @@ -232,6 +236,8 @@ const POSITIONS: ReadonlyArray z.ZodType]> = [ // The two the file-local alias `ActionConditionInputSchema` mounts. ['ui/action.zod.ts:actionObject.visible', () => slot(ActionSchema, 'visible')], ['ui/action.zod.ts:actionObject.disabled', () => slot(ActionSchema, 'disabled')], + // [#27946] Mounted directly, no boolean arm: the reader's pressed state. + ['ui/action.zod.ts:actionObject.pressed', () => slot(ActionSchema, 'pressed')], ['ui/app.zod.ts:BaseNavItemSchema.visible', () => slot(ObjectNavItemSchema, 'visible')], ['ui/bulk-action.zod.ts:BulkActionDefSchema.visible', () => slot(BulkActionDefSchema, 'visible')], ['ui/component.zod.ts:PageTabsProps.visibleWhen', () => slot(element(PageTabsProps, 'items'), 'visibleWhen')], @@ -252,10 +258,10 @@ const POSITIONS: ReadonlyArray z.ZodType]> = [ ]; describe('every evaluated slot refuses the two shapes no engine can run', () => { - it('reaches exactly the 31 declaring positions the census enumerated', () => { + it('reaches exactly the 32 declaring positions the census enumerated', () => { // A position that stops being reachable must red here rather than fall out // of the table: that silent drop is the #17630 failure in another costume. - expect(POSITIONS.length).toBe(31); + expect(POSITIONS.length).toBe(32); for (const [key, get] of POSITIONS) { expect(() => get(), `unreachable: ${key}`).not.toThrow(); } @@ -296,7 +302,7 @@ describe('every evaluated slot refuses the two shapes no engine can run', () => }); it('CONTROL — the persistence contract is NOT narrowed and still accepts both shapes', () => { - // This is what makes the 31 `false`s above a reading. Ruling item 2: + // This is what makes the 32 `false`s above a reading. Ruling item 2: // `ExpressionSchema` / `ExpressionInputSchema` keep `source` OR `ast`. for (const schema of [ExpressionSchema, ExpressionInputSchema, PredicateInputSchema]) { expect(schema.safeParse(AST_ONLY).success).toBe(true); @@ -304,7 +310,7 @@ describe('every evaluated slot refuses the two shapes no engine can run', () => } }); - it('CONTROL — a healthy predicate still parses at every one of the 31 positions', () => { + it('CONTROL — a healthy predicate still parses at every one of the 32 positions', () => { // The narrowing removes accepted shapes and adds none. The settings-manifest // pair speaks its own closed non-CEL grammar (#7169), so it gets the // predicate that grammar accepts; every other slot gets CEL. diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index bc3220b14cb..c9d34e015f1 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -365,6 +365,16 @@ const LEDGER: ReadonlyArray = [ key: 'conditionalFormatting', why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints", }, + // #27946 — an action's pressed-state predicate lands spec-first the same + // way: `planned` in the liveness ledger, drawn by no action surface until + // the console card that draws `aria-pressed` on the header buttons. + { + kind: 'omit', + type: 'action', + path: ROOT_PATH, + key: 'pressed', + why: "planned, not yet rendered — the reader's pressed-state predicate is validated at parse (CEL only, no boolean literal) and judged at the authoring doors in the `record` scope, but no action surface draws the pressed face until the console's header buttons read it. A form control offered today would let an author write a predicate nothing shows", + }, // #27973 — the same declared-not-enforced shape: `percentDisplay` is the spec // half of a percent field's read-only face, `planned` in `liveness/field.json` // until the console's percent face reads it. diff --git a/packages/spec/src/ui/action-pressed.test.ts b/packages/spec/src/ui/action-pressed.test.ts new file mode 100644 index 00000000000..5084b4f30bb --- /dev/null +++ b/packages/spec/src/ui/action-pressed.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#27946 — `pressed` on the action contract, the spec half. +// +// A record header action the reader toggles (HotCRM's "Helpful" / "Not +// Helpful") could not show that the reader already acted. `pressed` is the +// per-reader predicate a surface draws as `aria-pressed`: CEL over the same +// scope as `visible`, display only. These pins hold the parse (both arms, the +// normalization), the refusals (a boolean literal, the blank shapes every +// evaluated slot refuses), the renames, and that a block declared under +// `attachedOnRead` is how a per-reader fact reaches it. The lint half is +// pinned in `packages/lint/src/validate-expressions.test.ts` and +// `validate-expressions.attached-on-read.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { ActionSchema } from './action.zod'; +import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '../shared/expression.zod'; +import { ObjectSchema } from '../data/object.zod'; + +/** Minimum legal registered action: a `script` needs a dispatch `target`. */ +const base = { + name: 'mark_article_helpful', + label: 'Helpful', + objectName: 'kb_article', + type: 'script', + target: 'markHelpful', + locations: ['record_header'], +} as const; + +const PRESSED = "has(record.viewer) && record.viewer.vote == 'helpful'"; + +const messages = (r: { success: boolean; error?: { issues: Array<{ message: string }> } }) => + r.success ? [] : (r.error?.issues ?? []).map((i) => i.message); + +describe('ActionSchema.pressed — the parse', () => { + it('normalizes a CEL string to the evaluated envelope', () => { + const result = ActionSchema.safeParse({ ...base, pressed: PRESSED }); + + expect(result.success).toBe(true); + expect(result.success && result.data.pressed).toEqual({ dialect: 'cel', source: PRESSED }); + }); + + it('accepts the `{ dialect, source }` envelope', () => { + const envelope = { dialect: 'cel', source: PRESSED, meta: { rationale: 'the reader voted Helpful' } }; + const result = ActionSchema.safeParse({ ...base, pressed: envelope }); + + expect(result.success).toBe(true); + expect(result.success && result.data.pressed).toEqual(envelope); + }); + + it('is optional — an action without it parses with no key', () => { + const result = ActionSchema.safeParse(base); + + expect(result.success).toBe(true); + expect(result.success && 'pressed' in result.data).toBe(false); + }); +}); + +describe('ActionSchema.pressed — the refusals', () => { + it.each([true, false])('refuses the boolean literal %s: a constant is a style, not a reader\'s state', (literal) => { + const result = ActionSchema.safeParse({ ...base, pressed: literal }); + + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((i) => i.path.join('.'))).toContain('pressed'); + }); + + it.each([ + ['a blank bare string', ' '], + ['a blank `source`', { dialect: 'cel', source: ' ' }], + ['an `ast`-only envelope', { dialect: 'cel', ast: { kind: 'literal', value: true } }], + ])('refuses %s with the evaluated-slot sentence', (_label, value) => { + const result = ActionSchema.safeParse({ ...base, pressed: value }); + + expect(result.success).toBe(false); + expect(messages(result)).toContain(EVALUATED_EXPRESSION_SOURCE_REQUIRED); + }); +}); + +describe('ActionSchema.pressed — the renames', () => { + it.each(['active', 'activeWhen', 'pressedWhen'])('`%s` is refused with a rename to `pressed`', (alias) => { + const result = ActionSchema.safeParse({ ...base, [alias]: PRESSED }); + + expect(result.success).toBe(false); + expect(messages(result).join('\n')).toContain('`pressed`'); + }); +}); + +describe('ActionSchema.pressed — a per-reader fact reaches it through `attachedOnRead`', () => { + it('the object declares the block the predicate reads, beside its own columns', () => { + // The vote is a child row per reader, never a column of the article; the + // serving side computes `viewer.vote` per caller and attaches it. + const article = ObjectSchema.safeParse({ + name: 'kb_article', + label: 'Article', + fields: { title: { type: 'text', label: 'Title' } }, + attachedOnRead: { viewer: { vote: 'text' } }, + actions: [{ ...base, pressed: PRESSED }], + }); + + expect(article.success).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 2c94d856d73..b4bf92d45c4 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -953,6 +953,9 @@ const actionObject = () => strictObject({ // by the copy keys' `*Message` family. disabledMessage: 'disabledReason', disabledTooltip: 'disabledReason', disabledHint: 'disabledReason', disabledText: 'disabledReason', + // [#27946] The card's own second spelling (`active`) and the `*When` + // predicate family's spelling of the pressed-state predicate. + active: 'pressed', activeWhen: 'pressed', pressedWhen: 'pressed', style: 'variant', color: 'variant', appearance: 'variant', placement: 'locations', location: 'locations', position: 'locations', verb: 'method', httpMethod: 'method', @@ -1584,6 +1587,34 @@ const actionObject = () => strictObject({ * only; the contract is on {@link actionDisabledReason}. */ disabledReason: actionDisabledReason().optional().describe('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.'), + /** + * [#27946] Whether the action is drawn as ALREADY PRESSED for this reader — + * the toggle face of a button the reader acts on and can act on again + * ("you voted Helpful"). A CEL predicate over the same scope as `visible` + * and `disabled` (`record`, the reader), evaluated by the action surface + * against the row it fetched; TRUE draws the button pressed + * (`aria-pressed="true"`), FALSE or absent draws it as a plain button. + * + * Display only, like {@link actionDisabledReason}: it never gates, greys or + * routes the action — a pressed action stays runnable, and the action's own + * body decides what a second press does (records again, withdraws, or + * answers "already"). + * + * No boolean arm, unlike `visible` / `disabled`: a literal `true` would draw + * the button pressed for every reader on every record, which is a static + * style and not a reader's state, so it is refused at parse with the + * evaluated-slot sentence. + * + * Reading the reader's OWN state. A per-reader fact is rarely a column of + * the row — a vote is a child row per user. The row reaches the predicate + * through the object's `attachedOnRead` block: the serving side computes + * it per caller (a service, or an app's `afterFind` hook shaping the rows + * it returns) and the predicate reads `record..`, guarded with + * `has()` because a row fetched by another route carries no block. Action + * predicates are the sites where the build validator resolves a declared + * block (`SERVED_ROW_SITES` in `@objectstack/lint`). + */ + pressed: EvaluatedExpressionInputSchema.optional().describe('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.'), /** * [ADR-0066 D4] System capabilities required to INVOKE this action — a diff --git a/ui/packages/core/src/actions/actionKeys.ts b/ui/packages/core/src/actions/actionKeys.ts index 100c1f5a63c..ca2474420a6 100644 --- a/ui/packages/core/src/actions/actionKeys.ts +++ b/ui/packages/core/src/actions/actionKeys.ts @@ -299,6 +299,13 @@ export const SPEC_ACTION_KEYS = [ 'outcomeMessages', 'params', 'patch', + // Added to `ActionSchema` by objectstack#27946: the pressed-state predicate a + // header button draws as `aria-pressed` for the reader ("you voted Helpful"). + // Listed for the reason `description` is — this array restates the spec's + // declared keys, so an action carrying it is not reported as having an + // unknown key. `ActionDef` does NOT declare it and no surface reads it yet: + // the console card that draws the pressed face is a follow-up owed. + 'pressed', 'recordIdField', 'recordIdParam', 'refreshAfter',