diff --git a/.changeset/loud-pausing-resume-authority.md b/.changeset/loud-pausing-resume-authority.md new file mode 100644 index 0000000000..a37fa5a55b --- /dev/null +++ b/.changeset/loud-pausing-resume-authority.md @@ -0,0 +1,27 @@ +--- +'@objectstack/spec': patch +--- + +automation: `ActionDescriptor.resumeAuthority` no longer defaults to `'any'` — an +omission is now a distinct, reportable fact (#5561, from #3823) + +The #3801 resume gate keys on the suspended node, so it covers a pausing node type +exactly when that type's author declared who may resume it. The schema default made +that impossible to check: Zod filled the key inside `defineActionDescriptor`, so +"the author chose `'any'`" and "the author never considered it" produced +byte-identical descriptors. #3823 is what the erasure cost — 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 field is now optional with no default, and absent means absent. Two seams read +it: `AutomationEngine.registerNodeExecutor` warns once per node type when a +`supportsPause` descriptor omits it, and the new `check:resume-authority-declared` +gate fails CI on an omission in this repo's own executors. + +**Not a behaviour change.** The engine already resolved the value with `?? 'any'`, +so an undeclared pausing type is still raw-resumable exactly as before — loudly now +instead of silently. Nothing needs migrating: an executor that declared +`resumeAuthority` keeps its value, and one that omitted it keeps today's semantics +and gains a warning telling it to state its intent. Making omission mean +*fail-closed* is a breaking change still tracked on #5561 for a version window that +allows it; it is now a one-expression change rather than a schema migration. diff --git a/.changeset/warn-undeclared-resume-authority.md b/.changeset/warn-undeclared-resume-authority.md new file mode 100644 index 0000000000..44a3f00fa0 --- /dev/null +++ b/.changeset/warn-undeclared-resume-authority.md @@ -0,0 +1,20 @@ +--- +'@objectstack/service-automation': patch +--- + +automation: a pausing node type that never declares `resumeAuthority` is now named +at registration, and the four pausing built-ins declare theirs (#5561) + +`registerNodeExecutor` warns once per node type (per engine instance) when a +descriptor declares `supportsPause: true` and omits `resumeAuthority` — the state in +which the #3801 resume gate silently treats every pause that type creates as +raw-resumable through the generic resume route. The line names the two legal values +and says that declaring `'any'` explicitly silences it and changes no behaviour, so +a node whose pause really is open to the route is not pushed toward `'service'` to +quieten a log. + +`screen`, `wait`, `subflow` and `map` now declare `resumeAuthority: 'any'` +explicitly. Each was already correct on its own terms — it was inheriting the value +rather than stating it — so the warning names nothing on a stock boot today and only +catches future omissions. Authority resolution is unchanged: `resolveResumeAuthority` +still resolves an absent value to `'any'`. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 07772f3bfe..07b6bb505e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -381,6 +381,24 @@ jobs: - name: Engine test-double contract gate run: pnpm check:engine-double-contract + # Resume-authority declaration gate (#5561, from #3823). The #3801 resume + # gate keys on the SUSPENDED NODE, so it covers a pausing node type exactly + # when that type's author remembered to declare `resumeAuthority`. #3823 is + # what forgetting costs: ADR-0044 pointed a revise edge at a generic `wait`, + # `wait` is legitimately 'any', and a pause standing in a service-owned + # position inherited that value with nobody choosing it — an unaudited + # resubmit plus a destroyed remote run. Until #5561 removed the schema + # default the omission was not even observable (Zod filled the key, so it + # parsed byte-identically to an explicit 'any'); now a pausing descriptor + # that never states its authority cannot merge. Shipped sources only — + # fixtures deliberately construct the omission to test the engine's + # registration warning. Static AST, no build needed, so it belongs in this + # job. Runs its own --self-test first: the detector can be broken while + # every descriptor is fine, and a scan that stops matching would report OK + # while reading nothing (#4868's family). + - name: Resume-authority declaration gate + run: pnpm check:resume-authority-declared + typecheck: name: TypeScript Type Check runs-on: ubuntu-latest diff --git a/content/docs/references/automation/node-executor.mdx b/content/docs/references/automation/node-executor.mdx index a3c0542523..8a40d02db7 100644 --- a/content/docs/references/automation/node-executor.mdx +++ b/content/docs/references/automation/node-executor.mdx @@ -77,7 +77,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **needsOutbox** | `boolean` | ✅ | Dispatch via service-messaging outbox (retry/idempotency/dead-letter) | | **isAsync** | `boolean` | ✅ | Suspends the flow awaiting an external reply | | **handlerContract** | `Enum<'none' \| 'pure'>` | ✅ | Effect contract for author-supplied code this action invokes: 'none' (invokes none) or 'pure' (must not write — it returns a value and the flow graph persists it) | -| **resumeAuthority** | `Enum<'any' \| 'service'>` | ✅ | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals) | +| **resumeAuthority** | `Enum<'any' \| 'service'>` | optional | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Deliberately has no default — an omission is a distinct, reportable fact, and a pausing node type that omits it is warned about at registration (#5561) | | **maturity** | `Enum<'ga' \| 'beta' \| 'reserved'>` | ✅ | Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) | | **source** | `Enum<'builtin' \| 'plugin'>` | ✅ | builtin = platform baseline; plugin = third-party contributed | | **deprecated** | `boolean` | ✅ | Deprecated alias kept for back-compat | diff --git a/package.json b/package.json index f7e8e003b0..9b78044814 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "check:type-check-coverage": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs", "check:driver-conformance": "node scripts/check-driver-conformance.mjs --self-test && node scripts/check-driver-conformance.mjs", "check:engine-double-contract": "node scripts/check-engine-double-contract.mjs --self-test && node scripts/check-engine-double-contract.mjs", + "check:resume-authority-declared": "node scripts/check-resume-authority-declared.mjs --self-test && node scripts/check-resume-authority-declared.mjs", "check:stall-guard": "node scripts/run-with-stall-guard.mjs --self-test" }, "keywords": [ diff --git a/packages/services/service-automation/src/builtin/map-node.ts b/packages/services/service-automation/src/builtin/map-node.ts index 5cd8eb8e63..330e8ff78f 100644 --- a/packages/services/service-automation/src/builtin/map-node.ts +++ b/packages/services/service-automation/src/builtin/map-node.ts @@ -52,6 +52,11 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v // Each item's subflow may pause, so the map suspends and resumes per item. supportsPause: true, isAsync: true, + // As with `subflow`, `'any'` here is not the authority that applies: the + // #3801 gate follows the `map:` correlation to the in-flight item's child + // run and judges that node instead — judging the loop rather than the item + // is precisely the hole #3853 closed. Stated rather than inherited (#5561). + resumeAuthority: 'any', // Structured config form for the flow designer (ADR-0018). Mirrors the // objectui hardcoded `map` field group field-for-field, so the online // (schema-driven) form matches the offline one (objectui #2670 Phase 3 / diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 9f2ca5accb..2015944299 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -48,6 +48,11 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext icon: 'window', category: 'human', source: 'builtin', // Human-input nodes suspend the flow awaiting input. supportsPause: true, isAsync: true, + // The generic resume route IS this node's intended door: the flow-runner + // collects the inputs and hands them back as the continuation, so there + // is no service decision to route around (#3801). Stated rather than + // inherited from a default — an omission is now a reported fact (#5561). + resumeAuthority: 'any', // Designer form (ADR-0018, #3304) — mirrors objectui's hardcoded `screen` // field group: flat input list OR an object form, plus title/description. // `visibleWhen` is bare CEL (xExpression), `defaults` a free-form keyValue diff --git a/packages/services/service-automation/src/builtin/subflow-node.ts b/packages/services/service-automation/src/builtin/subflow-node.ts index 8ae1dca015..8e069f462d 100644 --- a/packages/services/service-automation/src/builtin/subflow-node.ts +++ b/packages/services/service-automation/src/builtin/subflow-node.ts @@ -52,6 +52,12 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext // A child that suspends (approval/screen/wait) suspends this node too — // the parent run pauses here and resumes when the child completes. supportsPause: true, + // `'any'` on this node is not the authority that ends up applying: the + // #3801 gate follows the `subflow:` correlation down to the CHILD and + // judges the node the signal actually lands on (#3853), so a parent parked + // above a pending approval is still refused. Stated rather than inherited + // so the omission-warning's silence here is a decision, not a gap (#5561). + resumeAuthority: 'any', }), async execute(node, variables, context) { // #4343 — the contract is parsed before anything runs, the same seam the diff --git a/packages/services/service-automation/src/builtin/wait-node.ts b/packages/services/service-automation/src/builtin/wait-node.ts index e67155c8aa..366e8e9d8a 100644 --- a/packages/services/service-automation/src/builtin/wait-node.ts +++ b/packages/services/service-automation/src/builtin/wait-node.ts @@ -165,6 +165,12 @@ export function registerWaitNode(engine: AutomationEngine, ctx: PluginContext): // Durable pause — the run suspends and resumes later (timer/signal). supportsPause: true, isAsync: true, + // An external producer is *meant* to resume a signal wait, so the generic + // route is the door (#3801). Stated rather than inherited from a default: + // #3823 is what inheriting it costs — ADR-0044 pointed a revise edge at a + // generic `wait`, and the pause in that service-owned position took this + // value without anyone choosing it (#5561). + resumeAuthority: 'any', }), async execute(node, variables, _context) { // `waitEventConfig` is the whole contract (`FlowNodeSchema`, flow.zod.ts). diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 13f5f489dd..6ea0cc46c6 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -1138,6 +1138,15 @@ export class AutomationEngine implements IAutomationService { * and a module-level flag would report only whichever engine ran first. */ private nodeTypeSealOmissionWarned = false; + /** + * Node types already named by {@link warnIfResumeAuthorityUndeclared} + * (#5561). Per **instance** and per **type**, for the same reason + * {@link nodeTypeSealOmissionWarned} is per instance: a hot-reload or a + * multi-tenant host re-registers the same executor repeatedly, and one + * omission must read as one finding rather than as a log that grows with + * uptime. + */ + private readonly resumeAuthorityOmissionWarned = new Set(); private triggers = new Map(); /** * Flows currently wired to a trigger, keyed by flow name → the trigger @@ -1334,11 +1343,60 @@ export class AutomationEngine implements IAutomationService { ); } this.actionDescriptors.set(descriptorType, executor.descriptor); + this.warnIfResumeAuthorityUndeclared(executor.descriptor); } this.logger.info(`Node executor registered: ${executor.type}`); } + /** + * Name a pausing node type that never declared WHO may resume the pauses it + * creates (#5561, the tracking item ADR-0044's amendment deferred). + * + * `resumeAuthority` carries no schema default precisely so this warning can + * exist: with `.default('any')` an omission parsed into a descriptor + * byte-identical to an author's explicit `'any'`, so the fact was gone + * before the engine ever saw the object. Absent now means absent, and a + * pausing type that leaves it absent is fail-open by omission rather than + * by decision — #3823 is what that costs (a revise pause standing in a + * service-owned position inherited `wait`'s legitimate `'any'`, and a raw + * resume walked past an unrecorded decision). + * + * **What it asserts, and why that is safe here.** Only the static fact that + * THIS descriptor omits the key — a property of the object being registered, + * fixed at authoring time, which no later registration can contradict. It + * reads no registry and draws no conclusion from anything being absent from + * one, so it is not the shape AGENTS.md "Startup registry reads" forbids and + * needs no seal flag (contrast {@link warnIfNodeTypeVocabularyNeverSealed}, + * which reports a missing CALL for the same reason). Whether the omission + * *matters* at run time is deliberately not judged: the engine still + * resolves absent to `'any'` ({@link resolveResumeAuthority}), so nothing + * about today's behaviour changes. + * + * **Blind spot, stated up front:** the trigger is `supportsPause`, itself a + * declaration no execution path enforces (#5703) — a run pauses because + * `execute()` returned `suspend: true`. An executor that suspends while + * leaving `supportsPause` false is therefore fail-open AND silent here. + * `check:resume-authority-declared` catches this repo's own executors at + * authoring time; #5703 tracks the runtime half. + */ + private warnIfResumeAuthorityUndeclared(descriptor: ActionDescriptor): void { + if (descriptor.supportsPause !== true) return; + if (descriptor.resumeAuthority !== undefined) return; + if (this.resumeAuthorityOmissionWarned.has(descriptor.type)) return; + this.resumeAuthorityOmissionWarned.add(descriptor.type); + this.logger.warn( + `[automation] node type '${descriptor.type}' declares supportsPause but never declares ` + + `resumeAuthority, so the #3801 resume gate treats every pause it creates as raw-resumable ` + + `through the generic route (POST /automation/:name/runs/:runId/resume) — fail-open by omission ` + + `rather than by decision, which is how #3823 walked past an unrecorded approval decision. ` + + `Declare it on the descriptor: 'any' if that route IS the intended door (a screen's collected ` + + `inputs, a signal wait's external producer), or 'service' if resuming is the tail of a decision ` + + `some service must authorize and record first. Declaring 'any' explicitly silences this and ` + + `changes no behaviour. Reported once per node type per engine.`, + ); + } + /** * Register a **deprecated alias** of a canonical node type (ADR-0018 M3). * @@ -2704,8 +2762,16 @@ export class AutomationEngine implements IAutomationService { * snapshotting at alias-registration time) also keeps it correct whichever * order the two register in. No alias of a pausing type exists today; this * keeps it from becoming a hole the day one does. + * + * The `?? 'any'` is load-bearing in a second way since #5561: with no schema + * default on `resumeAuthority`, an undeclared descriptor arrives with the key + * absent and this is the one place that resolves it. It resolves fail-OPEN, + * exactly as the removed default did — step one of #5561 changed nothing + * here, it only made the omission audible at registration. Flipping this + * fallback to `'service'` is the breaking half still tracked on #5561, and + * it is this single expression. */ - private resolveResumeAuthority(nodeType: string): ActionDescriptor['resumeAuthority'] { + private resolveResumeAuthority(nodeType: string): NonNullable { let descriptor = this.actionDescriptors.get(nodeType); for (let hop = 0; descriptor?.aliasOf && hop < AutomationEngine.MAX_ALIAS_HOPS; hop++) { const canonical = this.actionDescriptors.get(descriptor.aliasOf); diff --git a/packages/services/service-automation/src/resume-authority-declaration.test.ts b/packages/services/service-automation/src/resume-authority-declaration.test.ts new file mode 100644 index 0000000000..2b683960d3 --- /dev/null +++ b/packages/services/service-automation/src/resume-authority-declaration.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `resumeAuthority` omission warning at descriptor registration (#5561). + * + * The #3801 resume gate only covers a pausing node type whose author remembered + * to declare WHO may resume it. While `ActionDescriptorSchema.resumeAuthority` + * carried `.default('any')`, an omission could not even be observed: Zod filled + * the key inside `defineActionDescriptor`, producing a descriptor byte-identical + * to an author's explicit `'any'`. So a forgotten declaration was fail-open and + * unreportable — #3823 is the incident (a revise pause in a service-owned + * position inherited `wait`'s legitimate `'any'`, and a raw resume walked past a + * decision nothing had recorded). + * + * The default is gone, so absent means absent, and `registerNodeExecutor` says + * so once per node type. Runtime authority resolution is unchanged — absent + * still resolves to `'any'` — which is why this file asserts about the LOG and + * `resume-authority-gate.test.ts` still asserts about behaviour. + */ + +import { describe, it, expect } from 'vitest'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import type { ActionDescriptor } from '@objectstack/spec/automation'; +import { AutomationEngine } from './engine.js'; +import { installBuiltinNodes } from './builtin/index.js'; + +/** The stable substring this file counts findings by. */ +const OMISSION = 'declares supportsPause but never declares'; + +/** An engine whose `logger.warn` lines land in `warnings`. */ +function engineWith(warnings: string[]): AutomationEngine { + return new AutomationEngine(loggerInto(warnings)); +} + +function loggerInto(warnings: string[]): any { + const logger: any = { + info() {}, + debug() {}, + error() {}, + warn(msg: string) { warnings.push(msg); }, + child() { return logger; }, + }; + return logger; +} + +/** A registrable executor for `type`, publishing `descriptor` fields verbatim. */ +function executor(type: string, fields: Partial) { + return { + type, + descriptor: defineActionDescriptor({ type, version: '1.0.0', name: type, ...fields }), + async execute() { return { success: true }; }, + }; +} + +function omissions(warnings: string[]): string[] { + return warnings.filter((w) => w.includes(OMISSION)); +} + +describe('resumeAuthority omission warning (#5561)', () => { + it('names a pausing node type that never declared resumeAuthority, exactly once', () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + + engine.registerNodeExecutor(executor('plugin_pause', { supportsPause: true })); + + const found = omissions(warnings); + expect(found).toHaveLength(1); + expect(found[0]).toContain("'plugin_pause'"); + }); + + it('tells the author how to silence it, and that silencing changes no behaviour', () => { + const warnings: string[] = []; + engineWith(warnings).registerNodeExecutor(executor('plugin_pause', { supportsPause: true })); + + const [line] = omissions(warnings); + // Single self-sufficient line: the two legal values, the incident, and the + // fact that declaring 'any' is a no-op at run time (so an author whose node + // really is open does not feel pushed into 'service' to quieten a log). + expect(line).toContain("'any'"); + expect(line).toContain("'service'"); + expect(line).toContain('#3801'); + expect(line).toContain('#3823'); + expect(line).toContain("Declaring 'any' explicitly silences this and changes no behaviour"); + expect(line.split('\n')).toHaveLength(1); + }); + + it('does not double-count a re-registration of the same type (hot reload, multi-tenant seed)', () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + + engine.registerNodeExecutor(executor('plugin_pause', { supportsPause: true })); + engine.registerNodeExecutor(executor('plugin_pause', { supportsPause: true })); + engine.registerNodeExecutor(executor('plugin_pause', { supportsPause: true })); + + expect(omissions(warnings)).toHaveLength(1); + }); + + it('dedupes per TYPE, not per engine — a second omission is a second finding', () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + + engine.registerNodeExecutor(executor('pause_a', { supportsPause: true })); + engine.registerNodeExecutor(executor('pause_b', { supportsPause: true })); + + const found = omissions(warnings); + expect(found).toHaveLength(2); + expect(found.some((w) => w.includes("'pause_a'"))).toBe(true); + expect(found.some((w) => w.includes("'pause_b'"))).toBe(true); + }); + + it('is per engine instance — an embedded host that builds one engine per tenant hears it each time', () => { + const first: string[] = []; + const second: string[] = []; + + engineWith(first).registerNodeExecutor(executor('plugin_pause', { supportsPause: true })); + engineWith(second).registerNodeExecutor(executor('plugin_pause', { supportsPause: true })); + + expect(omissions(first)).toHaveLength(1); + expect(omissions(second)).toHaveLength(1); + }); + + it("stays silent when 'any' is declared explicitly — same value, a decision instead of a gap", () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + + engine.registerNodeExecutor(executor('open_pause', { supportsPause: true, resumeAuthority: 'any' })); + + expect(omissions(warnings)).toHaveLength(0); + }); + + it("stays silent when 'service' is declared", () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + + engine.registerNodeExecutor(executor('gated_pause', { supportsPause: true, resumeAuthority: 'service' })); + + expect(omissions(warnings)).toHaveLength(0); + }); + + it('stays silent for a non-pausing type that omits it — there is no pause to authorize', () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + + // Omitted entirely (the common case) and declared false (explicit). + engine.registerNodeExecutor(executor('send_sms', {})); + engine.registerNodeExecutor(executor('send_fax', { supportsPause: false })); + + expect(omissions(warnings)).toHaveLength(0); + }); + + it('stays silent for an executor that publishes no descriptor at all', () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + + engine.registerNodeExecutor({ type: 'bare', async execute() { return { success: true }; } }); + + expect(omissions(warnings)).toHaveLength(0); + }); + + /** + * The zero this step lands at: every built-in pausing type now declares its + * authority, so a real boot names nothing. Asserted together with the + * inventory it depends on — a zero that came from registering no pausing + * descriptors at all would be the empty-green trap, passing because nothing + * was produced rather than because the declarations are there. + */ + it('names nothing on a full built-in install, and the four pausing built-ins are why', () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + const ctx: any = { logger: loggerInto([]), getService() { return undefined; } }; + + installBuiltinNodes(engine, ctx); + + const pausing = engine.getActionDescriptors().filter((d) => d.supportsPause === true); + expect(pausing.map((d) => d.type).sort()).toEqual(['map', 'screen', 'subflow', 'wait']); + for (const d of pausing) { + expect(d.resumeAuthority, `${d.type} must declare its resume authority`).toBe('any'); + } + expect(omissions(warnings)).toHaveLength(0); + }); +}); + +describe('resumeAuthority resolution is unchanged by the missing default (#5561)', () => { + /** + * The half that must NOT move in step one: an undeclared pausing type is still + * resolved fail-open, because `resolveResumeAuthority`'s `?? 'any'` is what the + * removed schema default used to do. Flipping that expression to `'service'` is + * the breaking half still tracked on #5561; this pins today's answer so that + * flip cannot happen silently. + */ + it("resolves an undeclared pausing type to 'any', exactly as the removed default did", () => { + const warnings: string[] = []; + const engine = engineWith(warnings); + engine.registerNodeExecutor(executor('plugin_pause', { supportsPause: true })); + + const resolve = (engine as unknown as { + resolveResumeAuthority(t: string): 'any' | 'service'; + }).resolveResumeAuthority.bind(engine); + + expect(resolve('plugin_pause')).toBe('any'); + // Unregistered types keep answering 'any' too — the gate speaks only to + // authorization and leaves machine-state errors to `resumeInternal`. + expect(resolve('never_registered')).toBe('any'); + }); +}); diff --git a/packages/spec/src/automation/node-executor.test.ts b/packages/spec/src/automation/node-executor.test.ts index 2c4694b6c4..9d9971e43a 100644 --- a/packages/spec/src/automation/node-executor.test.ts +++ b/packages/spec/src/automation/node-executor.test.ts @@ -261,8 +261,22 @@ describe('Wait Executor — pause/resume scenario', () => { describe('ActionDescriptorSchema.resumeAuthority', () => { const base = { type: 'demo', version: '1.0.0', name: 'Demo' }; - it("defaults to 'any' — the generic resume route stays the door for screen / wait", () => { - expect(defineActionDescriptor(base).resumeAuthority).toBe('any'); + // Replaces the assertion that pinned the old `.default('any')` (#5561). That + // default is what made an omission unreadable: it produced a descriptor + // byte-identical to an author's explicit `'any'`, so the registration warning + // this field now feeds could not have existed. Absent must stay absent — + // reinstating any default turns the two cases below into one. + it('leaves an omission absent rather than defaulting it — the fact the registration warning reads', () => { + const desc = defineActionDescriptor(base); + expect(desc.resumeAuthority).toBeUndefined(); + expect(Object.hasOwn(desc, 'resumeAuthority')).toBe(false); + }); + + it("keeps an explicit 'any' distinguishable from an omission (the same value, a different fact)", () => { + const declared = defineActionDescriptor({ ...base, supportsPause: true, resumeAuthority: 'any' }); + expect(declared.resumeAuthority).toBe('any'); + expect(Object.hasOwn(declared, 'resumeAuthority')).toBe(true); + expect(Object.hasOwn(defineActionDescriptor({ ...base, supportsPause: true }), 'resumeAuthority')).toBe(false); }); it("accepts 'service' for a node only its owning service may resume", () => { diff --git a/packages/spec/src/automation/node-executor.zod.ts b/packages/spec/src/automation/node-executor.zod.ts index 5d495a9541..7b667ddb6b 100644 --- a/packages/spec/src/automation/node-executor.zod.ts +++ b/packages/spec/src/automation/node-executor.zod.ts @@ -278,7 +278,18 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ .describe('JSON Schema for the node config (drives the designer form; undeclared keys are rejected at registration)'), // ── capabilities ────────────────────────────────────────────────── - /** Supports async pause/resume (e.g. wait, human_task). */ + /** + * Supports async pause/resume (e.g. wait, human_task). + * + * **A declaration, not an enforced fact** (#5703). No execution path reads + * it: a run pauses because the executor's `execute()` returned + * `suspend: true`, and the #3801 resume gate keys on the suspended node's + * `resumeAuthority` alone. What it does drive is authoring-time: the designer + * palette, the registration warning below, and the + * `check:resume-authority-declared` gate. So an executor that suspends + * while leaving this `false` is invisible to both of those — #5703 tracks + * closing that seam. + */ supportsPause: z.boolean().default(false).describe('Supports async pause/resume'), /** Supports mid-execution cancellation. */ supportsCancellation: z.boolean().default(false).describe('Supports cancellation'), @@ -326,9 +337,9 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ * exists — so the node type that *produced* the pause is what decides * whether a raw resume is a legitimate continuation or a bypass. * - * - `'any'` (default) — the caller supplies the continuation and the route - * is the intended door: a `screen` node's collected inputs, a `wait` - * node's external signal. + * - `'any'` — the caller supplies the continuation and the route is the + * intended door: a `screen` node's collected inputs, a `wait` node's + * external signal. * - `'service'` — resuming is a SIDE EFFECT of a decision some service must * authorize and record first, so only that service may drive it. An * `approval` node declares this: `ApprovalService.decide` enforces the @@ -340,9 +351,29 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ * The engine enforces it: a resume of a `'service'` suspension is refused * unless the signal carries the in-process `RESUME_AUTHORITY_SERVICE` * marker — a symbol, so a JSON body can never carry it. + * + * **Carrying no `.default()` is the point** (#5561). A default would make + * "the author decided `'any'`" and "the author never considered it" the same + * value by the time any consumer sees the descriptor: Zod fills the key + * inside {@link defineActionDescriptor}, so the omission becomes + * unrecoverable one function call after it happens — measured, not assumed + * (the two parses are byte-identical). That erasure is how #3823 shipped: + * ADR-0044 pointed a revise edge at a generic `wait`, `wait` is legitimately + * `'any'`, and a pause standing in a service-owned position inherited a + * fail-open value nobody had chosen. Absent therefore means absent, and two + * seams read it: `AutomationEngine.registerNodeExecutor` warns once per node + * type when a `supportsPause` descriptor omits it, and + * `check:resume-authority-declared` fails CI on an omission in this repo's + * own executors. + * + * Runtime semantics are unchanged by that: the engine still resolves an + * absent value to `'any'` (`resolveResumeAuthority`), so omitting it is + * fail-open exactly as before — loudly now instead of silently. Flipping + * that fallback to `'service'` (fail-closed by omission) is the breaking + * half, still tracked on #5561 for a version window that allows it. */ - resumeAuthority: z.enum(['any', 'service']).default('any') - .describe("Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals)"), + resumeAuthority: z.enum(['any', 'service']).optional() + .describe("Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Deliberately has no default — an omission is a distinct, reportable fact, and a pausing node type that omits it is warned about at registration (#5561)"), /** * Runtime maturity of the capability behind this descriptor (ADR-0041 §4). diff --git a/scripts/check-resume-authority-declared.mjs b/scripts/check-resume-authority-declared.mjs new file mode 100644 index 0000000000..51da55b0c4 --- /dev/null +++ b/scripts/check-resume-authority-declared.mjs @@ -0,0 +1,442 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-resume-authority-declared -- a pausing action descriptor must SAY who +// may resume the pauses it creates (objectstack#5561, from objectstack#3823). +// +// node scripts/check-resume-authority-declared.mjs +// node scripts/check-resume-authority-declared.mjs --list +// node scripts/check-resume-authority-declared.mjs --self-test +// node scripts/check-resume-authority-declared.mjs --packages-dir +// +// ## The failure mode this exists for +// +// The #3801 resume gate keys on the SUSPENDED NODE: a pause whose descriptor +// declares `resumeAuthority: 'service'` continues only through the service that +// authorizes and records the decision, while `'any'` leaves the generic route +// (`POST /automation/:name/runs/:runId/resume`) open as the intended door. So the +// gate covers a pausing node type exactly when its author remembered the field. +// +// #3823 is what forgetting costs. ADR-0044 pointed an approval's revise edge at +// a generic `wait`; `wait` is legitimately `'any'` (an external producer is meant +// to resume a signal wait); the pause standing in a service-owned position +// inherited that value without anyone choosing it. Demonstrated cost: an +// unaudited resubmit, plus a destroyed remote run. +// +// Until #5561 the omission was not merely unnoticed, it was UNOBSERVABLE: +// `ActionDescriptorSchema.resumeAuthority` carried `.default('any')`, so +// `defineActionDescriptor` filled the key and produced a descriptor +// byte-identical to an explicit `'any'`. #5561 removed that default, which is +// what lets both this gate and the engine's registration warning exist. +// +// ## Why a repo gate ALONGSIDE the registration warning +// +// They catch different populations, and this one catches the population that has +// actually bitten us. The runtime warning speaks to whoever reads a server log +// -- the right channel for a third-party plugin, of which this tree has none. +// #3823 was OUR OWN executor, written in this repo, and the moment to tell its +// author is the pull request, not a production log line. Failing CI is also the +// only form of this signal that is impossible to miss: a pausing descriptor that +// never declares its authority cannot merge. +// +// ## What it checks +// +// DISCOVERED the scan found `defineActionDescriptor({...})` literals at all. +// Zero is not "a clean repo", it is a broken scan: DECLARED +// iterates the discovered set, so a discovery that silently stops +// matching would make this script print OK while reading nothing +// (the #4868 family -- a check that runs, passes, and structurally +// cannot reach its subject). +// DECLARED every discovered descriptor with `supportsPause: true` also +// declares `resumeAuthority`. The value is not judged: `'any'` and +// `'service'` are both correct answers, and which one is right is +// the author's call. Only the SILENCE is a finding. +// +// ## What it cannot see (stated up front, not discovered later) +// +// 1. `supportsPause` is itself a declaration no execution path enforces +// (objectstack#5703): a run pauses because the executor's `execute()` +// returned `suspend: true`. An executor that suspends while leaving +// `supportsPause` false is fail-open and invisible BOTH here and to the +// registration warning. Keying on the author's own literal is what makes +// this gate decidable without a call graph; #5703 tracks the runtime half. +// 1b. **Test fixtures are deliberately out of scope.** The subject here is the +// SHIPPED node vocabulary — descriptors a real engine registers in a real +// deployment. A fixture registers into a throwaway engine and ships to +// nobody, and more decisively: an undeclared pausing descriptor is exactly +// what the registration warning's own tests must construct +// (`resume-authority-declaration.test.ts`), so gating fixtures would make +// the mechanism this gate co-exists with untestable. Three such fixtures +// exist today and all three are deliberate. (Note this is the mirror of +// `check-engine-double-contract.mjs`, which scans ONLY tests: each gate's +// scope is its subject, not a repo-wide sweep.) +// 2. Descriptors assembled dynamically -- spread from a variable, built by a +// helper, or `JSON.parse`d -- are skipped rather than guessed at. The tree +// writes none today (proved by DISCOVERED's count matching the literals), +// and a scanner that tried to follow a spread would trade a decidable +// criterion for a heuristic, which is how a gate starts producing false +// positives and then gets switched off. +// 3. Third-party plugins outside this repo. That is the registration +// warning's job, by construction. +// +// ## Why AST, not regex +// +// `supportsPause: true` and `resumeAuthority` can sit any distance apart, in any +// order, inside a literal that also carries a `configSchema` JSON Schema tens of +// lines long with nested `properties` of its own -- `screen`'s descriptor is 150 +// lines. A regex over "supportsPause: true ... resumeAuthority" cannot tell a +// TOP-LEVEL descriptor property from an identically-named key nested inside that +// JSON Schema, and it cannot tell which literal a match belongs to. Object +// structure decides this, so the checker reads structure. + +import { readFileSync, readdirSync } from 'node:fs'; +import { join, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const DEFAULT_SCAN_ROOTS = ['packages', 'examples']; + +/** The factory whose argument IS an action descriptor (packages/spec). */ +const FACTORY = 'defineActionDescriptor'; + +// ── Discovery ─────────────────────────────────────────────────────────────── + +function walk(dir, out = []) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git' || e.name === '.cache') continue; + const p = join(dir, e.name); + if (e.isDirectory()) walk(p, out); + // Shipped sources only: no declaration files, no test fixtures (see "What it + // cannot see" 1b — an undeclared pausing descriptor is a legitimate fixture). + else if ( + /\.(ts|tsx|mts)$/.test(e.name) + && !/\.d\.ts$/.test(e.name) + && !/\.(test|spec)\.(ts|tsx|mts)$/.test(e.name) + ) out.push(p); + } + return out; +} + +function sourceFiles(scanRoots) { + const out = []; + for (const r of scanRoots) walk(join(ROOT, r), out); + return out.sort(); +} + +/** The `name` of a top-level property in an object literal, or null. */ +function propertyName(prop) { + if (!ts.isPropertyAssignment(prop)) return null; + const n = prop.name; + if (ts.isIdentifier(n)) return n.text; + if (ts.isStringLiteral(n)) return n.text; + return null; +} + +/** + * Read the descriptor facts this gate judges out of ONE literal. + * + * Only top-level properties are read. That is the load-bearing difference from + * a textual scan: `screen`'s descriptor nests a JSON Schema whose `properties` + * could carry any key name at all, and a nested `supportsPause` would be author + * data, not a capability declaration. + */ +function readDescriptor(objectLiteral) { + let supportsPause = null; + let declaresResumeAuthority = false; + let type = null; + + for (const prop of objectLiteral.properties) { + const name = propertyName(prop); + if (name === null) continue; + if (name === 'resumeAuthority') declaresResumeAuthority = true; + if (name === 'supportsPause') { + const init = prop.initializer; + if (init.kind === ts.SyntaxKind.TrueKeyword) supportsPause = true; + else if (init.kind === ts.SyntaxKind.FalseKeyword) supportsPause = false; + else supportsPause = 'dynamic'; + } + if (name === 'type' && ts.isStringLiteral(prop.initializer)) type = prop.initializer.text; + } + + return { type, supportsPause, declaresResumeAuthority }; +} + +/** Every `defineActionDescriptor({ ... })` literal in one source text. */ +function scanSource(fileName, text) { + const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true); + const found = []; + + const visit = (node) => { + if ( + ts.isCallExpression(node) + && ts.isIdentifier(node.expression) + && node.expression.text === FACTORY + && node.arguments.length > 0 + ) { + const arg = node.arguments[0]; + if (ts.isObjectLiteralExpression(arg)) { + const line = sf.getLineAndCharacterOfPosition(arg.getStart(sf)).line + 1; + found.push({ line, ...readDescriptor(arg) }); + } else { + // Not a literal (spread from a variable, a helper's return value). Recorded + // as opaque so `--list` shows it and DISCOVERED counts it, but never judged: + // see "What it cannot see" #2. + const line = sf.getLineAndCharacterOfPosition(arg.getStart(sf)).line + 1; + found.push({ line, type: null, supportsPause: 'dynamic', declaresResumeAuthority: false, opaque: true }); + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + + return found; +} + +function audit(scanRoots = DEFAULT_SCAN_ROOTS) { + const errors = []; + const found = []; + + for (const abs of sourceFiles(scanRoots)) { + let text; + try { + text = readFileSync(abs, 'utf8'); + } catch { + continue; + } + if (!text.includes(FACTORY)) continue; + const descriptors = scanSource(abs, text); + if (descriptors.length === 0) continue; + found.push({ file: relative(ROOT, abs), descriptors }); + } + + // DISCOVERED + if (found.length === 0) { + errors.push( + 'DISCOVERED: the scan found no defineActionDescriptor() literal anywhere. That is not a clean ' + + 'repo, it is a broken scan — DECLARED iterates this set, so it passes vacuously and this ' + + 'script reports OK while reading nothing. Fix the discovery before trusting a green run.', + ); + } + + // DECLARED + for (const { file, descriptors } of found) { + for (const d of descriptors) { + if (d.supportsPause !== true) continue; + if (d.declaresResumeAuthority) continue; + const named = d.type ? `'${d.type}'` : `the descriptor at line ${d.line}`; + errors.push( + `DECLARED: ${file}:${d.line} — node type ${named} declares supportsPause: true but never ` + + 'declares resumeAuthority, so the #3801 resume gate treats every pause it creates as ' + + 'raw-resumable through the generic resume route. Add the field to the descriptor: ' + + "resumeAuthority: 'any' if that route IS the intended door (a screen's collected inputs, " + + "a signal wait's external producer), or 'service' if resuming is the tail of a decision " + + 'some service must authorize and record first (an approval). Both values are accepted here ' + + '— only the silence is the finding, because an inherited value is one nobody chose, which ' + + 'is how #3823 walked past an unrecorded approval decision.', + ); + } + } + + return { found, errors }; +} + +function counts(found) { + let total = 0; + let pausing = 0; + let declared = 0; + let opaque = 0; + for (const f of found) { + for (const d of f.descriptors) { + total++; + if (d.opaque) opaque++; + if (d.supportsPause === true) { + pausing++; + if (d.declaresResumeAuthority) declared++; + } + } + } + return { total, pausing, declared, opaque }; +} + +function report({ list = false, scanRoots = DEFAULT_SCAN_ROOTS } = {}) { + const { found, errors } = audit(scanRoots); + const { total, pausing, declared, opaque } = counts(found); + + console.log( + `\naction descriptors: ${total} literal(s) in ${found.length} file(s) — ${pausing} pausing, ` + + `${declared} of those declare resumeAuthority` + + `${opaque ? `, ${opaque} assembled dynamically (not judged)` : ''}.\n`, + ); + + if (list) { + for (const { file, descriptors } of found) { + for (const d of descriptors) { + const kind = d.opaque ? 'opaque ' : d.supportsPause === true ? 'pausing' : 'plain '; + const auth = d.supportsPause === true ? (d.declaresResumeAuthority ? ' declared' : ' UNDECLARED') : ''; + console.log(` ${kind} ${file}:${d.line} ${d.type ?? '(dynamic type)'}${auth}`); + } + } + console.log(''); + } + + if (errors.length) { + for (const e of errors) console.error(` x ${e}`); + console.error( + '\nNote: supportsPause is itself a declaration nothing enforces at run time (#5703) — a run ' + + 'pauses because execute() returned suspend: true. An executor that suspends while leaving ' + + 'supportsPause false is fail-open and invisible to this gate AND to the engine\'s ' + + 'registration warning; #5703 tracks that half.\n', + ); + console.error(`check-resume-authority-declared: ${errors.length} problem(s).\n`); + process.exit(1); + } + + if (!list) { + for (const { file, descriptors } of found) { + for (const d of descriptors.filter((x) => x.supportsPause === true)) { + console.log(` pausing ${file}:${d.line} ${d.type ?? '(dynamic type)'} → declared`); + } + } + console.log(''); + } + console.log( + `check-resume-authority-declared: OK — every pausing descriptor declares its resume authority ` + + `(${declared}/${pausing}). supportsPause itself stays unenforced at run time (#5703).\n`, + ); +} + +// ── Self-test ─────────────────────────────────────────────────────────────── +// +// A guard that cannot fail is not a guard (#4118). This drives the detector at +// both sides of every decision it makes, so a refactor that neuters it fails +// here rather than turning every future PR green. + +function selfTest() { + const failures = []; + const expect = (label, cond) => { if (!cond) failures.push(label); }; + + const descriptor = (body) => `import { defineActionDescriptor } from '@objectstack/spec/automation'; +engine.registerNodeExecutor({ + type: 'x', + descriptor: defineActionDescriptor({ +${body} + }), + async execute() { return { success: true }; }, +}); +`; + + // ── The finding itself, and its two silences. + let d = scanSource('a.ts', descriptor(" type: 'x', version: '1.0.0', name: 'X', supportsPause: true,")); + expect('finds a pausing descriptor that omits resumeAuthority', + d.length === 1 && d[0].supportsPause === true && d[0].declaresResumeAuthority === false); + expect('reads the node type for the message', d[0].type === 'x'); + + d = scanSource('a.ts', descriptor(" type: 'x', version: '1.0.0', name: 'X', supportsPause: true, resumeAuthority: 'any',")); + expect("an explicit 'any' satisfies the gate", d.length === 1 && d[0].declaresResumeAuthority === true); + + d = scanSource('a.ts', descriptor(" type: 'x', version: '1.0.0', name: 'X', supportsPause: true, resumeAuthority: 'service',")); + expect("an explicit 'service' satisfies the gate", d.length === 1 && d[0].declaresResumeAuthority === true); + + // ── Scope: a non-pausing descriptor is not asked the question. + d = scanSource('a.ts', descriptor(" type: 'x', version: '1.0.0', name: 'X',")); + expect('a descriptor with no supportsPause is out of scope', d.length === 1 && d[0].supportsPause === null); + + d = scanSource('a.ts', descriptor(" type: 'x', version: '1.0.0', name: 'X', supportsPause: false,")); + expect('supportsPause: false is out of scope', d.length === 1 && d[0].supportsPause === false); + + // ── Structure, not text: the property must be the descriptor's OWN, not a + // key of the same name nested inside its configSchema JSON Schema. This is + // the case a regex cannot decide, and `screen` really does nest a schema. + const nested = descriptor(` type: 'x', version: '1.0.0', name: 'X', + configSchema: { + type: 'object', + properties: { + supportsPause: { type: 'boolean', title: 'Author field that happens to share the name' }, + resumeAuthority: { type: 'string' }, + }, + },`); + d = scanSource('a.ts', nested); + expect('a nested supportsPause key is not read as a capability', + d.length === 1 && d[0].supportsPause === null && d[0].declaresResumeAuthority === false); + + const nestedUnderPausing = descriptor(` type: 'x', version: '1.0.0', name: 'X', + supportsPause: true, + configSchema: { + type: 'object', + properties: { resumeAuthority: { type: 'string' } }, + },`); + d = scanSource('a.ts', nestedUnderPausing); + expect('a nested resumeAuthority key does not satisfy the gate for a pausing descriptor', + d.length === 1 && d[0].supportsPause === true && d[0].declaresResumeAuthority === false); + + // ── Two descriptors in one file are judged separately (crud-nodes.ts ships + // four), and a violation next to a compliant one is still found. + const two = `import { defineActionDescriptor } from '@objectstack/spec/automation'; +const a = defineActionDescriptor({ type: 'a', version: '1.0.0', name: 'A', supportsPause: true, resumeAuthority: 'service' }); +const b = defineActionDescriptor({ type: 'b', version: '1.0.0', name: 'B', supportsPause: true }); +`; + d = scanSource('two.ts', two); + expect('judges sibling descriptors independently', + d.length === 2 && d[0].declaresResumeAuthority === true && d[1].declaresResumeAuthority === false); + + // ── A dynamically assembled argument is recorded, never judged. + d = scanSource('dyn.ts', "const d = defineActionDescriptor(base);\n"); + expect('a non-literal argument is opaque, not a violation', d.length === 1 && d[0].opaque === true); + + d = scanSource('spread.ts', + "const d = defineActionDescriptor({ ...base, supportsPause: true });\n"); + expect('a spread literal is still read for its own explicit properties', + d.length === 1 && d[0].supportsPause === true && d[0].declaresResumeAuthority === false); + + // ── A same-named local factory is not the spec's. Deliberately still counted: + // the criterion is the literal's shape, and a hand-rolled factory producing + // a descriptor has the same obligation. Asserted so the choice is visible. + d = scanSource('local.ts', + "function defineActionDescriptor(x: any) { return x; }\nconst d = defineActionDescriptor({ type: 'x', supportsPause: true });\n"); + expect('a same-named local factory is judged too (documented choice)', + d.length === 1 && d[0].supportsPause === true); + + expect('a file with no descriptors yields nothing', scanSource('empty.ts', 'export const x = 1;\n').length === 0); + + // ── Wiring: discovery must reach the real tree, and specifically the four + // pausing built-ins #5561 declared. Deliberately NOT asserted here: that the + // tree is clean. That is the gated run's job, and duplicating it would make + // a genuine violation surface as a self-test failure — the least legible + // message available. + const { found } = audit(); + expect('discovers descriptors in the real tree', found.length > 0); + const pausingTypes = new Set(); + for (const f of found) { + for (const dd of f.descriptors) if (dd.supportsPause === true && dd.type) pausingTypes.add(dd.type); + } + for (const t of ['screen', 'wait', 'subflow', 'map']) { + expect(`discovery reaches the '${t}' pausing built-in`, pausingTypes.has(t)); + } + + if (failures.length) { + for (const f of failures) console.error(` x self-test: ${f}`); + console.error(`\ncheck-resume-authority-declared --self-test: ${failures.length} failure(s).\n`); + process.exit(1); + } + console.log( + 'OK self-test: finds a pausing descriptor that omits resumeAuthority, accepts either declared ' + + 'value, leaves non-pausing descriptors alone, reads TOP-LEVEL properties only (a configSchema ' + + 'key of the same name neither triggers nor satisfies it), judges sibling descriptors ' + + 'independently, treats a non-literal argument as opaque rather than as a violation, and proves ' + + 'discovery reaches the four pausing built-ins.', + ); +} + +const argv = process.argv.slice(2); +const dirFlag = argv.indexOf('--packages-dir'); +const scanRoots = dirFlag === -1 ? DEFAULT_SCAN_ROOTS : [argv[dirFlag + 1]]; + +if (argv.includes('--self-test')) selfTest(); +else report({ list: argv.includes('--list'), scanRoots }); diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 07096820e9..7efe8b5f93 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -188,7 +188,10 @@ const DEBT = { }, '@objectstack/service-automation': { errors: 2, - note: 'code-tier 2 (TS2741: engine.test.ts misses resumeAuthority, the #4198 discovery that opened #4311).', + note: 'code-tier 2 (TS2741: engine.test.ts:2547/2577 build a descriptor literal missing a required ' + + 'field, the #4198 discovery that opened #4311). The missing field TS names moved from ' + + 'resumeAuthority to handlerContract in #5561, which made resumeAuthority optional; both literals ' + + 'omit both, and TS reports one at a time. The count is unchanged by that.', }, '@objectstack/service-cluster': { errors: 1,