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
38 changes: 38 additions & 0 deletions .changeset/action-undoable-liveness-corrected.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@objectstack/spec": patch
"@objectstack/cli": patch
---

fix(spec): `action.undoable` is `live`, not `experimental` — stop warning on a property that works (#3714)

The liveness ledger marked `action.undoable` `experimental` on a #1992-era note:
*"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)."* That was true when written. objectui has since wired **two** readers,
both gating real behaviour:

| Reader | What the flag gates |
|---|---|
| app-shell `useConsoleActionRuntime.tsx:409` | builds the undo operation the success toast's Undo button invokes (`:147`) |
| app-shell `RecordDetailView.tsx:545` | restores the record's prior field values (`:404`) |

`components` `action/action-button.tsx:113` forwards the flag for exactly this
reason, per its own comment: *"without this the flag is dropped and the handler
never builds the undo operation."*

**Why it mattered.** The CLI liveness lint warns on `experimental` as well as
`dead`, so authoring a *working* property produced a
`liveness-experimental-property` warning — "declared but NOT enforced at
runtime". An author (or an AI) reading the ledger or that warning concludes
`undoable` is aspirational and skips it, losing a shipped feature. Authoring
`undoable: true` is now silent, and the protocol reference no longer claims
setting it "currently has no effect".

Nothing to migrate: the schema, the parsed shape, and the runtime are unchanged
— only the classification of what they already do.

This is the *understating* failure direction, the mirror of the preview-renderer
over-claims corrected in #3685/#3711/#3686. Both directions have the same root
cause, now written into `packages/spec/liveness/README.md`: **a ledger entry is a
claim with a timestamp, and code moves under it in both directions** — entries
are worth re-verifying rather than trusting indefinitely.
4 changes: 2 additions & 2 deletions content/docs/protocol/objectui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -184,7 +184,7 @@ interface Action {
confirmText?: string; // Confirmation message before execution
successMessage?: string; // Toast shown after success
errorMessage?: string; // Toast shown on failure (overrides the raw error)
undoable?: boolean; // Offer an Undo affordance after a single-record update succeeds (experimental — see note below)
undoable?: boolean; // Offer an Undo affordance after a single-record update succeeds
refreshAfter?: boolean; // Reload the view after execution (default false)
resultDialog?: ResultDialog; // One-shot reveal of API response values
shortcut?: string; // Keyboard shortcut, e.g. "Ctrl+S"
Expand DownExpand Up@@ -334,7 +334,7 @@ refreshAfter: true
- `confirmText` — message shown in a confirm dialog before the action runs.
- `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.
- `errorMessage` — toast shown when the action fails; overrides the raw server error with author-controlled copy.
- `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.
- `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.
- `refreshAfter` — reload the current view after success.
- `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.

Expand Down
23 changes: 18 additions & 5 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,9 +17,9 @@ describe('lintLivenessProperties', () => {
// NOTE: as of #2377 the object- and field-level dead+authorWarn surface is
// empty (enforce-or-remove complete for those types), so the positive-warn
// assertions here run against still-dead props of OTHER governed types
// (flow.nodes.outputSchema, tool.permissions, permission.contextVariables,
// action.undoable). The object/field WALKER is still exercised by the
// silent-clean and default-on-suppression cases below.
// (flow.nodes.outputSchema, tool.permissions, agent.memory). The object/field
// WALKER is still exercised by the silent-clean and default-on-suppression
// cases below.

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

it('warns on action.undoable (experimental — declared but not enforced)', () => {
it('warns on an experimental prop with no authorWarn of its own (agent.memory)', () => {
// `experimental` warns implicitly — shouldWarn() treats a declared-but-
// unenforced guarantee like an opted-in dead prop. Repointed from
// action.undoable in #3714, which turned out to have two objectui readers.
const findings = lintLivenessProperties({ agents: [{ name: 'ag1', memory: { kind: 'buffer' } }] });
const f = findings.find((x) => x.message.includes('`memory`'));
expect(f).toBeDefined();
expect(f!.rule).toBe('liveness-experimental-property');
});

it('stays silent on action.undoable — live since #3714, not experimental', () => {
// Regression guard for the OTHER failure direction: an understated ledger
// entry warns "declared but NOT enforced" on a property that works, telling
// authors (and AI) to skip a shipped feature.
const findings = lintLivenessProperties({ actions: [{ name: 'a1', undoable: true }] });
expect(paths(findings).some((m) => m.includes('`undoable`'))).toBe(true);
expect(paths(findings).some((m) => m.includes('`undoable`'))).toBe(false);
});

it('warns on the security-shaped dead props (tool.permissions)', () => {
Expand Down
18 changes: 13 additions & 5 deletions packages/spec/liveness/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,12 +74,20 @@ as `live`, 10 were wrong** — a 77% error rate for the preview-renderer standar
Note the two failure directions the sweep exposed. Most entries **overstated**
liveness. But `flow.status` was *understated*: the file-level note still said
"status/active gate nothing", true when written and falsified a month later by
`497bda853`. **A ledger entry is a claim with a timestamp; code moves under it
in both directions.**
`497bda853`. `action.undoable` was the same shape (#3714): marked `experimental`
on a #1992-era "no runtime reader yet" note that objectui falsified with two
readers. **A ledger entry is a claim with a timestamp; code moves under it
in both directions.** An entry is worth re-verifying, not trusting indefinitely
— see the methodology below.

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

### How to verify a claim without fooling yourself

Expand DownExpand Up@@ -259,7 +267,7 @@ EOF
| 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) |
| 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 |
| 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) |
| 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) |
| 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) |
| hook | 11 | – | 2 | – | model-healthy; only label/description dead (benign) |
| 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) |
| position | 4 | – | – | – | (role's ADR-0090 successor) fully live |
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/liveness/action.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,8 +74,9 @@
"note": "objectui toast (error counterpart of successMessage; added #1990)."
},
"undoable": {
"status": "experimental",
"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."
"status": "live",
"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",
"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."
},
"refreshAfter": {
"status": "live",
Expand Down
Loading