Skip to content

Commit 80334c7

Browse files
os-zhuangclaude
andauthored
feat(spec)!: action 收紧,ADR-0010 信封欠债归零(#4001 批 6d) (#4533)
* feat(spec)!: action closes, and the ADR-0010 envelope debt list reaches zero (#4001) `ActionParamSchema` has been strict since #3746 — the template this campaign was generalized from, and the source of its sharpest lesson: `visibleWhen` → `visible` showed the most valuable alias entry is rarely a typo but a key that reads as a control and silently is not one. The action AROUND the param stayed open for three more releases. The AI exposure block is why this one mattered. `ActionAiSchema` is the governance gate — its own doc says a half-finished or unreviewed action must never be silently armed — yet `requireConfirmation`, one letter off, was dropped in silence, so an author who asked for a human-in-the-loop gate on an AI-invoked action did not get one and was not told. The root tombstones the key this campaign cares most about. `permissions` on an action is NOT a permission gate: authorization comes from the object's permission sets and, on the AI surface, from the agent's access/permissions (#1884). The rejection says so and names the trap beside it — `visible` and `disabled` are UI predicates, they hide or grey a button, they do not stop a request. Third time in this campaign after skill.permissions and agent.visibility, always a different surface, always reading like a control. The undeclared-envelope debt list is now EMPTY. The structural walk opened it with eight names after replacing a probe that had been hiding seven; `action` was the last. The empty set is kept, not deleted: with no exemptions the DECLARES case runs over every registered type, so a new type shipping without the spread fails immediately instead of being quietly added to a list. Registered types closed: 24 of 25. Only `view` remains. Two lint-layer facts recorded rather than papered over: the array-index test has run out of subject (no strip-mode array-of-objects remains anywhere), and `view` is the last open root — when it closes, change the floor to 0 and assert the empty set deliberately rather than deleting the test, because an empty result nobody chose is indistinguishable from a derivation that broke. Verified: 284 files / 7240 tests, tsc clean, 8 generated artifacts current, all spec gates green, and CRM/Todo/showcase/platform-objects build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY * fix(spec): an action's capability gate is real — point the near-misses AT it (#4001) The first draft of this batch's guidance claimed `permissions` on an action is not a permission gate and sent authors to the object's permission sets. That is wrong: `requiredPermissions` (ADR-0066 D4) is a declared key enforced with a 403 on the platform action route, and `ui/actions.mdx` teaches it by name. Anyone following the prescription would have been invited to delete a working gate. - `permissions` / `capabilities` / `requiresPermissions` / `acl` now RENAME onto `requiredPermissions` instead of pointing away from it. - The dead `requiredPermissions` guidance entry is removed — the key is declared, so strict never reaches it; the entry was wrong AND unreachable. - `location` → `locations` was backwards: `locations` is the declared key, so the alias would have suggested renaming a correct key to a nonexistent one. - The `visible`/`disabled` warning survives on `hidden`, where it belongs: hiding is not gating, and a headless action is `locations: []`. Ledger finding 7 for the fourth time — this campaign's own prescriptions are a surface that can be confidently wrong. Caught by checking the docs the drift report flagged against the schema rather than against memory of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY * test(metadata-protocol): a stored-row fixture wrote `object` where the key is `objectName` (#4001) `ActionSchema` has never declared `object` — the key is `objectName`. Two stored-row fixtures wrote it anyway, `.strip` ate it, and the tests passed. With the shape closed the row now fails spec validation after conversion and the protocol reports `invalid: 1`. Worth naming because of where it was: these fixtures stand in for pre-17 rows in a database, so a typo in one reads as a claim about real legacy data — "stored actions carry `object`" — when no such key ever existed. There is no missing conversion here; `git log -S` finds no commit that ever declared it. Sixth strip-era fiction this campaign has found in a test, and the first dressed as a stored ROW rather than an authored literal. Also the reason this was caught by CI rather than locally: the previous pass ran only `packages/spec`. The blast radius of closing a registered type is every package that parses that type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 67bf2e2 commit 80334c7

8 files changed

Lines changed: 211 additions & 35 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`action` rejects unknown keys, and the ADR-0010 protection-envelope debt list reaches zero.
6+
7+
`ActionParamSchema` has been strict since #3746 — the template this whole campaign was generalized from, and the source of its sharpest lesson: `visibleWhen``visible` showed that the most valuable entry in an alias table is rarely a typo, it is a key that reads as a control and silently is not one. The action *around* the param stayed open for three more releases.
8+
9+
**The AI exposure block is the reason this one mattered.** `ActionAiSchema` is the governance gate — its own doc says the platform's value is that "a human can govern exactly which capabilities the agent fleet is allowed to invoke", and that "a half-finished or unreviewed action must never be silently armed". Yet `requireConfirmation` (one letter off `requiresConfirmation`) was dropped in silence, so an author who asked for a human-in-the-loop gate on an AI-invoked action did not get one and was not told. Both that block and the action root now reject, with prescriptions for the two keys authors reach for at the wrong level (`exposed` and `requiresConfirmation` belong under `ai`).
10+
11+
**An action's capability gate is real, and the near-misses now rename onto it.** `requiredPermissions` (ADR-0066 D4) is enforced with a 403 on the platform action route, so `permissions` / `capabilities` / `acl` are aliased to it rather than being told the gate lives elsewhere. What *is* tombstoned is the trap beside it: `visible` and `disabled` are UI predicates — **they hide or grey a button, they do not stop a request** — and an action with no UI surface is `locations: []`, still gated.
12+
13+
That entry was wrong in the first draft of this change, in the direction that matters. It claimed an action carries no permission key and sent authors to the object's permission sets, which — had anyone followed it — invites deleting a working `requiredPermissions` gate. Caught by checking the docs the drift report flagged (`ui/actions.mdx` teaches exactly that key) against the schema. It is the ledger's finding 7 for the fourth time: **this campaign's own prescriptions are themselves a surface that can be confidently wrong**, and the only defence is verifying each one against the schema rather than against memory of it.
14+
15+
`resultDialog` and its fields, the AI param hints, and the `bodyShape` wrapper close alongside.
16+
17+
**The undeclared-envelope debt list is now empty.** The structural walk opened it with eight names (`action`, `book`, `field`, `job`, `mapping`, `page`, `translation`, `validation`) after replacing a probe that had been hiding seven of them; `action` was the last. The empty set is kept rather than deleted — with no exemptions, the `DECLARES the protection envelope` case now runs over every registered type, so a new type shipping without the spread fails immediately instead of being quietly added to a list. Adding a name back is filing a bug, not granting an exemption.
18+
19+
Registered types closed at the top level: **24 of 25**. Only `view` remains.
20+
21+
Two things the lint layer surfaced, recorded rather than papered over:
22+
23+
- **The array-index test has run out of subject.** It was `pages[].regions[]`, then `objects[].actions[]`; with `action` closed there is no declared array-of-objects left anywhere in the registered surface that is still strip-mode. The walker's array handling is unchanged and still correct — what is gone is any metadata type that exercises it. The test now asserts the hand-off plus the per-node descent under a closed root (the #4522 fix), and says in place that an indexed assertion should be restored if a new strip surface ever appears.
24+
- **`view` is the last open root**, so when it closes this layer has nothing left to warn about at a root. The test says to change the floor to 0 and assert the empty set *deliberately* — not to delete the test, because an empty result nobody chose is indistinguishable from a derivation that broke.
25+
26+
Authoring impact: a key `ActionSchema` does not declare is now rejected instead of silently discarded — it was already being ignored, so no working action changes.

content/docs/references/ui/action.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ const result = Action.parse(data);
113113
| **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. |
114114
| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. |
115115
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
116+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
117+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
118+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
119+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
120+
| **_packageId** | `string` | optional | Owning package machine id. |
121+
| **_packageVersion** | `string` | optional | Owning package version. |
122+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
116123

117124

118125
---

packages/metadata-protocol/src/protocol.stored-conversions.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,15 @@ const legacyObjectRow = {
8585
};
8686

8787
// A pre-17 standalone action row still carrying the removed `execute` alias.
88+
// NOTE: this once wrote `object: 'crm_invoice'`. `ActionSchema` has never
89+
// declared `object` — the key is `objectName` — and `.strip` ate it, so the
90+
// fixture read as a claim about real legacy data while being a typo. Closing
91+
// the shape (#4001) made it a diagnostic. Sixth strip-era fiction this campaign
92+
// has found in a test, and the first one dressed as a stored ROW.
8893
const legacyActionRow = {
8994
type: 'action',
9095
name: 'convert',
91-
metadata: { name: 'convert', label: 'Convert', type: 'script', object: 'crm_invoice', execute: 'convertHandler' },
96+
metadata: { name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', execute: 'convertHandler' },
9297
};
9398

9499
describe('getMetaItems — stored rows are served canonical (#3903)', () => {

packages/metadata-protocol/src/protocol.stored-migration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ const canonicalObjectRow = {
139139
const legacyActionRow = {
140140
type: 'action',
141141
name: 'convert',
142-
metadata: { name: 'convert', label: 'Convert', type: 'script', object: 'crm_invoice', execute: 'convertHandler' },
142+
metadata: { name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', execute: 'convertHandler' },
143143
};
144144

145145
describe('migrateStoredMetadata — preview (#4327)', () => {

packages/spec/authorable-surface.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7103,6 +7103,13 @@
71037103
"ui/AIChatWindowProps:aria",
71047104
"ui/AIChatWindowProps:context",
71057105
"ui/AIChatWindowProps:mode",
7106+
"ui/Action:_lock",
7107+
"ui/Action:_lockDocsUrl",
7108+
"ui/Action:_lockReason",
7109+
"ui/Action:_lockSource",
7110+
"ui/Action:_packageId",
7111+
"ui/Action:_packageVersion",
7112+
"ui/Action:_provenance",
71067113
"ui/Action:ai",
71077114
"ui/Action:aria",
71087115
"ui/Action:body",

packages/spec/src/kernel/metadata-authoring-lint.test.ts

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,20 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => {
5454
// `page.regions[0].zzz` all `safeParse` to failure — and the check is worth
5555
// repeating on the next one, because a broken walk and a successful
5656
// graduation shrink this count identically.
57-
// 3 → 2 when `dashboard` closed; `dashboard.zzz` was confirmed rejected by
58-
// the parse first, same as the batch before it.
59-
expect(lintables.length).toBeGreaterThanOrEqual(2);
60-
// `view` matters doubly: it is a UNION (container | ViewItem | overlay), so
61-
// its presence pins the union half of the posture logic — a regression that
62-
// silently dropped unions would shrink coverage without failing the count.
63-
// When `view` and `action` close, this whole layer has nothing left to warn
64-
// about at a ROOT, which is the campaign finishing rather than the lint
65-
// breaking — at that point assert the empty set deliberately, do not delete
66-
// the test.
67-
for (const expected of ['action', 'view']) {
57+
// 3 → 2 when `dashboard` closed, 2 → 1 when `action` did; in each case the
58+
// departed root was confirmed rejected by the parse before the number moved.
59+
expect(lintables.length).toBeGreaterThanOrEqual(1);
60+
// `view` is the LAST open root, and it matters doubly: it is a UNION
61+
// (container | ViewItem | overlay), so its presence pins the union half of
62+
// the posture logic — a regression that silently dropped unions would shrink
63+
// coverage without failing the count.
64+
//
65+
// When `view` closes, this layer has nothing left to warn about at a ROOT.
66+
// That is the campaign finishing, not the lint breaking. At that point change
67+
// the floor to 0 and assert the empty set DELIBERATELY — do not delete this
68+
// test, because an empty result that nobody chose is indistinguishable from
69+
// a derivation that broke.
70+
for (const expected of ['view']) {
6871
expect(lintableTypes, `expected '${expected}' to be lint-covered`).toContain(expected);
6972
}
7073
});
@@ -177,7 +180,7 @@ describe('the #4148 behaviours survive the generalization', () => {
177180
// strip site under a CLOSED root (the #4522 behaviour), and `view` — the
178181
// last open root, and the union case — reports at its own.
179182
const findings = lintUnknownAuthoringKeys({
180-
objects: [{ name: 'a', label: 'A', actions: [{ name: 'act', zzz: 1 }] }],
183+
objects: [{ name: 'a', label: 'A', userActions: { zzz: 1 } }],
181184
views: [{ name: 'v', object: 'a', zzz: 1 }],
182185
});
183186
// Deduped deliberately: `view` is a union (container | ViewItem | overlay)
@@ -187,7 +190,7 @@ describe('the #4148 behaviours survive the generalization', () => {
187190
// and it becomes moot when `view` closes. Left recorded rather than papered
188191
// over by picking a non-union collection.
189192
expect([...new Set(findings.map((f) => `${f.surface}:${f.path}`))].sort()).toEqual([
190-
'object:objects.a.actions.0.zzz',
193+
'object:objects.a.userActions.zzz',
191194
'view:views.v.zzz',
192195
]);
193196
});
@@ -241,17 +244,29 @@ describe('nested descent (#4001 evidence phase)', () => {
241244
});
242245

243246
it('reports inside an array element, indexed by position', () => {
244-
// Was `pages[].regions[]` until `page` closed (#4001 batch 6a) — the parse
245-
// rejects that key now. `object.actions[]` is the same structural case and
246-
// carries a second property worth pinning: `object` itself is CLOSED, and
247-
// its nested strip sites still report. That is the #4522 fix — the walk
248-
// descends per node instead of gating a whole collection on its root's
249-
// posture — so this test now covers the array index and that regression at
250-
// once.
251-
const [finding] = lintUnknownAuthoringKeys({
247+
// This test has now run out of subject, and that is worth saying plainly
248+
// rather than deleting it or inventing a fixture.
249+
//
250+
// It was `pages[].regions[]`, then `objects[].actions[]` when `page` closed
251+
// (6a), and with `action` closed (6d) there is no declared ARRAY OF OBJECTS
252+
// left anywhere in the registered surface that is still strip-mode. The
253+
// walker's array-index handling is unchanged and still correct; what is gone
254+
// is any metadata type that exercises it. That is the ratchet finishing, not
255+
// the walk regressing.
256+
//
257+
// So: assert the hand-off, and assert what still holds — the per-node
258+
// descent under a CLOSED root, which is the #4522 fix and the reason the
259+
// walk no longer gates a whole collection on its root's posture. If a new
260+
// strip surface with a nested array ever appears, restore the indexed
261+
// assertion here; do not let it go untested a second time.
262+
expect(lintUnknownAuthoringKeys({
252263
objects: [{ name: 'o1', actions: [{ name: 'a', zzz_nested: 1 }] }],
264+
})).toEqual([]);
265+
266+
const [nested] = lintUnknownAuthoringKeys({
267+
objects: [{ name: 'o1', userActions: { zzz_nested: 1 } }],
253268
});
254-
expect(finding).toMatchObject({ path: 'objects.o1.actions.0.zzz_nested', surface: 'object' });
269+
expect(nested).toMatchObject({ path: 'objects.o1.userActions.zzz_nested', surface: 'object' });
255270
});
256271

257272
it('hands the field record and its nested array to the parse', () => {

packages/spec/src/kernel/metadata-type-schemas.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,16 @@ const PROBE: Record<string, unknown> = {
8080
* actually checked (the other 24 took an early return), so it was the only
8181
* envelope gap anyone could see for as long as that probe was green — and it
8282
* outlasted every gap the probe was hiding.
83+
*
84+
* **This list is now EMPTY, and that is the end state — not a reason to delete
85+
* it.** Every registered type declares the envelope its loader stamps. Keeping
86+
* the empty set means the `DECLARES the protection envelope` case below runs
87+
* over ALL types with no exemptions, so the day a new registered type ships
88+
* without the spread it fails immediately rather than being quietly added here.
89+
* If you find yourself adding a name back, that is a bug being filed, not an
90+
* exemption being granted.
8391
*/
84-
const UNDECLARED_ENVELOPE = new Set<string>([
85-
'action',
86-
]);
92+
const UNDECLARED_ENVELOPE = new Set<string>([]);
8793

8894
/**
8995
* Every object shape reachable from `schema`, unwrapping the wrappers the
@@ -218,7 +224,7 @@ describe('registered metadata types', () => {
218224
* type fails this suite until the list shrinks, so the list cannot outlive the
219225
* debt and start exempting types that no longer need exempting.
220226
*/
221-
const STILL_STRIP = new Set<string>(['action', 'view']);
227+
const STILL_STRIP = new Set<string>(['view']);
222228

223229
/** The registered schema's own top-level posture: `.strict()` sets a `never` catchall. */
224230
function topLevelPosture(schema: unknown, depth = 0): 'strict' | 'strip' | null {
@@ -285,7 +291,7 @@ describe('#4001 — registered-type closure is derived, not tallied', () => {
285291
it('reports the campaign number so a reader never has to count', () => {
286292
const closed = types.filter((t) => !STILL_STRIP.has(t));
287293
expect(closed.length + STILL_STRIP.size).toBe(types.length);
288-
expect(closed.length).toBe(23);
294+
expect(closed.length).toBe(24);
289295
expect(types.length).toBe(25);
290296
});
291297
});

0 commit comments

Comments
 (0)