Skip to content

Commit 6afa59d

Browse files
os-justinclaude
andauthored
fix(spec): refuse undoable: true where no runtime fulfils it (#19635)
Fixes #19297 Clause-②: yes Ruling `5754211444` (batch #203 item 5 · letter C · 「203 同意」), letter **C plus D's two text corrections in one PR**. ## What this does `undoable: true` on a registered `action` is now legal only on a shape some runtime actually fulfils, and refused at parse time everywhere else. One `.refine()` on `ActionSchema`, reading `operation` and `type` together, with a remedy naming **both** fulfilling shapes. | shape | who takes the snapshot | verdict | |---|---|---| | `operation: 'update'` | the framework runtime | accepted | | `type: 'api'` | the pinned console | accepted | | `script` / `url`, and the dormant `flow` / `modal` / `form`, without `operation: 'update'` | nobody | **refused** | D's two corrections ride along: the `undoable` `.describe()` sentence (and the code comment above it carrying the same claim) said an action with no `operation` has nothing anchoring the capture — measured false against the pinned console; and `skills/objectstack-ui/rules/actions.md` attributed the snapshot to 「the runtime」 on a bullet whose own example is the api shape. ## The ablation the ruling names Three legs, each a real on-disk mutation of the refine through `scripts/ablation-replace.mjs` (anchor hit proven, blob hash before/after printed, restore proven `git diff HEAD` empty). Two populations, each named so a count never travels without its definition: - **(B) type matrix**, n=7 — one synthetic action per `ActionType` member with `undoable: true` and no `operation`, plus the `operation: 'update'` shape. - **(A) live corpus**, n=4 — every action document in this tree that actually declares `undoable: true`: the published `ReassignLeadAction` skill example, `packages/spec/src/ui/action-row-update.test.ts:119`, and `packages/runtime/src/action-declarative-update.test.ts` at :75 and :533. | leg | (B) refused | (A) refused | |---|---|---| | L0 — guard ablated (the pre-change state) | 0 / 7 | 0 / 4 | | **L1 — the precise refine, as landed** | **5 / 7** | **0 / 4** | | L2 — blanket refusal (direction A, require `operation: 'update'`) | 6 / 7 | **1 / 4 — the published `ReassignLeadAction`** | Refused at L1, by name: `script`, `url`, `flow`, `modal`, `form`. Accepted: `api`, and `operation: 'update'`. **The ruling's expectation holds exactly** — the published example and api actions stay accepted, and the refused set is the one the ruling's own binding text enumerates (「measured as `script` / `url` and the dormant `flow` / `modal` / `form`」). L2 reproduces, first-hand, the breakage the ruling cites as its reason for refusing A. One note on the dispatch's shorthand 「blanket refusal 1 → 5」: it does not reproduce as a single count moving, because its two numbers belong to two different populations. Both numbers are real and both are above — **5** is the precise refine's refused type-shapes, **1** is the live document a blanket refusal would break. No fork: the ruling's stated, testable expectation is met. ## Risk, and what was measured rather than assumed - **Zero live behaviour deleted.** (A) above is the whole in-tree population and the precise refine refuses none of it. - **The two ledger contentions the dispatch flagged did not materialise.** `packages/spec/dropped-refinements.baseline.json` is **unchanged** — `ui/Action` already carries a root site (`in`) from the existing refine chain, so a second refinement at the same position adds no new site; `check:authorable-surface` and the spec build are green without touching it. `packages/spec/liveness/state-counts.md` is **unchanged** too, and `check:liveness` is green: the `props/undoable` row's status (`live`) and its objectui-side evidence are untouched by this diff, so no re-citation is owed here. - **`../objectui` is not checked out in this container**, so the two console readers at the pinned sha were **not** read first-hand. Every statement about them in this PR is carried from the card's recorded evidence and from `packages/spec/liveness/action.json`, not re-measured. Stated rather than implied. - **Rollback** is one commit: the refine is a single self-contained block in `ActionSchema`'s chain plus its pin test. The prose corrections are independent of it and correct on their own. ## Skills line budget — both readings The dispatch's ceiling was **+2 net lines**. The published catalog also carries a **token** ratchet with **zero headroom** on this file, which the first phrasing red at +35 tokens; the correction was tightened to fit rather than the ceiling raised. | reading | before | after | delta | |---|---|---|---| | `skills/objectstack-ui/rules/actions.md` — lines | 208 | 208 | **+0** | | `skills/objectstack-ui/rules/actions.md` — tokens (`ceil(utf8 bytes / 4)`, ceiling 2108) | 2108 | **2107** | **−1** | | `skills/objectstack-ui/rules/actions.md` — bytes | 8432 | 8428 | −4 | | whole `skills/objectstack-ui` package — lines | 2155 | 2155 | +0 | | all published `SKILL.md` — lines | 6145 | 6145 | +0 | | whole published `skills/` tree — lines | 12766 | 12766 | +0 | No surrounding prose was rewritten and no ceiling was moved. `check-skills-token-ratchet` and `check-skill-line-ratchet` are both green. ## Verification All readings at `c8d4d6513`, the final commit. **Gates** — derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, every derived command run with its exit code captured before any pipe, reconciled with `--ran`: > 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN — a DERIVED zero, all 115 recorded an exit code and none of them is 3. All 115 exit 0. Nine first returned exit 3 (`PREREQUISITE NOT MET` — a gate that reads built output, not a finding) and were re-run to a real reading after the dependency closure and then the full build. **Suites** — the spec lane charter's consumer requirement, each package named with a real reading: | package | reading | |---|---| | `@objectstack/spec` (own) | 510 files / 14924 passed, 1 todo · `typecheck` exit 0 | | `@objectstack/runtime` | 272 files / 3799 passed, 1 skipped | | `@objectstack/lint` | 108 files / 4084 passed | | `@objectstack/objectql` | 303 files / 5050 passed | | `@objectstack/metadata-protocol` | 188 files / 2645 passed, 19 skipped | | `@objectstack/example-crm` | 5 files / 45 passed | | `@objectstack/example-showcase` | 29 files / 384 passed | | `@objectstack/example-todo` | 7 files / 238 passed | | `@objectstack/cli` | unit layer: 222 files / 3141 passed. The integration layer is declared to CI — this diff touches no integration-layer file, no `bin/` entry and no spawn helper | `pnpm lint` (`eslint . --no-inline-config`, the whole repo) exit 0 at `c8d4d6513`. New pin: `packages/spec/src/ui/action-undoable-fulfillment.test.ts`, 20 cases — every unfulfilled shape refused at the `undoable` path, the remedy naming both fulfilling shapes and the runtime that fulfils each, refusal through the registered `action` metadata door, both fulfilled shapes accepted, and `undoable` absent or `false` untouched on every type. ## Acceptance notes - The second `undoable` prose line in `action.zod.ts` (「`undoable` captures the prior values of exactly the fields written.」) was read and **left as written**: it sits inside the `operation` key's own EXECUTOR CONTRACT block, scoped to the `operation: 'update'` shape, where it is true. It is not the sentence ruling D calls measured-false. - The stale remote branch `claude/issue-19297-undoable-requires-operation` was inspected read-only and carries **no commits of its own** — its tip `488f4f54` is an ordinary `main` commit. Nothing of direction A exists on it to inherit. Not branched from, not pushed to. - The rule lives on `ActionSchema`'s chain alone, matching the existing scoping precedent beside it; an inline action is not a registered action and has no console reader to speak for. The `.describe()` renders into the InlineAction reference table too, so its refusal sentence is qualified 「on a registered action」, the same way the `confirmText` describe is. ## 维护者速读(草稿) **改了什么** 「做完给我一个撤销按钮」这个开关,以前写在哪种动作上都收,可真正会去拍快照的只有两种。现在把合法范围收到这两种——一种由框架运行时兑现,一种由我们自己钉住的控制台兑现——其余写法在解析时就被拒,拒绝话术里同时点名这两条出路。顺带改正两处说明文字:schema 里那句「没有 operation 就没有东西可锚」实测为假,技能文档里把快照归给「运行时」的那句,对 api 那种形状其实是控制台干的。 **为什么改** 声明了却没人兑现,是北极星第 4 条禁止的形状:作者写了,既不被拒也拿不到处方。但最直觉的修法——强制配 `operation: 'update'`——实测会当场炸掉我们自己已发布的示例和控制台里每一个带撤销的 api 动作,所以没有采用。这次收的是「一个读者都没有」的那部分,删掉的活行为是零。 **风险与代价(含回滚)** 风险低:全仓四份真实写法无一被拒,例子应用、runtime、lint、objectql、metadata-protocol 的套件全绿。代价是把「api 形状由控制台兑现」这句话写进了 spec——裁决已认定这是契约陈述而非越界。回滚是一次 revert:那条规则是独立的一段,两处文字改正即使单独留下也是对的。派发词担心的两个生成账本(`dropped-refinements`、`state-counts`)实测都没被动到,不存在和其它 PR 抢同一个文件的问题。**一处如实说明**:姊妹仓 objectui 没有检出到这个容器里,控制台那两个读者本次未能亲自读到,相关陈述沿用卡片已记录的证据。 **席位意见** **你要做的** 回一句可落地即可。本 PR 触到 `skills/**`,按 Tier H 规则保持 draft,不合并、不排队、不开自动合并;落地等的是至档合约复核加上您的批准。 --- _Generated by [Claude Code](https://claude.ai/code/session_01GBPc6CYjy3tNTYhaKCmqg4)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a36914b commit 6afa59d

9 files changed

Lines changed: 291 additions & 8 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for authored metadata** — `undoable: true` on a registered `action` is now legal only on a shape some runtime actually fulfils, and refused at parse time everywhere else.
6+
7+
Clause-②: yes
8+
9+
The accept set narrows. `undoable` was a plain optional boolean that no refinement read, so it parsed clean on every action shape while only two of them ever produced an Undo — the declared-but-inert case the spec refuses at author time (ADR-0078).
10+
11+
**The two fulfilled shapes, and which runtime fulfils each**
12+
13+
| shape | who takes the snapshot |
14+
| --- | --- |
15+
| `operation: 'update'` (with a `patch`) | the framework runtime — the prior value of every field in the merged write bag, `patch` UNDER the collected `params` |
16+
| `type: 'api'` | the pinned console — it builds the undo envelope from `undoable` alone |
17+
18+
Both stay accepted, byte-identically. Naming the console in the contract is deliberate: the spec is the contract for every runtime including the console, and a closed table of fulfillable combinations is what the declared-is-delivered rule asks for.
19+
20+
**What is refused**
21+
22+
`undoable: true` on `type: 'script'` (the default route) or `type: 'url'`, and on the dormant `type: 'flow'` / `'modal'` / `'form'`, in each case without `operation: 'update'`. Nothing reads the flag on those shapes, so it promised an Undo that never appeared.
23+
24+
```
25+
✗ undoable: `undoable: true` has no runtime that can fulfil it on this action. An Undo is
26+
captured on exactly two shapes: `operation: 'update'`, where the framework runtime snapshots
27+
the prior value of every field the write bag touches, and `type: 'api'`, which the console
28+
snapshots. …
29+
```
30+
31+
**⛔ What is deliberately NOT refused, because it was measured wrong.** Requiring `operation: 'update'` — the obvious repair — would refuse the published `ReassignLeadAction` skill example (`type: 'api'` + `undoable: true`, no `operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every console api action with undo along with it. The console's two readers gate the undo envelope on `action.undoable` alone with zero reads of `action.operation`, and those same two files are the entire recorded evidence for this package's own liveness verdict `action/undoable: live`. `undoable` absent or `false` is untouched on every type, and the rule lives on `ActionSchema`'s refine chain alone — an inline action is not a registered action.
32+
33+
### Migration — FROM → TO
34+
35+
| You wrote | Write instead |
36+
| --- | --- |
37+
| `{ type: 'script', body, undoable: true }` | `{ operation: 'update', patch: { … }, undoable: true }` if the action is a single-record field write, or drop `undoable` and keep the handler |
38+
| `{ type: 'url' \| 'flow' \| 'modal' \| 'form', undoable: true }` | the same action without `undoable` — those routes never had a capture, so behaviour is unchanged |
39+
40+
⛔ Not mechanically convertible, so this ships as an ADR-0087 D3 structured TODO rather than a D2 conversion: which of the two fulfilled shapes an author meant is an intent no artifact records — a `script` action with an inline handler and an api action calling an endpoint are different dispatches, not two spellings of one — and dropping the flag automatically would remove an Undo the author asked for.
41+
42+
<!-- adr-0087: registered ui-action-undoable-unfulfillable-refused -->
43+
44+
**Published surface.** No export is added, removed or renamed; `ActionType` still carries all six types. The `undoable` `.describe()` and the comment above it are corrected in the same change: both claimed that an action with no `operation` has nothing anchoring the capture, which is false against the pinned console.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ const result = ApiMethod.parse(data);
461461
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
462462
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
463463
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
464-
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. |
464+
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |
465465
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
466466
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
467467
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ const result = MetadataBulkResultSchema.parse(data);
330330
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
331331
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
332332
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
333-
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. |
333+
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |
334334
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
335335
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
336336
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const result = ActionSchema.parse(data);
4949
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
5050
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
5151
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
52-
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. |
52+
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |
5353
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
5454
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
5555
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-action-undoable-unfulfillable-refused',
7+
surface: '`action` documents declaring `undoable: true` on a shape no runtime fulfils — '
8+
+ "`type: 'script'` (the default route) and `type: 'url'`, plus the dormant "
9+
+ "`type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update'`",
10+
replacement: "either of the two fulfilled shapes — `operation: 'update'` with a `patch`, "
11+
+ 'where the framework runtime snapshots the prior value of every field in the merged '
12+
+ "write bag, or `type: 'api'`, where the pinned console builds the undo envelope — or "
13+
+ 'no `undoable` at all. ⛔ NOT mechanically convertible: which of the two the author '
14+
+ 'meant is an intent no artifact records (a `script` action with an inline handler and '
15+
+ 'an api action calling an endpoint are different dispatches, not two spellings of '
16+
+ 'one), and dropping the flag silently would remove an Undo the author asked for. The '
17+
+ 'refusal names both shapes and the drop, and the author chooses',
18+
reason:
19+
'The key was a plain optional boolean read by no refinement, so every combination '
20+
+ 'parsed clean while only two of them ever produced an Undo — the declared-but-inert '
21+
+ 'shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring '
22+
+ "`operation: 'update'`, was MEASURED WRONG and is deliberately not what this entry "
23+
+ "records: the pinned console's two readers gate the undo envelope on "
24+
+ '`action.undoable` alone with zero reads of `action.operation`, and those same two '
25+
+ "files are the entire recorded evidence for this package's own liveness verdict "
26+
+ '`action/undoable: live`. A blanket requirement would therefore have refused the '
27+
+ 'published `ReassignLeadAction` skill example (`type: \'api\'` + `undoable: true`, no '
28+
+ '`operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every '
29+
+ 'console api action with undo along with it. So the accepted set is closed to the '
30+
+ 'two shapes some runtime fulfils rather than to the one the framework runtime '
31+
+ 'fulfils. Stating "`type: \'api\'` is fulfilled by the console" in the contract is '
32+
+ 'the point, not a leak: the spec is the contract for every runtime including the '
33+
+ 'console, and a closed table of fulfillable combinations is what the '
34+
+ 'declared-is-delivered rule asks for.',
35+
acceptanceCriteria:
36+
"Every `action` document declaring `undoable: true` carries `operation: 'update'` or "
37+
+ "`type: 'api'`. Both fulfilled shapes parse byte-identically to before — the "
38+
+ 'published `ReassignLeadAction` example included — and an action with `undoable` '
39+
+ 'absent or `false` is untouched on every type. An action declaring `undoable: true` '
40+
+ 'on any other shape is refused with a per-key issue at `undoable` whose message names '
41+
+ 'both fulfilling shapes and the runtime that fulfils each; the author adds the shape '
42+
+ 'they meant or drops the flag.',
43+
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12410,6 +12410,45 @@ const step18: MigrationStep = {
1241012410
+ 'and must be verified as such: nothing ever parsed or read these shapes, so removing '
1241112411
+ 'them removes no behaviour.',
1241212412
},
12413+
{
12414+
id: 'ui-action-undoable-unfulfillable-refused',
12415+
surface: '`action` documents declaring `undoable: true` on a shape no runtime fulfils — '
12416+
+ "`type: 'script'` (the default route) and `type: 'url'`, plus the dormant "
12417+
+ "`type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update'`",
12418+
replacement: "either of the two fulfilled shapes — `operation: 'update'` with a `patch`, "
12419+
+ 'where the framework runtime snapshots the prior value of every field in the merged '
12420+
+ "write bag, or `type: 'api'`, where the pinned console builds the undo envelope — or "
12421+
+ 'no `undoable` at all. ⛔ NOT mechanically convertible: which of the two the author '
12422+
+ 'meant is an intent no artifact records (a `script` action with an inline handler and '
12423+
+ 'an api action calling an endpoint are different dispatches, not two spellings of '
12424+
+ 'one), and dropping the flag silently would remove an Undo the author asked for. The '
12425+
+ 'refusal names both shapes and the drop, and the author chooses',
12426+
reason:
12427+
'The key was a plain optional boolean read by no refinement, so every combination '
12428+
+ 'parsed clean while only two of them ever produced an Undo — the declared-but-inert '
12429+
+ 'shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring '
12430+
+ "`operation: 'update'`, was MEASURED WRONG and is deliberately not what this entry "
12431+
+ "records: the pinned console's two readers gate the undo envelope on "
12432+
+ '`action.undoable` alone with zero reads of `action.operation`, and those same two '
12433+
+ "files are the entire recorded evidence for this package's own liveness verdict "
12434+
+ '`action/undoable: live`. A blanket requirement would therefore have refused the '
12435+
+ 'published `ReassignLeadAction` skill example (`type: \'api\'` + `undoable: true`, no '
12436+
+ '`operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every '
12437+
+ 'console api action with undo along with it. So the accepted set is closed to the '
12438+
+ 'two shapes some runtime fulfils rather than to the one the framework runtime '
12439+
+ 'fulfils. Stating "`type: \'api\'` is fulfilled by the console" in the contract is '
12440+
+ 'the point, not a leak: the spec is the contract for every runtime including the '
12441+
+ 'console, and a closed table of fulfillable combinations is what the '
12442+
+ 'declared-is-delivered rule asks for.',
12443+
acceptanceCriteria:
12444+
"Every `action` document declaring `undoable: true` carries `operation: 'update'` or "
12445+
+ "`type: 'api'`. Both fulfilled shapes parse byte-identically to before — the "
12446+
+ 'published `ReassignLeadAction` example included — and an action with `undoable` '
12447+
+ 'absent or `false` is untouched on every type. An action declaring `undoable: true` '
12448+
+ 'on any other shape is refused with a per-key issue at `undoable` whose message names '
12449+
+ 'both fulfilling shapes and the runtime that fulfils each; the author adds the shape '
12450+
+ 'they meant or drops the flag.',
12451+
},
1241312452
// The one key this close DECLARES rather than refuses is `dependsOn`, so an author
1241412453
// who wrote it keeps working and now has a contract saying so. Everything else
1241512454
// undeclared becomes a parse error. Registered as a structured TODO (ADR-0087 D3)

0 commit comments

Comments
 (0)