From 188d45079939d3299bdf4fb96de53463b6f7000b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:11:10 +0000 Subject: [PATCH] =?UTF-8?q?docs(spec):=20D3=20=E8=B4=A6=E6=9C=AC=20resumeA?= =?UTF-8?q?uthority=20=E4=B8=80=E6=9D=A1=E4=B8=8D=E5=86=8D=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E6=89=8B=E5=B7=A5=E6=8E=92=E6=9F=A5=20supportsPause?= =?UTF-8?q?=20=E4=B8=8D=E4=B8=80=E8=87=B4=20(#6844)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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" 行,故一并提交重新生成的产物。刻意不过度更正:守卫不判「未注册描述符」 的执行器,这一残留写进了新文案。受理面逐字节未变。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk --- ...resume-authority-supportspause-enforced.md | 34 +++++++++++ docs/protocol-upgrade-guide.md | 2 +- .../spec/src/migrations/migrations.test.ts | 57 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 13 +++-- 4 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 .changeset/d3-resume-authority-supportspause-enforced.md diff --git a/.changeset/d3-resume-authority-supportspause-enforced.md b/.changeset/d3-resume-authority-supportspause-enforced.md new file mode 100644 index 0000000000..f943b51f40 --- /dev/null +++ b/.changeset/d3-resume-authority-supportspause-enforced.md @@ -0,0 +1,34 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): D3 迁移账本里 `resumeAuthority` 那条的验收标准不再要求手工排查 `supportsPause` 不一致 —— #6746 之后引擎自己会拒绝 (#6844) + +`MIGRATIONS_BY_MAJOR[17].semantic` 中 `action-descriptor-resume-authority-default-flip` +一条的 `acceptanceCriteria`,结尾的 ⚠️ 子句写的是: + +> `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. + +写下时属实,PR #6746(#6667,2026-08-08 合入)之后不再属实: +`AutomationEngine.refuseUndeclaredSuspension` 正是拒绝这一类执行器的,拒绝点在 +`executeNode` 里每个 `result.suspend === true` 都要经过的那一个接缝 +(`packages/services/service-automation/src/engine.ts`)。于是一个 v17 升级者被告知 +去手工审计一类引擎已经自己拒绝的错误,而且被告知这个不一致「没有声音」,实际上它很响。 + +这不是内部注释:该字符串被 `gen:upgrade-guide` 逐字投影进 +`docs/protocol-upgrade-guide.md` 的 "Done when" 行,而那正是升级作者(依 ADR-0033, +往往是 AI)读的那一行,所以本 PR 一并提交了重新生成的产物。 + +**改后的说法,以及为什么不是「什么都不用查了」**:两条告警通道(启动告警、 +`check:resume-authority-declared`)确实仍然都以 `supportsPause: true` 为触发条件, +所以「不被任何告警通道覆盖」这半句保留;变化的是它现在会在运行时被拒绝,且属于 +guard-class —— `fault` 边路由不了(`refuseNode` 打的是 `errorClass: 'guard'`, +`executeNode` 在 `errorClass === 'guard'` 时不去找 `fault` 边)。同时刻意没有过度 +更正:该守卫明确不判「完全没有注册描述符」的执行器(engine.ts 中 +"What it does NOT judge → Silence",由 `supports-pause-runtime-enforcement.test.ts` +的 descriptor-less 用例钉住),这类执行器的暂停照样会被创建,只在 resume 路由上才 +被拒绝,所以这一条作为仍需留意的残留被写进了新文案。 + +受理面(schema 接受什么)逐字节未变 —— 本条只动账本散文与其生成产物。 diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index a6f877db0d..8f013ea1c4 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -392,7 +392,7 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh - 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. - **`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 - 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. - - 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. + - 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). - **`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 - 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). - 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. diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 167ff1c7ef..762c022915 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -154,6 +154,63 @@ describe('migration chain (ADR-0087 D3)', () => { }); }); + // Third of the same class, one field over again: `acceptanceCriteria` is the + // "Done when" line `gen:upgrade-guide` projects verbatim, so a caveat that has + // been overtaken by an enforcement is a published instruction to hand-audit a + // mistake the engine now refuses on its own. #6746 (#6667) added + // `AutomationEngine.refuseUndeclaredSuspension` + // (`packages/services/service-automation/src/engine.ts`), called from + // `executeNode` on every `result.suspend === true`, which made this entry's + // "warned about by NEITHER channel — check those by hand" false in the + // direction that costs a reader work. #6749 fixed the TSDoc half; this is the + // registry channel it explicitly excluded (#6844). + describe('protocol-17 #5561 entry — supportsPause is enforced now, so stop asking for a hand-audit (#6844)', () => { + const entry = () => + MIGRATIONS_BY_MAJOR[17]!.semantic.find( + (s) => s.id === 'action-descriptor-resume-authority-default-flip', + ); + + it('finds the entry, and it still states the resumeAuthority criterion (anti-vacuity)', () => { + // Guards the whole block against passing because the entry vanished: every + // negative below is vacuously true on `undefined`, and a `.find()` that + // stops matching is exactly how that happens. + expect(entry()).toBeDefined(); + expect(entry()!.acceptanceCriteria).toMatch(/resumeAuthority/); + expect(entry()!.acceptanceCriteria).toMatch(/supportsPause/); + }); + + it('no longer tells the reader to hand-check the supportsPause mismatch', () => { + // Pinned on the INSTRUCTION, not on wording: the entry may still name the + // old gap in order to say it closed — going quiet would leave a reader who + // remembers the published guide still doing the audit by hand. + const a = entry()!.acceptanceCriteria; + expect(a).not.toMatch(/check (those|them) by\s+hand/i); + expect(a).not.toMatch(/is a declaration nothing\s+enforces/i); + expect(a).not.toMatch(/warned about by NEITHER channel/i); + }); + + it('names the enforcing mechanism and that a `fault` edge cannot route it', () => { + // Matched by idiom, not by sentence: the reader has to be able to FIND the + // guard, which is what distinguishes this from a bare "it is enforced now". + const a = entry()!.acceptanceCriteria; + expect(a).toMatch(/refuseUndeclaredSuspension/); + expect(a).toMatch(/#6667/); + expect(a).toMatch(/guard-class/); + expect(a).toMatch(/`fault` edge/); + }); + + it('does not over-correct into "nothing left to check"', () => { + // The gate deliberately does NOT judge a descriptor-less executor + // (engine.ts `refuseUndeclaredSuspension`, "What it does NOT judge → + // Silence"; pinned by `supports-pause-runtime-enforcement.test.ts`'s + // descriptor-less case). Claiming total coverage would be the same defect + // as the old over-claim, pointing the other way. + const a = entry()!.acceptanceCriteria; + expect(a).toMatch(/NO descriptor/); + expect(a).toMatch(/resume route/); + }); + }); + describe('composition (cross-major is the designed-for case)', () => { it('composes only the steps in (from, to]', () => { const chain = composeMigrationChain(10, 11); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 53c744409f..7ccb782cee 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -2753,10 +2753,15 @@ const step17: MigrationStep = { + '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.', + + '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).', }, { id: 'export-field-meta-constraints-retired',