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
34 changes: 34 additions & 0 deletions .changeset/d3-resume-authority-supportspause-enforced.md
Original file line number Diff line number Diff line change
@@ -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 接受什么)逐字节未变 —— 本条只动账本散文与其生成产物。
2 changes: 1 addition & 1 deletion docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions packages/spec/src/migrations/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 9 additions & 4 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading