Skip to content

Commit e650d67

Browse files
docs(spec): D3 账本 resumeAuthority 一条不再要求手工排查 supportsPause 不一致 (#6844) (#6875)
`MIGRATIONS_BY_MAJOR[17].semantic` 中 `action-descriptor-resume-authority-default-flip` 的 `acceptanceCriteria` 结尾断言该不一致「不被任何通道告警,请手工排查」。PR #6746 (#6667) 之后不成立:`AutomationEngine.refuseUndeclaredSuspension` 在 `executeNode` 中 每个 `result.suspend === true` 必经的接缝上拒绝它,且为 guard-class,`fault` 边路由不了。 该字符串由 `gen:upgrade-guide` 逐字投影进 `docs/protocol-upgrade-guide.md` 的 "Done when" 行,故一并提交重新生成的产物。刻意不过度更正:守卫不判「未注册描述符」 的执行器,这一残留写进了新文案。受理面逐字节未变。 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7094957 commit e650d67

4 files changed

Lines changed: 101 additions & 5 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): D3 迁移账本里 `resumeAuthority` 那条的验收标准不再要求手工排查 `supportsPause` 不一致 —— #6746 之后引擎自己会拒绝 (#6844)
6+
7+
`MIGRATIONS_BY_MAJOR[17].semantic``action-descriptor-resume-authority-default-flip`
8+
一条的 `acceptanceCriteria`,结尾的 ⚠️ 子句写的是:
9+
10+
> `supportsPause` is a declaration nothing enforces (#5703), so an executor whose
11+
> `execute()` returns `suspend: true` while leaving `supportsPause` false is warned
12+
> about by NEITHER channel — check those by hand against the same rule.
13+
14+
写下时属实,PR #6746(#6667,2026-08-08 合入)之后不再属实:
15+
`AutomationEngine.refuseUndeclaredSuspension` 正是拒绝这一类执行器的,拒绝点在
16+
`executeNode` 里每个 `result.suspend === true` 都要经过的那一个接缝
17+
(`packages/services/service-automation/src/engine.ts`)。于是一个 v17 升级者被告知
18+
去手工审计一类引擎已经自己拒绝的错误,而且被告知这个不一致「没有声音」,实际上它很响。
19+
20+
这不是内部注释:该字符串被 `gen:upgrade-guide` 逐字投影进
21+
`docs/protocol-upgrade-guide.md` 的 "Done when" 行,而那正是升级作者(依 ADR-0033,
22+
往往是 AI)读的那一行,所以本 PR 一并提交了重新生成的产物。
23+
24+
**改后的说法,以及为什么不是「什么都不用查了」**:两条告警通道(启动告警、
25+
`check:resume-authority-declared`)确实仍然都以 `supportsPause: true` 为触发条件,
26+
所以「不被任何告警通道覆盖」这半句保留;变化的是它现在会在运行时被拒绝,且属于
27+
guard-class —— `fault` 边路由不了(`refuseNode` 打的是 `errorClass: 'guard'`,
28+
`executeNode``errorClass === 'guard'` 时不去找 `fault` 边)。同时刻意没有过度
29+
更正:该守卫明确不判「完全没有注册描述符」的执行器(engine.ts 中
30+
"What it does NOT judge → Silence",由 `supports-pause-runtime-enforcement.test.ts`
31+
的 descriptor-less 用例钉住),这类执行器的暂停照样会被创建,只在 resume 路由上才
32+
被拒绝,所以这一条作为仍需留意的残留被写进了新文案。
33+
34+
受理面(schema 接受什么)逐字节未变 —— 本条只动账本散文与其生成产物。

docs/protocol-upgrade-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ The same descriptor loses a key in this step, and the pairing is the point (#674
394394
- Done when: No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, `ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, `ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, `ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 on any that survives. Every author who was pointed at L2 has been re-pointed by name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends `ETLPipeline` as L1's destination and no longer advertises a transformation-type table. The surviving layers still parse unchanged — a connector declaring `syncConfig` and an import declaring `mapping.transform` both behave exactly as they did in 16.x.
395395
- **`action-descriptor-resume-authority-default-flip`**`automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)` → an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning
396396
- Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.
397-
- Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is a declaration nothing enforces (#5703), so an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is warned about by NEITHER channel — check those by hand against the same rule.
397+
- Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is no longer the declaration nothing enforced (#5703, closed by #6667): an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is still warned about by neither warning channel, but `AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the one seam every suspension passes through — a guard-class failure no `fault` edge routes — so it needs no hand-check. The residue that does: an executor registering NO descriptor declares nothing for either warning or the refusal to read, so its pauses are still created and refused only later, on the resume route (#5561).
398398
- **`export-field-meta-constraints-retired`**`@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)` → the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift
399399
- Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred).
400400
- Done when: No code of yours reads any of the eight off a `buildFieldMetaMap` / `prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / `.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an `ExportFieldMeta`-typed value; each hit moves to the object schema you already passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, so an untyped or `any`-typed read compiles clean and silently becomes `undefined` — assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. Note `hasDefault` has no one-to-one replacement key: it was the derived predicate `defaultValue != null`, mirroring the engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply that same `!= null` test yourself.

packages/spec/src/migrations/migrations.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,63 @@ describe('migration chain (ADR-0087 D3)', () => {
154154
});
155155
});
156156

157+
// Third of the same class, one field over again: `acceptanceCriteria` is the
158+
// "Done when" line `gen:upgrade-guide` projects verbatim, so a caveat that has
159+
// been overtaken by an enforcement is a published instruction to hand-audit a
160+
// mistake the engine now refuses on its own. #6746 (#6667) added
161+
// `AutomationEngine.refuseUndeclaredSuspension`
162+
// (`packages/services/service-automation/src/engine.ts`), called from
163+
// `executeNode` on every `result.suspend === true`, which made this entry's
164+
// "warned about by NEITHER channel — check those by hand" false in the
165+
// direction that costs a reader work. #6749 fixed the TSDoc half; this is the
166+
// registry channel it explicitly excluded (#6844).
167+
describe('protocol-17 #5561 entry — supportsPause is enforced now, so stop asking for a hand-audit (#6844)', () => {
168+
const entry = () =>
169+
MIGRATIONS_BY_MAJOR[17]!.semantic.find(
170+
(s) => s.id === 'action-descriptor-resume-authority-default-flip',
171+
);
172+
173+
it('finds the entry, and it still states the resumeAuthority criterion (anti-vacuity)', () => {
174+
// Guards the whole block against passing because the entry vanished: every
175+
// negative below is vacuously true on `undefined`, and a `.find()` that
176+
// stops matching is exactly how that happens.
177+
expect(entry()).toBeDefined();
178+
expect(entry()!.acceptanceCriteria).toMatch(/resumeAuthority/);
179+
expect(entry()!.acceptanceCriteria).toMatch(/supportsPause/);
180+
});
181+
182+
it('no longer tells the reader to hand-check the supportsPause mismatch', () => {
183+
// Pinned on the INSTRUCTION, not on wording: the entry may still name the
184+
// old gap in order to say it closed — going quiet would leave a reader who
185+
// remembers the published guide still doing the audit by hand.
186+
const a = entry()!.acceptanceCriteria;
187+
expect(a).not.toMatch(/check (those|them) by\s+hand/i);
188+
expect(a).not.toMatch(/is a declaration nothing\s+enforces/i);
189+
expect(a).not.toMatch(/warned about by NEITHER channel/i);
190+
});
191+
192+
it('names the enforcing mechanism and that a `fault` edge cannot route it', () => {
193+
// Matched by idiom, not by sentence: the reader has to be able to FIND the
194+
// guard, which is what distinguishes this from a bare "it is enforced now".
195+
const a = entry()!.acceptanceCriteria;
196+
expect(a).toMatch(/refuseUndeclaredSuspension/);
197+
expect(a).toMatch(/#6667/);
198+
expect(a).toMatch(/guard-class/);
199+
expect(a).toMatch(/`fault` edge/);
200+
});
201+
202+
it('does not over-correct into "nothing left to check"', () => {
203+
// The gate deliberately does NOT judge a descriptor-less executor
204+
// (engine.ts `refuseUndeclaredSuspension`, "What it does NOT judge →
205+
// Silence"; pinned by `supports-pause-runtime-enforcement.test.ts`'s
206+
// descriptor-less case). Claiming total coverage would be the same defect
207+
// as the old over-claim, pointing the other way.
208+
const a = entry()!.acceptanceCriteria;
209+
expect(a).toMatch(/NO descriptor/);
210+
expect(a).toMatch(/resume route/);
211+
});
212+
});
213+
157214
describe('composition (cross-major is the designed-for case)', () => {
158215
it('composes only the steps in (from, to]', () => {
159216
const chain = composeMigrationChain(10, 11);

packages/spec/src/migrations/registry.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2767,10 +2767,15 @@ const step17: MigrationStep = {
27672767
+ 'on each of your pausing nodes can still be continued the way you intend: a resume '
27682768
+ "through the generic route succeeds for the ones you declared `'any'`, and answers "
27692769
+ "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue "
2770-
+ 'through your own service API instead. ⚠️ `supportsPause` is a declaration nothing '
2771-
+ 'enforces (#5703), so an executor whose `execute()` returns `suspend: true` while '
2772-
+ 'leaving `supportsPause` false is warned about by NEITHER channel — check those by '
2773-
+ 'hand against the same rule.',
2770+
+ 'through your own service API instead. ⚠️ `supportsPause` is no longer the '
2771+
+ 'declaration nothing enforced (#5703, closed by #6667): an executor whose '
2772+
+ '`execute()` returns `suspend: true` while leaving `supportsPause` false is still '
2773+
+ 'warned about by neither warning channel, but '
2774+
+ '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the '
2775+
+ 'one seam every suspension passes through — a guard-class failure no `fault` edge '
2776+
+ 'routes — so it needs no hand-check. The residue that does: an executor registering '
2777+
+ 'NO descriptor declares nothing for either warning or the refusal to read, so its '
2778+
+ 'pauses are still created and refused only later, on the resume route (#5561).',
27742779
},
27752780
{
27762781
id: 'export-field-meta-constraints-retired',

0 commit comments

Comments
 (0)