Skip to content

Commit f63cd09

Browse files
authored
fix(spec): action.undoable is live, not experimental — two objectui readers gate it (#3714) (#3829)
The liveness ledger marked `action.undoable` `experimental` on a #1992-era note ("no runtime reader yet ... objectui has an UndoManager but does not key off this field"). True when written, falsified since: objectui wired two readers, both gating real behaviour — app-shell useConsoleActionRuntime.tsx:409 builds the undo operation the success toast's Undo button invokes (:147), and RecordDetailView.tsx:545 restores the record's prior field values (:404). Because the CLI liveness lint warns on `experimental` as well as `dead`, authoring a *working* property produced a "declared but NOT enforced at runtime" warning — telling authors and AI to skip a shipped feature. - ledger: `experimental` -> `live` with the two readers as evidence - README: action row 33|1|2 -> 34|0|2; `undoable` joins `flow.status` as the second understatement, with the cost of understating spelled out - protocol docs: drop the "currently has no effect" claim - CLI lint test: experimental-path assertion repoints to agent.memory, plus a regression guard that `undoable` is now silent Schema, parsed shape and runtime are unchanged — only the classification.
1 parent 48c110e commit f63cd09

5 files changed

Lines changed: 74 additions & 14 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(spec): `action.undoable` is `live`, not `experimental` — stop warning on a property that works (#3714)
7+
8+
The liveness ledger marked `action.undoable` `experimental` on a #1992-era note:
9+
*"no runtime reader yet — neither service-automation nor objectui consume the
10+
action's `undoable` flag (objectui has an UndoManager but does not key off this
11+
field)."* That was true when written. objectui has since wired **two** readers,
12+
both gating real behaviour:
13+
14+
| Reader | What the flag gates |
15+
|---|---|
16+
| app-shell `useConsoleActionRuntime.tsx:409` | builds the undo operation the success toast's Undo button invokes (`:147`) |
17+
| app-shell `RecordDetailView.tsx:545` | restores the record's prior field values (`:404`) |
18+
19+
`components` `action/action-button.tsx:113` forwards the flag for exactly this
20+
reason, per its own comment: *"without this the flag is dropped and the handler
21+
never builds the undo operation."*
22+
23+
**Why it mattered.** The CLI liveness lint warns on `experimental` as well as
24+
`dead`, so authoring a *working* property produced a
25+
`liveness-experimental-property` warning — "declared but NOT enforced at
26+
runtime". An author (or an AI) reading the ledger or that warning concludes
27+
`undoable` is aspirational and skips it, losing a shipped feature. Authoring
28+
`undoable: true` is now silent, and the protocol reference no longer claims
29+
setting it "currently has no effect".
30+
31+
Nothing to migrate: the schema, the parsed shape, and the runtime are unchanged
32+
— only the classification of what they already do.
33+
34+
This is the *understating* failure direction, the mirror of the preview-renderer
35+
over-claims corrected in #3685/#3711/#3686. Both directions have the same root
36+
cause, now written into `packages/spec/liveness/README.md`: **a ledger entry is a
37+
claim with a timestamp, and code moves under it in both directions** — entries
38+
are worth re-verifying rather than trusting indefinitely.

content/docs/protocol/objectui/actions.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ interface Action {
184184
confirmText?: string; // Confirmation message before execution
185185
successMessage?: string; // Toast shown after success
186186
errorMessage?: string; // Toast shown on failure (overrides the raw error)
187-
undoable?: boolean; // Offer an Undo affordance after a single-record update succeeds (experimental — see note below)
187+
undoable?: boolean; // Offer an Undo affordance after a single-record update succeeds
188188
refreshAfter?: boolean; // Reload the view after execution (default false)
189189
resultDialog?: ResultDialog; // One-shot reveal of API response values
190190
shortcut?: string; // Keyboard shortcut, e.g. "Ctrl+S"
@@ -334,7 +334,7 @@ refreshAfter: true
334334
- `confirmText` — message shown in a confirm dialog before the action runs.
335335
- `successMessage` — toast shown after a successful run. When omitted the UI shows a generic "Action completed" toast, so set this for any action whose outcome isn't self-evident.
336336
- `errorMessage` — toast shown when the action fails; overrides the raw server error with author-controlled copy.
337-
- `undoable` — marks a single-record update action as offering an **Undo** affordance in the success toast to restore the record's prior values. **Experimental:** the flag is declared in the schema but no runtime consumer keys off it yet (objectui ships an `UndoManager` but does not read this field), so setting it currently has no effect.
337+
- `undoable` — marks a single-record update action as offering an **Undo** affordance in the success toast, which restores the record's prior field values. The action runtime snapshots the record before the update and only builds the undo operation when this flag is set, so an action that omits it gets no Undo. Single-record updates only: there is nothing to snapshot when the action isn't scoped to one record.
338338
- `refreshAfter` — reload the current view after success.
339339
- `mode` — a semantic hint (`create` / `edit` / `delete` / `custom`). Pure metadata with no runtime branching: today only the AI confirmation heuristic reads it (a `delete` mode nudges an AI invocation toward requiring approval), while the UI does not branch on it.
340340

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ describe('lintLivenessProperties', () => {
1717
// NOTE: as of #2377 the object- and field-level dead+authorWarn surface is
1818
// empty (enforce-or-remove complete for those types), so the positive-warn
1919
// assertions here run against still-dead props of OTHER governed types
20-
// (flow.nodes.outputSchema, tool.permissions, permission.contextVariables,
21-
// action.undoable). The object/field WALKER is still exercised by the
22-
// silent-clean and default-on-suppression cases below.
20+
// (flow.nodes.outputSchema, tool.permissions, agent.memory). The object/field
21+
// WALKER is still exercised by the silent-clean and default-on-suppression
22+
// cases below.
2323

2424
it('does NOT warn on a default-on flag the author left alone (enable.searchable: true)', () => {
2525
const findings = lintLivenessProperties(objStack({ enable: { searchable: true } }));
@@ -93,9 +93,22 @@ describe('lintLivenessProperties', () => {
9393
expect(hits[0].where).toBe("flow 'f1'");
9494
});
9595

96-
it('warns on action.undoable (experimental — declared but not enforced)', () => {
96+
it('warns on an experimental prop with no authorWarn of its own (agent.memory)', () => {
97+
// `experimental` warns implicitly — shouldWarn() treats a declared-but-
98+
// unenforced guarantee like an opted-in dead prop. Repointed from
99+
// action.undoable in #3714, which turned out to have two objectui readers.
100+
const findings = lintLivenessProperties({ agents: [{ name: 'ag1', memory: { kind: 'buffer' } }] });
101+
const f = findings.find((x) => x.message.includes('`memory`'));
102+
expect(f).toBeDefined();
103+
expect(f!.rule).toBe('liveness-experimental-property');
104+
});
105+
106+
it('stays silent on action.undoable — live since #3714, not experimental', () => {
107+
// Regression guard for the OTHER failure direction: an understated ledger
108+
// entry warns "declared but NOT enforced" on a property that works, telling
109+
// authors (and AI) to skip a shipped feature.
97110
const findings = lintLivenessProperties({ actions: [{ name: 'a1', undoable: true }] });
98-
expect(paths(findings).some((m) => m.includes('`undoable`'))).toBe(true);
111+
expect(paths(findings).some((m) => m.includes('`undoable`'))).toBe(false);
99112
});
100113

101114
it('warns on the security-shaped dead props (tool.permissions)', () => {

packages/spec/liveness/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,20 @@ as `live`, 10 were wrong** — a 77% error rate for the preview-renderer standar
7474
Note the two failure directions the sweep exposed. Most entries **overstated**
7575
liveness. But `flow.status` was *understated*: the file-level note still said
7676
"status/active gate nothing", true when written and falsified a month later by
77-
`497bda853`. **A ledger entry is a claim with a timestamp; code moves under it
78-
in both directions.**
77+
`497bda853`. `action.undoable` was the same shape (#3714): marked `experimental`
78+
on a #1992-era "no runtime reader yet" note that objectui falsified with two
79+
readers. **A ledger entry is a claim with a timestamp; code moves under it
80+
in both directions.** An entry is worth re-verifying, not trusting indefinitely
81+
— see the methodology below.
7982

8083
When in doubt, the honest status is `dead` + `authorWarn`: an author who gets a
81-
warning for a property that turns out to work loses nothing; an author who gets
82-
silence for a property that does nothing ships a bug.
84+
warning for a property that turns out to work loses nothing *at runtime*; an
85+
author who gets silence for a property that does nothing ships a bug. But the
86+
ledger is also read as a capability catalogue — by authors and by AI — so an
87+
understated entry does have a cost: `undoable` sat behind a "declared but NOT
88+
enforced" warning for a month while it worked, which is an invitation to skip a
89+
shipped feature. Erring toward `dead` is the right default *and* a debt to
90+
re-verify.
8391

8492
### How to verify a claim without fooling yourself
8593

@@ -259,7 +267,7 @@ EOF
259267
| object | 40 || 0 | 1 | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) |
260268
| field | 55 || 0 || healthy — full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers |
261269
| flow | 26 || 5 || dead = description/template/nodes.outputSchema/errorHandling.fallbackNodeId (engine uses fault edges) + `active` CORRECTED to dead 2026-07 (deprecated no-op — `status` is what gates binding/execution since 497bda853; the file `_note` claiming otherwise is fixed) |
262-
| action | 33 | 1 | 2 || `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live for real since objectui#2863 (six surfaces); `shortcut` + `bulkEnabled` CORRECTED to dead 2026-07 — registered into ActionEngine but their accessors have no non-test caller (#3686 sweep) |
270+
| action | 34 | 0 | 2 || `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live for real since objectui#2863 (six surfaces); `shortcut` + `bulkEnabled` CORRECTED to dead 2026-07 — registered into ActionEngine but their accessors have no non-test caller (#3686 sweep); `undoable` CORRECTED to live 2026-07 — understated, two objectui readers gate the toast's Undo and the record restore (#3714) |
263271
| hook | 11 || 2 || model-healthy; only label/description dead (benign) |
264272
| permission | 32 || 0 || CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets) |
265273
| position | 4 |||| (role's ADR-0090 successor) fully live |

packages/spec/liveness/action.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@
7474
"note": "objectui toast (error counterpart of successMessage; added #1990)."
7575
},
7676
"undoable": {
77-
"status": "experimental",
78-
"note": "Declared + demoed in #1992 (example-crm reassign) but no runtime reader yet: neither service-automation nor objectui consume the action's `undoable` flag (objectui has an UndoManager but does not key off this field). Promote to live once a consumer is wired."
77+
"status": "live",
78+
"evidence": "objectui: app-shell useConsoleActionRuntime.tsx:409 gates building the undo operation the success toast's Undo button invokes (:147); app-shell RecordDetailView.tsx:545 gates restoring the record's prior values (:404); plumbed through by components action/action-button.tsx:113",
79+
"note": "STATUS CORRECTED 2026-07 (#3714) — this entry UNDERSTATED liveness, the mirror image of the preview-renderer over-claims #3686/#3711 fixed. The #1992-era note (\"no runtime reader yet ... objectui has an UndoManager but does not key off this field\") was true when written and has since been falsified: objectui wired two readers, both gating real behaviour, and action-button.tsx:113 forwards the flag precisely because — per its own comment — \"without this the flag is dropped and the handler never builds the undo operation\". Authoring side: ActionDefaultInspector.tsx \"Offer undo\" checkbox. Consequence while the entry stood: the CLI liveness lint warns on `experimental` too, so authoring a WORKING property produced a \"declared but NOT enforced\" warning."
7980
},
8081
"refreshAfter": {
8182
"status": "live",

0 commit comments

Comments
 (0)