From 990af4228f833010c85f37020d4043dac642d8dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:22:48 +0000 Subject: [PATCH 1/6] feat(runtime, spec): the resume door's 400 FLOW_FAILED details carry the engine's stranded verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /automation/:name/runs/:runId/resume` copied `errorMessage` and `summary` off the engine result and dropped `status`, so `AutomationResult.status: 'stranded'` (terminally failed but repairable by an operator verb) reached the wire as the same 400 FLOW_FAILED a plain terminal failure does. The #16472 family ruling (option A): carry `status` and `repairable` in the details of the existing code, no FLOW_STRANDED sibling. - spec: `ResumeFailureDetailsSchema` (`@objectstack/spec/api`) declares the structure once — `{ runId, status?: 'failed' | 'stranded', repairable }`. - runtime: the resume door forwards `status` verbatim when the engine stamped one, names the resumed run as `runId`, and answers `repairable` as `status === 'stranded'` — always present on this arm, present-and-false on the plain terminal exit. Trigger door and /actions unchanged. - client: `automation.resume` docblock; docs: flows.mdx, client-sdk.mdx. - pins: spec schema + type-level subset pin; runtime door pins (fake engine, every arm); verify wire pin through the real engine. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- .../automation-resume-stranded-details.md | 13 + content/docs/api/client-sdk.mdx | 31 +++ content/docs/automation/flows.mdx | 26 ++ packages/client/src/index.ts | 18 ++ ...automation-resume-stranded-details.test.ts | 243 ++++++++++++++++++ packages/runtime/src/domains/automation.ts | 97 ++++++- .../spec/src/api/automation-api.zod.test.ts | 80 +++++- packages/spec/src/api/automation-api.zod.ts | 70 +++++ ...automation-resume-stranded-details.test.ts | 152 +++++++++++ 9 files changed, 727 insertions(+), 3 deletions(-) create mode 100644 .changeset/automation-resume-stranded-details.md create mode 100644 packages/runtime/src/domains/automation-resume-stranded-details.test.ts create mode 100644 packages/verify/src/automation-resume-stranded-details.test.ts diff --git a/.changeset/automation-resume-stranded-details.md b/.changeset/automation-resume-stranded-details.md new file mode 100644 index 0000000000..c080333b31 --- /dev/null +++ b/.changeset/automation-resume-stranded-details.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +"@objectstack/client": patch +--- + +The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded. + +`POST /api/v1/automation/:name/runs/:runId/resume` answers a run that consumed its pause and then failed with `400 FLOW_FAILED`, and until now its `error.details` carried the run's two artefacts only (`errorMessage`, `summary`). The engine's own verdict was dropped at the door: `AutomationResult.status: 'stranded'` — a run that is terminally failed *but* repairable by an explicit operator verb, because the pause a durable decision was waiting on is gone with the failure — reached the wire as the same `400` a plain terminal failure does, so an HTTP-only caller could not tell "beyond reach" from "repair waiting". + +- **`@objectstack/spec`** declares `ResumeFailureDetailsSchema` (`@objectstack/spec/api`): `{ runId, status?: 'failed' | 'stranded', repairable }` — the machine-readable shape of a resume failure told to the caller, declared once so every carrier of the family ruling spells the same members. +- **`@objectstack/runtime`**: the resume door's `400 FLOW_FAILED` details now carry that structure beside `errorMessage` / `summary`. `runId` is the run the resume was addressed to; `status` is the engine's own stamp, forwarded verbatim when it set one and never synthesised (the subflow-child-failed exit stamps none today); `repairable` is `status === 'stranded'` and is **always present on this arm** — present-and-false on a plain terminal failure, deliberately, so an absent member reads as an older server rather than as "not repairable". The code stays `FLOW_FAILED` (no `FLOW_STRANDED` sibling is minted), so a client that treats it as terminal keeps working and one that wants to offer a repair branches on `details.repairable`, never on the message text. The trigger door and `/actions` are unchanged: they never resume, so the member is absent there and absent means "not a resume". +- **`@objectstack/client`**: `automation.resume` documents the new members. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index c784b024c5..6886baf712 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -750,6 +750,37 @@ try { } ``` +**The resume door says one thing more.** `client.automation.resume()` rejects +with the same `400` `FLOW_FAILED` when a run consumed its pause and then failed +— and there the `details` also carry the engine's verdict, because one of those +failures is *stranded*: terminal like any failure, but the pause a durable +decision was waiting on is gone with it and only an explicit operator verb can +re-arm the run. The shape is `ResumeFailureDetailsSchema` from +`@objectstack/spec/api`; branch on it, never on the message text: + +```typescript +import { ResumeFailureDetailsSchema } from '@objectstack/spec/api'; + +try { + await client.automation.resume('order_approval', runId, { inputs }); +} catch (err) { + if (!isApiError(err) || err.code !== 'FLOW_FAILED') throw err; + const verdict = ResumeFailureDetailsSchema.safeParse(err.details); + if (verdict.success && verdict.data.repairable) { + // verdict.data.status === 'stranded' — verdict.data.runId names the run + // an operator can re-arm; offer that instead of closing as terminal. + } +} +``` + +`repairable` is always present on the resume door's `400` — `false` on a plain +terminal failure, deliberately, so an absent member reads as an older server +rather than as "not repairable". `status` is the engine's own stamp +(`'stranded'` or `'failed'`), forwarded when the engine set one and never +invented by the door. The code stays `FLOW_FAILED`: there is no `FLOW_STRANDED` +sibling, and the trigger door's `400` carries `errorMessage` / `summary` only — +it never resumes, so "repairable" has no referent there. + --- ## Configuration diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 253306e2c9..d81206d9b0 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -688,6 +688,32 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume segment](#retry-pause-boundary). +### A resume that fails downstream says whether the run is stranded + +The suspension is consumed *before* the downstream nodes run (that is what buys +exactly-once across a crash), so a node that throws after the resume leaves a +run that is terminal **and** whose pause is gone. The engine names that state +`status: 'stranded'` — distinct from a plain `failed`, because an explicit +operator verb can still re-arm it — and the resume route forwards the verdict +in the `400` `FLOW_FAILED` details, beside the author's `errorMessage` and the +per-node `summary`: + +```json +400 { "error": { "code": "FLOW_FAILED", "message": "Node 'tail' failed: …", + "details": { "runId": "run_42", "status": "stranded", "repairable": true, + "errorMessage": "…", "summary": { … } } } } +``` + +Branch on `details.repairable`, never on the message: it is `true` exactly when +`status` is `stranded`, and it is **always present** on this route's `400` — +`false` on a plain terminal failure, so an absent member reads as an older +server rather than as "not repairable". `status` is the engine's own stamp, +forwarded when it set one and never invented by the route. The code does not +change (there is no `FLOW_STRANDED`), and the trigger route's `400` carries +`errorMessage` / `summary` only — it never resumes, so the question has no +referent there. The shape is `ResumeFailureDetailsSchema` in +`@objectstack/spec/api`. + ### Who may resume — the gate is the suspended node The resume route is generic, so **the node the run is parked on** decides diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 96ecd278e4..82c7b914b9 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4533,9 +4533,27 @@ export class ObjectStackClient { * err.message; // the node failure, verbatim * err.details?.errorMessage; // the flow author's `errorMessage` * err.details?.summary; // per-node accounting of the failed run + * err.details?.runId; // the run this resume was addressed to + * err.details?.status; // 'stranded' | 'failed' — the engine's verdict, when it stamped one + * err.details?.repairable; // true exactly when `status` is 'stranded' * } * ``` * + * **Since #15221 the 400 tells a stranded run from a plain failure.** + * A resume that consumed the pause and then failed downstream leaves + * the run *stranded* — terminal like any failure, but the pause a + * durable decision was waiting on is gone with it and only an explicit + * operator verb can re-arm it. The engine says so + * (`AutomationResult.status: 'stranded'`), and the door forwards that + * verdict in `err.details`: branch on `err.details.repairable`, never + * on the message text. `repairable` is always present on this 400 — + * `false` on a plain terminal failure, deliberately, so an absent + * member reads as an older server rather than as "not repairable". + * The shape is `ResumeFailureDetailsSchema` (`@objectstack/spec/api`); + * the code stays `FLOW_FAILED` (no `FLOW_STRANDED` sibling). The + * trigger door's 400 carries `errorMessage` / `summary` only — it never + * resumes, so "repairable" has no referent there. + * * A **stale** suspension (the flow deregistered, or the node edited away * under a live pause) rejects with **404** rather than 400: nothing ran, * and the pause is gone for good. The refusals that leave the suspension diff --git a/packages/runtime/src/domains/automation-resume-stranded-details.test.ts b/packages/runtime/src/domains/automation-resume-stranded-details.test.ts new file mode 100644 index 0000000000..ef751b1ae3 --- /dev/null +++ b/packages/runtime/src/domains/automation-resume-stranded-details.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15221 — the resume door's `400 FLOW_FAILED` details carry the engine's + * verdict: `status: 'stranded'` and `repairable`, so a client can branch + * without a message regex (the #16472 family ruling, maintainer 2026-09-07, + * option A). + * + * The defect, measured on the door before this change: the arm copied + * `errorMessage` and `summary` off the engine result and dropped `status`, so + * `AutomationResult.status: 'stranded'` (#14384 — terminally failed BUT + * repairable by an operator verb) reached the wire as the same `400 + * FLOW_FAILED` a plain terminal failure does. An HTTP-only caller could not + * tell "beyond reach" from "repair waiting", and the console — which treats + * `400 FLOW_FAILED` as terminal (#8684) — closed on both. + * + * What is pinned, and why each pin is its own case: + * + * 1. **The stranded arm carries the structure** — `runId`, `status: + * 'stranded'`, `repairable: true` — beside the two artefacts that were + * already there, and the whole `details` parses under the spec's + * `ResumeFailureDetailsSchema`: the discriminator is reachable AND typed, + * never prose. + * 2. **The plain terminal arm is present-and-false, not absent.** The engine + * stamps no `status` on its other exit (a subflow child that failed + * terminally), so that arm carries `repairable: false` and NO `status` — + * the door never synthesises a `'failed'` the producer did not say. + * 3. **CONTROL against the regex the ruling forbids**: a message that SAYS + * "stranded" with no engine verdict answers `repairable: false`. The + * door reads the producer's discriminator, never the text. + * 4. **A stamped `'failed'` is forwarded verbatim** — the door relays, it + * does not filter. + * 5. **The coded refusals are untouched**: every arm that leaves the + * suspension intact answers exactly what it did, with no details. + * 6. **The 200 arms are untouched**: paused and completed relay the result. + * 7. **The trigger door is untouched** — its `400 FLOW_FAILED` details stay + * `{ errorMessage?, summary? }` with no `repairable` at all. Absent there + * means "not a resume", never "not repairable"; the discriminator belongs + * to the one door where a pause can be consumed. + * + * The producer side (`AutomationEngine` stamping `'stranded'` on exactly one + * exit) is pinned in `service-automation`'s `stranded-run-status.test.ts`; the + * wire, driven through the real engine, in `@objectstack/verify`'s + * `automation-resume-stranded-details.test.ts`. This file pins the DOOR's + * shaping with a fake engine, so each arm can be reached by name. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { ResumeFailureDetailsSchema } from '@objectstack/spec/api'; +import type { AutomationResult } from '@objectstack/spec/contracts'; + +import { HttpDispatcher } from '../http-dispatcher.js'; + +const CTX = { request: {}, executionContext: { userId: 'user_1' } } as any; +const RESUME = '/flow_a/runs/run_1/resume'; +const TRIGGER = '/flow_a/trigger'; + +const SUMMARY = { + nodes: [{ id: 'tail', type: 'tail', status: 'failure', error: 'tail blew up' }], +} as unknown as NonNullable; + +function makeDispatcher(resumeResult: AutomationResult, executeResult?: AutomationResult) { + const spies = { + resume: vi.fn(async () => resumeResult), + execute: vi.fn(async () => executeResult ?? { success: true, output: {}, durationMs: 1 }), + }; + const services: Record = { automation: spies }; + const resolve = (name: string) => services[name]; + const kernel: any = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel), spies }; +} + +describe('#15221 — the resume door forwards the engine\'s stranded verdict in the 400 FLOW_FAILED details', () => { + it('stranded: runId, status and repairable ride beside errorMessage and summary — typed, not prose', async () => { + const { dispatcher, spies } = makeDispatcher({ + success: false, + error: 'tail blew up', + durationMs: 12, + status: 'stranded', + errorMessage: 'Please contact support', + summary: SUMMARY, + }); + + const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX); + + expect(spies.resume).toHaveBeenCalledWith('run_1', {}); + expect(result.response?.status).toBe(400); + const error = result.response?.body?.error; + // The code does NOT move: no `FLOW_STRANDED` sibling is minted under + // the ruling — the console's terminal reading of this code stays true. + expect(error?.code).toBe('FLOW_FAILED'); + expect(error?.message).toBe('tail blew up'); + // The verdict, by name, off a typed structure. + expect(error?.details).toEqual({ + runId: 'run_1', + status: 'stranded', + repairable: true, + errorMessage: 'Please contact support', + summary: SUMMARY, + }); + // `code` is promoted out of `details` into `error.code` (#3842) — the + // structure does not smuggle a second copy. + expect(error?.details?.code).toBeUndefined(); + // Reachable AND typed: the whole `details` object is what a client + // hands to the spec schema, artefacts included, and gets the verdict. + const parsed = ResumeFailureDetailsSchema.safeParse(error?.details); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data).toEqual({ runId: 'run_1', status: 'stranded', repairable: true }); + // The message is NOT the carrier: nothing in it says "stranded", and + // the client did not need it to. + expect(error?.message).not.toMatch(/strand/i); + // ADR-0112: no inner envelope for a status-blind caller to misread. + expect(result.response?.body?.data).toBeUndefined(); + }); + + it('plain terminal failure (no engine status): repairable is present-and-false, status is absent — never synthesised', async () => { + // The shape the engine emits on its OTHER exit into this arm — a + // subflow child that failed terminally — measured on `resumeInternal`: + // `{ success: false, error, durationMs }`, no `status`, no artefacts. + const { dispatcher } = makeDispatcher({ + success: false, + error: "subflow run 'child_1' (child_flow) failed: boom", + durationMs: 3, + }); + + const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX); + + expect(result.response?.status).toBe(400); + expect(result.response?.body?.error?.code).toBe('FLOW_FAILED'); + // Exactly these two: present-and-false is the contract, and the door + // did not invent a `'failed'` the producer never stamped. + expect(result.response?.body?.error?.details).toEqual({ runId: 'run_1', repairable: false }); + expect(ResumeFailureDetailsSchema.safeParse(result.response?.body?.error?.details).success).toBe(true); + }); + + it('CONTROL — a message that says "stranded" with no engine verdict is NOT repairable: the door reads the discriminator, never the text', async () => { + const { dispatcher } = makeDispatcher({ + success: false, + error: 'run is stranded — but nobody stamped it', + durationMs: 3, + }); + + const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX); + + expect(result.response?.status).toBe(400); + expect(result.response?.body?.error?.details?.repairable).toBe(false); + expect(result.response?.body?.error?.details?.status).toBeUndefined(); + }); + + it('a stamped `failed` is forwarded verbatim, with repairable false', async () => { + const { dispatcher } = makeDispatcher({ + success: false, + error: 'rejected', + durationMs: 3, + status: 'failed', + }); + + const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX); + + expect(result.response?.status).toBe(400); + expect(result.response?.body?.error?.details).toEqual({ runId: 'run_1', status: 'failed', repairable: false }); + }); + + it('the runId names the run this door was asked to resume — the path parameter, not a guess off the result', async () => { + const { dispatcher, spies } = makeDispatcher({ success: false, error: 'x', durationMs: 1, status: 'stranded' }); + + const result = await dispatcher.handleAutomation('/flow_a/runs/run_other_42/resume', 'POST', {}, CTX); + + expect(spies.resume).toHaveBeenCalledWith('run_other_42', {}); + expect(result.response?.body?.error?.details?.runId).toBe('run_other_42'); + }); +}); + +describe('#15221 — every other arm of the door is untouched', () => { + it.each([ + ['PERMISSION_DENIED', 403], + ['INVALID_SIGNAL', 400], + ['INVALID_SCREEN_INPUT', 400], + ['RUN_NOT_FOUND', 404], + ['STORE_UNAVAILABLE', 503], + ['RESUME_IN_PROGRESS', 409], + ] as const)('coded refusal %s → %i, no details', async (code, status) => { + // Every coded arm left the suspension intact (or never found one) and + // is answered BEFORE the terminal arm; none carries the structure, + // and a `status` a producer might stamp beside a code changes nothing. + const { dispatcher } = makeDispatcher({ success: false, code, error: `${code}: refused`, status: 'stranded' } as AutomationResult); + + const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX); + + expect(result.response?.status).toBe(status); + expect(result.response?.body?.error?.details).toBeUndefined(); + }); + + it('a paused resume still answers 200 with the engine result relayed', async () => { + const { dispatcher } = makeDispatcher({ + success: true, + status: 'paused', + runId: 'run_1', + screen: { nodeId: 'ask', title: 'Next', fields: [] } as never, + }); + + const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.status).toBe('paused'); + expect(result.response?.body?.data?.runId).toBe('run_1'); + expect(result.response?.body?.error).toBeUndefined(); + }); + + it('a completed resume still answers 200 with the engine result relayed', async () => { + const { dispatcher } = makeDispatcher({ success: true, output: { done: true }, durationMs: 4, summary: SUMMARY }); + + const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.output).toEqual({ done: true }); + }); + + it('the trigger door\'s 400 FLOW_FAILED details stay { errorMessage?, summary? } — no repairable, because it never resumes', async () => { + // The deliberate asymmetry, pinned: at a door that never consumes a + // pause "repairable" has no referent, so the member is ABSENT there — + // and absent means "not a resume", never "not repairable". Only the + // resume door says false. + const { dispatcher, spies } = makeDispatcher( + { success: true }, + { success: false, error: 'boom', durationMs: 2, status: 'failed', errorMessage: 'Author text', summary: SUMMARY }, + ); + + const result = await dispatcher.handleAutomation(TRIGGER, 'POST', {}, CTX); + + expect(spies.execute).toHaveBeenCalled(); + expect(result.response?.status).toBe(400); + expect(result.response?.body?.error?.code).toBe('FLOW_FAILED'); + expect(result.response?.body?.error?.details).toEqual({ errorMessage: 'Author text', summary: SUMMARY }); + expect(result.response?.body?.error?.details?.repairable).toBeUndefined(); + expect(result.response?.body?.error?.details?.runId).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 1ccffd93f6..28abbb21a4 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -17,13 +17,14 @@ import { // the posture rule and the #10243 measurement behind it. import { refuseUngrantedActivationWrite, FLOW_ACTIVATION_SUBJECT } from './activation-gate.js'; import { CoreServiceName } from '@objectstack/spec/system'; -import type { IAutomationService, ISecurityService } from '@objectstack/spec/contracts'; +import type { AutomationResult, IAutomationService, ISecurityService } from '@objectstack/spec/contracts'; import { isServiceServeable } from '../service-serveable.js'; import { validationFailure, validationFailureDetails, fieldsFromZodIssues, VALIDATION_FAILED_STATUS, } from '../validation-failure.js'; import { ExecutionStatus } from '@objectstack/spec/automation'; import { ListRunsRequestSchema } from '@objectstack/spec/api'; +import type { ResumeFailureDetails } from '@objectstack/spec/api'; import { parseEnumParam, parseIntegerParam, parseStringParam } from '../query-param.js'; import { capabilityUnavailable } from './unavailable.js'; // [#9446] The ONE #9378 status table, now shared with the `/actions` door. @@ -912,6 +913,69 @@ async function respondToFlowTrigger( return { handled: true, response: deps.success(result) }; } +/** + * [#15221] The machine-readable verdict the resume door's `400 FLOW_FAILED` + * arm carries in `error.details`, beside the run's two artefacts — the + * #16472 family ruling (maintainer 2026-09-07, option A), applied to this + * door: `status: 'stranded'` and `repairable`, so a client branches without a + * message regex, and ⛔ no `FLOW_STRANDED` sibling code (a new code is a + * ledger event; the console needing one is its own card). + * + * The structure is `ResumeFailureDetailsSchema` (`@objectstack/spec/api`), + * declared once for every carrier the ruling names; this door is the + * PRODUCER of one of them, so the two members it owns are bound to that + * declaration at compile time (`satisfies`) and the third is relayed. + * + * What each member says, and why it is shaped the way it is: + * + * - `runId` — the run this door was asked to resume (the path's `:runId`). + * The engine stamps `'stranded'` on exactly one exit, `resumeInternal`'s + * own catch arm for the run being resumed, so the resumed run IS the run + * that is actually stranded; the engine result carries no `runId` on a + * terminal exit (the contract sets it on `'paused'` only), and this door + * knows the id from the request rather than sniffing it out of the + * engine's message. + * - `status` — the engine's own verdict, forwarded VERBATIM when it stamped + * one and never synthesised. Measured on the engine: the stranded exit + * stamps `'stranded'`; the other exit that reaches this arm — a subflow + * child that failed terminally — stamps nothing, so that arm carries no + * `status` today rather than a `'failed'` this door made up. Reading the + * producer's verdict is the whole rule (PD #12; `flow-dispatch-status.ts` + * says it for the trigger table). + * - `repairable` — `status === 'stranded'`, and ALWAYS present on this arm. + * Present-and-false on the plain terminal exit is a deliberate contract, + * not an implementation detail: an ABSENT member would be + * indistinguishable from a server that predates this field, and + * `StrandedDecisionDetails.repairable` (`@objectstack/types`, the approvals + * door's carrier) already fixed the vocabulary — `false` is the honest + * answer for every other exit, including the ones that report no status + * at all, because promising a repair verb that will refuse is worse than + * promising nothing. + * + * ⛔ Not reused from `@objectstack/types`: `strandedDecisionDetails` / + * `strandedDecisionFailure` are an all-four-or-nothing envelope whose + * `finalized` and `decision` are approvals facts with no referent at a + * generic resume (this door has no decision to report), and its reader + * refuses a partial envelope by design. Only the `repairable` / `runId` + * vocabulary is shared, through the spec declaration. + * + * ⛔ Not on the trigger door and not on `/actions`: neither ever resumes, so + * "repairable" has no referent there; their `400 FLOW_FAILED` details stay + * `{ errorMessage?, summary? }`, and an absent `repairable` there means "not + * a resume", never "not repairable". Pinned in + * `automation-resume-stranded-details.test.ts`, both halves. + */ +function resumeFailureDetails(runId: string, result: AutomationResult): Record { + const verdict = { + runId, + repairable: result.status === 'stranded', + } satisfies Omit; + return { + ...verdict, + ...(result.status !== undefined ? { status: result.status } : {}), + }; +} + /** * Handles Automation requests * path: sub-path after /automation/ @@ -957,7 +1021,11 @@ async function respondToFlowTrigger( * ⚑ run-state read — `sys_automation_run` grant (#7900) * GET /:name/runs/:runId → getRun * ⚑ run-state read — `sys_automation_run` grant (#7900) - * POST /:name/runs/:runId/resume → resume a paused run (screen input / ADR-0019) + * POST /:name/runs/:runId/resume → resume a paused run (screen input / ADR-0019; + * a run that resumed and then failed → 400 + * `FLOW_FAILED` whose details carry the engine's + * verdict — `status: 'stranded'` + `repairable` — + * beside `errorMessage` / `summary`, #15221) * GET /:name/runs/:runId/screen → the screen a paused run awaits * ⚑ run's trigger identity OR the * `sys_automation_run` grant (#7968) @@ -1490,6 +1558,10 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // existence is unknown; the same call is expected // to work once it recovers (#4420) // RESUME_IN_PROGRESS → 409, a concurrent resume already has this run + // A result with NO code and `success: false` consumed its pause and + // ran: 400 `FLOW_FAILED` (#8684), whose details carry the engine's + // own verdict since #15221 — `status: 'stranded'` + `repairable` + // (`resumeFailureDetails` above) — beside `errorMessage` / `summary`. // All are enforced in the ENGINE, at the one place a signal reaches the // variable map — deliberately not re-implemented here. Guarding a field // at a time in the transport is what let `output` reopen the hole @@ -1691,6 +1763,26 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // deliberately does not copy it. `summary` rides along for the // same reason it was on the 200 body: a failed run's per-node // accounting is how a caller finds WHICH node failed. + // + // [#15221] And the engine's VERDICT rides with them. Of the + // two exits above, only the flow-itself-failed one can be + // `status: 'stranded'` (#14384 / #13937: the pause a durable + // decision was waiting on is gone and an operator verb can + // re-arm the run) — and until now this arm copied + // `errorMessage` and `summary` off the result and dropped + // `status`, so `'stranded'` could not reach the wire through + // any door and an HTTP-only caller read "beyond reach" and + // "repair waiting" as one and the same 400. The #16472 + // ruling (option A) carries it here, in the details of the + // EXISTING code: `runId`, `status` (verbatim, when stamped) + // and `repairable` (`status === 'stranded'`, always present — + // false on the plain terminal exit, deliberately, see + // `resumeFailureDetails`), declared once as + // `ResumeFailureDetailsSchema` in `@objectstack/spec/api`. + // ⛔ No `FLOW_STRANDED` sibling code: the console treats + // `400 FLOW_FAILED` as terminal (#8684) and a client that + // wants to branch reads `details.repairable`, never a regex + // over the message. if (result?.success === false) { return { handled: true, @@ -1698,6 +1790,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str code: 'FLOW_FAILED', ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), ...(result.summary !== undefined ? { summary: result.summary } : {}), + ...resumeFailureDetails(parts[2], result), }), }; } diff --git a/packages/spec/src/api/automation-api.zod.test.ts b/packages/spec/src/api/automation-api.zod.test.ts index d3f464850b..8271c7fe3d 100644 --- a/packages/spec/src/api/automation-api.zod.test.ts +++ b/packages/spec/src/api/automation-api.zod.test.ts @@ -23,8 +23,9 @@ import { GetRunResponseSchema, AutomationApiErrorCode, AutomationApiContracts, + ResumeFailureDetailsSchema, } from './automation-api.zod'; -import type { TriggerFlowResponse } from './automation-api.zod'; +import type { TriggerFlowResponse, ResumeFailureDetails } from './automation-api.zod'; import { ExecutionStatus } from '../automation/execution.zod'; import type { AutomationResult } from '../contracts/automation-service'; @@ -51,6 +52,22 @@ type Assert< T extends true > = T; */ export type TriggerFlowDataMatchesContract = Assert< Eq< TriggerFlowResponse['data'], AutomationResult > >; +/** + * #15221 — the resume door's `400 FLOW_FAILED` details forward the ENGINE's + * verdict, so the `status` the details schema declares must be exactly the + * terminal-failure subset of the contract's own union: rename or remove + * `'stranded'` / `'failed'` on `AutomationResult.status` and `Extract` yields + * fewer members, which reds this alias by name. Deliberately a SUBSET pin, + * not an identity: `'completed'` / `'paused'` / `'refused'` are `success: true` + * verdicts and can never reach a 400. + * + * Exported for the same reason as the pin above (TS6196 otherwise). + */ +export type ResumeFailureStatusIsTheContractsTerminalFailureSubset = Assert< Eq< + NonNullable, + Extract, 'failed' | 'stranded'> +> >; + // ========================================== // Path Parameters // ========================================== @@ -486,6 +503,67 @@ describe('TriggerFlowResponseSchema', () => { }); }); +// ========================================== +// Resume failure details (#15221) +// ========================================== + +describe('ResumeFailureDetailsSchema', () => { + // The stranded arm: the whole point of the schema — a client reads + // `repairable` off a typed structure and never regexes the message. + it('accepts the stranded verdict — runId, status and repairable', () => { + const result = ResumeFailureDetailsSchema.parse({ + runId: 'run_1', + status: 'stranded', + repairable: true, + }); + expect(result).toEqual({ runId: 'run_1', status: 'stranded', repairable: true }); + }); + + // The plain terminal arm as the engine emits it today (a subflow child that + // failed terminally stamps NO status): `status` is optional, `repairable` + // is not — present-and-false is the contract, not absence. + it('accepts a verdict with no status — repairable stays required', () => { + const result = ResumeFailureDetailsSchema.parse({ runId: 'run_1', repairable: false }); + expect(result).toEqual({ runId: 'run_1', repairable: false }); + expect(ResumeFailureDetailsSchema.safeParse({ runId: 'run_1' }).success).toBe(false); + expect(ResumeFailureDetailsSchema.safeParse({ runId: 'run_1', repairable: 'yes' }).success).toBe(false); + }); + + it('accepts the plain `failed` verdict', () => { + const result = ResumeFailureDetailsSchema.parse({ runId: 'run_1', status: 'failed', repairable: false }); + expect(result.status).toBe('failed'); + }); + + // The two terminal-failure members and nothing else: a `success: true` + // verdict (`paused` / `completed` / `refused`) never reaches a 400, so the + // schema refuses it rather than describing an arm that does not exist. + it('refuses a status that is not a terminal failure', () => { + for (const status of ['paused', 'completed', 'refused', 'bogus']) { + expect(ResumeFailureDetailsSchema.safeParse({ runId: 'run_1', status, repairable: false }).success, status).toBe(false); + } + expect(ResumeFailureDetailsSchema.shape.status.unwrap().options).toEqual(['failed', 'stranded']); + }); + + it('requires the runId', () => { + expect(ResumeFailureDetailsSchema.safeParse({ repairable: true, status: 'stranded' }).success).toBe(false); + }); + + // On the wire the structure rides INSIDE the same `error.details` object as + // the run's two artefacts; a client hands the whole `details` to this + // schema and gets the verdict back — the artefacts are neither required + // nor refused. + it('parses the whole `details` object the resume door emits — the artefacts ride beside it', () => { + const result = ResumeFailureDetailsSchema.parse({ + runId: 'run_1', + status: 'stranded', + repairable: true, + errorMessage: 'Please contact support', + summary: { nodes: [] }, + }); + expect(result).toEqual({ runId: 'run_1', status: 'stranded', repairable: true }); + }); +}); + // ========================================== // Toggle Flow // ========================================== diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 80689d0a42..681d183cb1 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -386,6 +386,76 @@ export type TriggerFlowResponse = z.input; /** Post-parse shape of {@link TriggerFlowResponse} — defaults applied, transforms run (ADR-0122). */ export type TriggerFlowResponseParsed = z.infer; +// ========================================== +// 7b. Resume failure details (POST /api/automation/:name/runs/:runId/resume, 400 FLOW_FAILED) +// ========================================== + +/** + * The machine-readable half of a resume failure, as it reaches the caller + * (#15221; the #16472 family ruling, maintainer 2026-09-07, option A). + * + * `POST /api/automation/:name/runs/:runId/resume` answers a run that consumed + * its pause and then failed with `400 FLOW_FAILED` (#8684), and the + * `error.details` of that answer carried the run's two artefacts only — the + * author's `errorMessage` and the per-node `summary`. The engine's own verdict + * was not forwarded: `AutomationResult.status: 'stranded'` (#14384, the + * #13937 shape-4 ruling) names the terminally-failed-but-REPAIRABLE run — the + * pause a durable decision was waiting on is gone with the failure, and only + * an explicit operator verb can re-arm it — and it is distinct from a plain + * terminal failure on purpose. Both exits reached the wire as one and the same + * `400 FLOW_FAILED`, so an HTTP-only caller could not tell "this run is beyond + * reach" from "this run has a repair waiting", and a console that treats + * `400 FLOW_FAILED` as terminal closed on both. `data.status: 'stranded'`, + * declared on `TriggerFlowResponseSchema` above and parity-pinned to the + * contract, could not appear on the wire through any door. + * + * The ruling (option A): the resume door's `400 FLOW_FAILED` details carry the + * verdict in a shape a client can branch on WITHOUT a message regex — the + * registered error code, the `runId` of the run that is actually stranded, and + * `repairable`. ⛔ No `FLOW_STRANDED` sibling code is minted under it: a new + * code is a ledger event, and if a client needs a distinct code to branch, + * that is its own card. This schema is the structure the ruling asks to be + * declared ONCE in `packages/spec` and reused by the other carriers it names + * (the approvals decision and recall results, #15556 / #15970) — declared here + * ahead of them, so they spell the same members rather than their own. + * + * On the wire it rides INSIDE the same `error.details` object as + * `errorMessage` / `summary` (both stay optional and unchanged); a client + * parses `details` with this schema and branches on `repairable`. Present on + * the resume door's `400 FLOW_FAILED` arm and on no other door: the trigger + * door and `/actions` never resume, so "repairable" has no referent there and + * their `details` are unchanged — ABSENT there means "not a resume", never + * "not repairable". + */ +export const ResumeFailureDetailsSchema = lazySchema(() => z.object({ + runId: z.string().describe( + 'The run the resume was addressed to - the run that failed, and on the ' + + '`stranded` arm the run an operator verb can re-arm. Named so a caller ' + + 'acts on an identifier instead of parsing one out of the message', + ), + status: z.enum(['failed', 'stranded']).optional().describe( + 'The engine\'s own lifecycle verdict for the run, forwarded verbatim when ' + + 'the producer stamped one and never synthesised by the door - absent when ' + + 'the engine reported no status (a subflow child that failed terminally, ' + + 'an engine that predates the discriminator). `stranded` is the ' + + 'terminally-failed-but-repairable run of `AutomationResult.status`; ' + + '`failed` says the run ran and was rejected. These two terminal-failure ' + + 'members of that union are the only ones that can reach a 400', + ), + repairable: z.boolean().describe( + 'Whether the engine says this run can still be re-armed by an operator ' + + 'verb - `true` exactly when `status` is `stranded`, derived from the ' + + 'engine\'s discriminator and never from the message text. Always present ' + + 'on this arm: `false` is the honest answer for every other exit, the ones ' + + 'that report no status included, because an absent member would be ' + + 'indistinguishable from a server that predates this field, and promising ' + + 'a repair verb that will refuse is worse than promising nothing', + ), +})); +export type ResumeFailureDetails = z.input; +/** Post-parse shape of {@link ResumeFailureDetails} — defaults applied, transforms run (ADR-0122). */ +export type ResumeFailureDetailsParsed = z.infer; + // ========================================== // 8. Toggle Flow (POST /api/automation/:name/toggle) // ========================================== diff --git a/packages/verify/src/automation-resume-stranded-details.test.ts b/packages/verify/src/automation-resume-stranded-details.test.ts new file mode 100644 index 0000000000..601e0bb9d3 --- /dev/null +++ b/packages/verify/src/automation-resume-stranded-details.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15221 — the stranded verdict reaches the WIRE, driven through the real + * engine: `POST /automation/:name/runs/:runId/resume` on a run that consumed + * its pause and then failed downstream answers `400 FLOW_FAILED` whose + * `error.details` carry `status: 'stranded'`, `repairable: true` and the + * `runId` — the #16472 family ruling (maintainer 2026-09-07, option A). + * + * Why a real engine and not the dispatcher's fake: the dispatcher-level pins + * (`packages/runtime`'s `automation-resume-stranded-details.test.ts`) prove + * the door SHAPES what it is given; this proves the engine GIVES it — that + * the producer's `'stranded'` stamp (`resumeInternal`'s catch arm, + * `stranded-run-status.test.ts`) and the door's forwarding meet on one wire, + * which is the exact seam the card measured as broken: a contract member + * (`data.status: 'stranded'`, parity-pinned to `AutomationResult`) that no + * door could put on the wire. + * + * Two facts are held equal, the way the engine's own pin holds them: the wire + * says `repairable: true` ⇔ `restoreConsumedSuspension` accepts the run. A + * wire that promised a repair the verb refuses would be worse than the + * silence it replaces. + * + * CONTROL: the same flow with a downstream node that does NOT throw resumes + * to a 200 — the structure is a property of the failure arm, not of the + * route. + */ + +import { describe, it, expect } from 'vitest'; + +import { HttpDispatcher } from '@objectstack/runtime'; +import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { ResumeFailureDetailsSchema } from '@objectstack/spec/api'; + +const CTX = { request: {}, executionContext: { userId: 'user_1' } } as never; + +function createTestLogger(): never { + const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => logger }; + return logger as never; +} + +/** + * `resumeAuthority: 'any'` so the generic resume route is the intended door + * (an `approval` pause is `'service'`-owned and answers 403 long before the + * terminal arm — the populations do not overlap). + */ +const holdDescriptor = defineActionDescriptor({ + type: 'hold', version: '1.0.0', name: 'hold', + supportsPause: true, resumeAuthority: 'any', +}); +const tailDescriptor = defineActionDescriptor({ type: 'tail', version: '1.0.0', name: 'tail' }); + +/** start → hold (pauses) → tail (throws, or not) → end. */ +const STRAND_FLOW = { + name: 'strand_flow', label: 'Strand', type: 'autolaunched', + errorMessage: 'Please contact support', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'hold', type: 'hold', label: 'Hold' }, + { id: 'tail', type: 'tail', label: 'Tail' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'hold' }, + { id: 'e2', source: 'hold', target: 'tail' }, + { id: 'e3', source: 'tail', target: 'end' }, + ], +}; + +function boot(opts: { tailThrows: boolean }) { + const engine = new AutomationEngine(createTestLogger(), new InMemorySuspendedRunStore()); + engine.registerNodeExecutor({ + type: 'hold', + descriptor: holdDescriptor, + async execute() { + return { success: true, suspend: true, correlation: 'approval:req_1' }; + }, + } as never); + engine.registerNodeExecutor({ + type: 'tail', + descriptor: tailDescriptor, + async execute() { + if (opts.tailThrows) throw new Error('tail blew up'); + return { success: true, output: { done: true } }; + }, + } as never); + engine.registerFlow('strand_flow', STRAND_FLOW as never); + + const services: Record = { automation: engine }; + const resolve = (name: string): unknown => services[name]; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string): Promise => resolve(name), + context: { getService: resolve }, + }; + return { engine, dispatcher: new HttpDispatcher(kernel as never) }; +} + +/** Trigger through the door, and hand back the paused run's id off the 200. */ +async function park(dispatcher: HttpDispatcher): Promise { + const started = await dispatcher.handleAutomation('/strand_flow/trigger', 'POST', {}, CTX); + expect(started.response?.status).toBe(200); + expect(started.response?.body?.data?.status).toBe('paused'); + const runId = started.response?.body?.data?.runId as string; + expect(typeof runId).toBe('string'); + return runId; +} + +describe('#15221 — the wire carries the engine\'s stranded verdict through the resume door', () => { + it('a resume that consumed the pause and failed downstream answers 400 FLOW_FAILED with status stranded, repairable true and the runId', async () => { + const { engine, dispatcher } = boot({ tailThrows: true }); + const runId = await park(dispatcher); + + const result = await dispatcher.handleAutomation(`/strand_flow/runs/${runId}/resume`, 'POST', {}, CTX); + + expect(result.response?.status).toBe(400); + const error = result.response?.body?.error; + expect(error?.code).toBe('FLOW_FAILED'); + // The verdict, on the wire, by name. + expect(error?.details).toMatchObject({ runId, status: 'stranded', repairable: true }); + // Beside the artefacts that were already there — neither displaced. + expect(error?.details?.errorMessage).toBe('Please contact support'); + expect(error?.details?.summary?.nodes?.some((n: { status?: string }) => n.status === 'failure')).toBe(true); + // Typed: the spec's declaration parses the whole `details` object. + expect(ResumeFailureDetailsSchema.safeParse(error?.details).success).toBe(true); + // Not prose: the message names the failure, not the verdict. + expect(error?.message).toContain('tail blew up'); + expect(error?.message).not.toMatch(/strand/i); + + // One fact stated twice: `repairable: true` on the wire ⇔ the operator + // verb accepts exactly this run — and `resume` no longer can (the + // pause is gone), which is what makes the door's verdict worth having. + expect(await engine.hasSuspendedRun(runId)).toBe(false); + const again = await dispatcher.handleAutomation(`/strand_flow/runs/${runId}/resume`, 'POST', {}, CTX); + expect(again.response?.status).toBe(404); + const restored = await engine.restoreConsumedSuspension(runId, { requestedBy: 'ops' }); + expect(restored.restored).toBe(true); + expect(await engine.hasSuspendedRun(runId)).toBe(true); + }); + + it('CONTROL — the same flow whose downstream node succeeds resumes to 200 with no error envelope', async () => { + const { dispatcher } = boot({ tailThrows: false }); + const runId = await park(dispatcher); + + const result = await dispatcher.handleAutomation(`/strand_flow/runs/${runId}/resume`, 'POST', {}, CTX); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.success).toBe(true); + expect(result.response?.body?.error).toBeUndefined(); + }); +}); From 82370a517e1170550016840d0d68b50fab107e5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:43:24 +0000 Subject: [PATCH 2/6] chore(spec): regenerate the artifacts the new ResumeFailureDetailsSchema export moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api-surface, export-origins, declaration-map, the generated api reference page, and the unknown-key strictness ledger count (450 -> 451 in api/) — each regenerated by `check:generated --fix`, only the five it proved stale. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- content/docs/references/api/automation-api.mdx | 17 +++++++++++++++-- content/docs/references/index.mdx | 10 +++++----- ...6-07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/api-surface/api.json | 3 +++ packages/spec/authorable-surface/api.json | 3 +++ packages/spec/declaration-map/api.json | 2 ++ packages/spec/export-origins/api.json | 3 +++ packages/spec/json-schema.manifest/api.json | 1 + 8 files changed, 33 insertions(+), 8 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 83077d417a..1fa9d72e43 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -30,8 +30,8 @@ GET /api/automation/:name/runs/:runId — Get single execution run ## TypeScript Usage ```typescript -import { AutomationApiErrorCode, AutomationFlowPathParamsSchema, AutomationRunPathParamsSchema, CreateFlowRequestSchema, CreateFlowResponseSchema, DeleteFlowRequestSchema, DeleteFlowResponseSchema, FlowSummarySchema, GetFlowRequestSchema, GetFlowResponseSchema, GetRunRequestSchema, GetRunResponseSchema, ListFlowsRequestSchema, ListFlowsResponseSchema, ListRunsRequestSchema, ListRunsResponseSchema, ToggleFlowRequestSchema, ToggleFlowResponseSchema, TriggerFlowRequestSchema, TriggerFlowResponseSchema, UpdateFlowRequestSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api'; -import type { AutomationApiErrorCode, AutomationFlowPathParams, AutomationRunPathParams, CreateFlowRequest, CreateFlowResponse, DeleteFlowRequest, DeleteFlowResponse, FlowSummary, GetFlowRequest, GetFlowResponse, GetRunRequest, GetRunResponse, ListFlowsRequest, ListFlowsResponse, ListRunsRequest, ListRunsResponse, ToggleFlowRequest, ToggleFlowResponse, TriggerFlowRequest, TriggerFlowResponse, UpdateFlowRequest, UpdateFlowResponse } from '@objectstack/spec/api'; +import { AutomationApiErrorCode, AutomationFlowPathParamsSchema, AutomationRunPathParamsSchema, CreateFlowRequestSchema, CreateFlowResponseSchema, DeleteFlowRequestSchema, DeleteFlowResponseSchema, FlowSummarySchema, GetFlowRequestSchema, GetFlowResponseSchema, GetRunRequestSchema, GetRunResponseSchema, ListFlowsRequestSchema, ListFlowsResponseSchema, ListRunsRequestSchema, ListRunsResponseSchema, ResumeFailureDetailsSchema, ToggleFlowRequestSchema, ToggleFlowResponseSchema, TriggerFlowRequestSchema, TriggerFlowResponseSchema, UpdateFlowRequestSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api'; +import type { AutomationApiErrorCode, AutomationFlowPathParams, AutomationRunPathParams, CreateFlowRequest, CreateFlowResponse, DeleteFlowRequest, DeleteFlowResponse, FlowSummary, GetFlowRequest, GetFlowResponse, GetRunRequest, GetRunResponse, ListFlowsRequest, ListFlowsResponse, ListRunsRequest, ListRunsResponse, ResumeFailureDetails, ToggleFlowRequest, ToggleFlowResponse, TriggerFlowRequest, TriggerFlowResponse, UpdateFlowRequest, UpdateFlowResponse } from '@objectstack/spec/api'; // Validate data const result = AutomationApiErrorCode.parse(data); @@ -511,6 +511,19 @@ const result = AutomationApiErrorCode.parse(data); | **hasMore** | `boolean` | ✅ | Whether more runs are available | +--- + +## ResumeFailureDetails + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **runId** | `string` | ✅ | The run the resume was addressed to - the run that failed, and on the `stranded` arm the run an operator verb can re-arm. Named so a caller acts on an identifier instead of parsing one out of the message | +| **status** | `Enum<'failed' \| 'stranded'>` | optional | The engine's own lifecycle verdict for the run, forwarded verbatim when the producer stamped one and never synthesised by the door - absent when the engine reported no status (a subflow child that failed terminally, an engine that predates the discriminator). `stranded` is the terminally-failed-but-repairable run of `AutomationResult.status`; `failed` says the run ran and was rejected. These two terminal-failure members of that union are the only ones that can reach a 400 | +| **repairable** | `boolean` | ✅ | Whether the engine says this run can still be re-armed by an operator verb - `true` exactly when `status` is `stranded`, derived from the engine's discriminator and never from the message text. Always present on this arm: `false` is the honest answer for every other exit, the ones that report no status included, because an absent member would be indistinguishable from a server that predates this field, and promising a repair verb that will refuse is worse than promising nothing | + + --- ## ToggleFlowRequest diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 438008c672..a3916267c6 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1574 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1575 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 31 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1574** | 14 protocol modules | +| **Total** | **198** | **1575** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 436 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 437 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -70,7 +70,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `GetAnalyticsMetaRequest` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | | [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | -| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowSummary`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListFlowsRequest`, `ListFlowsResponse`, `ListRunsRequest`, `ListRunsResponse`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | +| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowSummary`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListFlowsRequest`, `ListFlowsResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | | [`batch.zod.ts`](/docs/references/api/batch) | `BatchConfig`, `BatchOperationResult`, `BatchOperationType`, `BatchOptions`, `BatchRecord`, `BatchUpdateRequest`, `BatchUpdateResponse`, `CrossObjectBatchDroppedFields`, `CrossObjectBatchOperation`, `CrossObjectBatchRequest`, `CrossObjectBatchResponse`, `DeleteManyRequest`, `UpdateManyRecord`, `UpdateManyRequest` | | [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` | | [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index a9f018a123..d138ab014c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 450 | +| `api/` | 451 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 8 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 45eb3a35cd..6275edbcd2 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -902,6 +902,9 @@ "RestServerConfig (type)", "RestServerConfigParsed (type)", "RestServerConfigSchema (const)", + "ResumeFailureDetails (type)", + "ResumeFailureDetailsParsed (type)", + "ResumeFailureDetailsSchema (const)", "RetryStrategy (const)", "RetryStrategy (type)", "RevertPackageCommitResponse (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index a032679976..36ecc5babf 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1500,6 +1500,9 @@ "api/RestServerConfig:metadata", "api/RestServerConfig:openApi31 [RETIRED]", "api/RestServerConfig:routes", + "api/ResumeFailureDetails:repairable", + "api/ResumeFailureDetails:runId", + "api/ResumeFailureDetails:status", "api/RevertPackageCommitResponse:failed", "api/RevertPackageCommitResponse:failedCount", "api/RevertPackageCommitResponse:revertCommitId", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index b089f3fbed..774f94795f 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -644,6 +644,8 @@ "RestQueryAdapterSchema": "api/RestQueryAdapter", "RestServerConfig": "api/RestServerConfig", "RestServerConfigSchema": "api/RestServerConfig", + "ResumeFailureDetails": "api/ResumeFailureDetails", + "ResumeFailureDetailsSchema": "api/ResumeFailureDetails", "RetryStrategy": "api/RetryStrategy", "RevertPackageCommitResponse": "api/RevertPackageCommitResponse", "RevertPackageCommitResponseSchema": "api/RevertPackageCommitResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 4980e2d401..883f2289d3 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -864,6 +864,9 @@ "RestServerConfig": "src/api/rest-server.zod.ts#RestServerConfig (type)", "RestServerConfigParsed": "src/api/rest-server.zod.ts#RestServerConfigParsed (type)", "RestServerConfigSchema": "src/api/rest-server.zod.ts#RestServerConfigSchema (const)", + "ResumeFailureDetails": "src/api/automation-api.zod.ts#ResumeFailureDetails (type)", + "ResumeFailureDetailsParsed": "src/api/automation-api.zod.ts#ResumeFailureDetailsParsed (type)", + "ResumeFailureDetailsSchema": "src/api/automation-api.zod.ts#ResumeFailureDetailsSchema (const)", "RetryStrategy": "src/api/errors.zod.ts#RetryStrategy (type)", "RevertPackageCommitResponse": "src/api/package-lifecycle.zod.ts#RevertPackageCommitResponse (type)", "RevertPackageCommitResponseParsed": "src/api/package-lifecycle.zod.ts#RevertPackageCommitResponseParsed (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 26ba5096f0..6bea8f1451 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -355,6 +355,7 @@ "api/RestApiRouteRegistration", "api/RestQueryAdapter", "api/RestServerConfig", + "api/ResumeFailureDetails", "api/RetryStrategy", "api/RevertPackageCommitResponse", "api/RollbackMetaItemResponse", From cde77edcc92a64bf163934cc9170690ee163f53d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:56:15 +0000 Subject: [PATCH 3/6] docs(permissions): re-anchor the system-context census rows the resume-door helper moved Pure line rot: the helper and its import shift four `ec.isSystem` read sites in domains/automation.ts; rewritten by the gate's own --fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- content/docs/permissions/system-context.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f61cbb4b79..88a1c53943 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -160,12 +160,12 @@ The largest single consumer — **17 of the 106 sites**. | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | | 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5520`, `:6977`, `:7225`, `:7656`, `:7849` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | -| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:552`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | +| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:1057`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:552`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:177`, `:268` | -| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` | +| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:255`, `:546`, `:636` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | | 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:77`, `webhook-provenance.ts:68` | | 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:319` | From aa5b987ae29288a9d0c37ba8607770dfa243e995 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:24:16 +0000 Subject: [PATCH 4/6] =?UTF-8?q?chore(changeset):=20grade=20@objectstack/cl?= =?UTF-8?q?ient=20minor=20=E2=80=94=20a=20clause-=E2=91=A1=20PR=20may=20no?= =?UTF-8?q?t=20grade=20a=20package=20it=20grew=20as=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check Changeset's finding on #16587: the PR declares clause-② yes and moved packages/client/src/**, and the 2026-09-04 ruling (decision batch #35, on #15294) binds per PR — at least `minor` for a package whose public surface this PR moved, whatever the commit type says. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- .changeset/automation-resume-stranded-details.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/automation-resume-stranded-details.md b/.changeset/automation-resume-stranded-details.md index c080333b31..cb2d2563ee 100644 --- a/.changeset/automation-resume-stranded-details.md +++ b/.changeset/automation-resume-stranded-details.md @@ -1,7 +1,7 @@ --- "@objectstack/spec": minor "@objectstack/runtime": minor -"@objectstack/client": patch +"@objectstack/client": minor --- The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded. From 782b92ceb5efa6b4af25a39a5ca8e011926cf2d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:43:24 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix(runtime):=20bind=20the=20relayed=20stat?= =?UTF-8?q?us=20to=20the=20published=20enum=20=E2=80=94=20a=20guard=20on?= =?UTF-8?q?=20the=20two=20terminal-failure=20members,=20and=20the=20/actio?= =?UTF-8?q?ns=20negative=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review follow-ups on #16587: resumeFailureDetails now returns ResumeFailureDetails, relaying status through a guard on 'failed' | 'stranded' (satisfies-bound to the schema's enum) so the compile-time binding is true by construction — still a relay, never a synthesised verdict. actions-flow-dispatch-status.test.ts gains the exact-equality negative pin the docblock claimed for /actions, and the docblock now names both pin files. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- .../src/actions-flow-dispatch-status.test.ts | 7 +++ packages/runtime/src/domains/automation.ts | 54 +++++++++++++------ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/actions-flow-dispatch-status.test.ts b/packages/runtime/src/actions-flow-dispatch-status.test.ts index 921eaf2b3a..20e746aa66 100644 --- a/packages/runtime/src/actions-flow-dispatch-status.test.ts +++ b/packages/runtime/src/actions-flow-dispatch-status.test.ts @@ -368,6 +368,13 @@ describe("#9585 — the failed run's artefacts ride BOTH doors' 400 details", () // `code` is promoted out of `details` into the declared field by the // shared envelope builder, never duplicated (`error-envelope.ts`). expect(res.response.body.error.details.code).toBeUndefined(); + // [#15221] …and NOTHING else rides here. The resume door's 400 carries + // the engine's verdict (`runId` / `status` / `repairable`, + // `ResumeFailureDetailsSchema`); this door never resumes, so + // "repairable" has no referent and the member is ABSENT — absent + // means "not a resume", never "not repairable". Exact equality is the + // negative pin: a member added here by accident reds this line. + expect(res.response.body.error.details).toEqual({ errorMessage: AUTHOR_MESSAGE, summary: SUMMARY }); }); it('one failed run, two doors, ONE details payload — the drift pin', async () => { diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 28abbb21a4..4f5478e39d 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -913,6 +913,19 @@ async function respondToFlowTrigger( return { handled: true, response: deps.success(result) }; } +/** + * The two `AutomationResult.status` members a `success: false` result can + * carry — the enum `ResumeFailureDetailsSchema.status` publishes, spelled + * once here and `satisfies`-bound to it (a member the spec drops reds this + * line; a member the spec adds is caught by the spec's own subset pin). + */ +const TERMINAL_FAILURE_STATUSES = ['failed', 'stranded'] as const satisfies readonly NonNullable[]; + +/** The guard {@link resumeFailureDetails} relays `status` through — a narrowing, never a default. */ +function isTerminalFailureStatus(status: AutomationResult['status']): status is (typeof TERMINAL_FAILURE_STATUSES)[number] { + return status !== undefined && (TERMINAL_FAILURE_STATUSES as readonly string[]).includes(status); +} + /** * [#15221] The machine-readable verdict the resume door's `400 FLOW_FAILED` * arm carries in `error.details`, beside the run's two artefacts — the @@ -923,8 +936,10 @@ async function respondToFlowTrigger( * * The structure is `ResumeFailureDetailsSchema` (`@objectstack/spec/api`), * declared once for every carrier the ruling names; this door is the - * PRODUCER of one of them, so the two members it owns are bound to that - * declaration at compile time (`satisfies`) and the third is relayed. + * PRODUCER of one of them, so the whole object is bound to that declaration + * at compile time (the return type IS `ResumeFailureDetails`): the two + * members the door owns are computed, and `status` is relayed through a + * guard on the two terminal-failure members the published enum names. * * What each member says, and why it is shaped the way it is: * @@ -935,13 +950,21 @@ async function respondToFlowTrigger( * terminal exit (the contract sets it on `'paused'` only), and this door * knows the id from the request rather than sniffing it out of the * engine's message. - * - `status` — the engine's own verdict, forwarded VERBATIM when it stamped - * one and never synthesised. Measured on the engine: the stranded exit - * stamps `'stranded'`; the other exit that reaches this arm — a subflow - * child that failed terminally — stamps nothing, so that arm carries no + * - `status` — the engine's own verdict, forwarded when it stamped one and + * never synthesised. Measured on the engine: the stranded exit stamps + * `'stranded'`; the other exit that reaches this arm — a subflow child + * that failed terminally — stamps nothing, so that arm carries no * `status` today rather than a `'failed'` this door made up. Reading the * producer's verdict is the whole rule (PD #12; `flow-dispatch-status.ts` - * says it for the trigger table). + * says it for the trigger table). It is relayed through a GUARD on the + * two terminal-failure members (`'failed' | 'stranded'`) — exactly the + * members `ResumeFailureDetailsSchema.status` publishes — so the binding + * is true by construction and not by accident of what is reachable: a + * `success: false` result stamped with a `success: true` verdict + * (`'completed'` / `'paused'` / `'refused'`, unreachable per the contract) + * is neither forwarded under a schema that refuses it nor turned into + * anything else. `TERMINAL_FAILURE_STATUSES` is `satisfies`-bound to the + * schema's enum, so a member the spec drops reds this file. * - `repairable` — `status === 'stranded'`, and ALWAYS present on this arm. * Present-and-false on the plain terminal exit is a deliberate contract, * not an implementation detail: an ABSENT member would be @@ -962,17 +985,16 @@ async function respondToFlowTrigger( * ⛔ Not on the trigger door and not on `/actions`: neither ever resumes, so * "repairable" has no referent there; their `400 FLOW_FAILED` details stay * `{ errorMessage?, summary? }`, and an absent `repairable` there means "not - * a resume", never "not repairable". Pinned in - * `automation-resume-stranded-details.test.ts`, both halves. + * a resume", never "not repairable". Pinned as exact `details` equality at + * both doors: the trigger door in `automation-resume-stranded-details.test.ts`, + * `/actions` in `actions-flow-dispatch-status.test.ts` (#9585's artefacts pin). */ -function resumeFailureDetails(runId: string, result: AutomationResult): Record { - const verdict = { - runId, - repairable: result.status === 'stranded', - } satisfies Omit; +function resumeFailureDetails(runId: string, result: AutomationResult): ResumeFailureDetails { + const status = isTerminalFailureStatus(result.status) ? result.status : undefined; return { - ...verdict, - ...(result.status !== undefined ? { status: result.status } : {}), + runId, + repairable: status === 'stranded', + ...(status !== undefined ? { status } : {}), }; } From c3208dfe0e1b62adfe63b510a60ca8f8f8b57815 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:46:33 +0000 Subject: [PATCH 6/6] docs(permissions): re-anchor the census row the terminal-failure guard moved Pure line rot again: the guard, its constant and their docblocks sit above the anonymous-deny read in domains/automation.ts, shifting it :1057 -> :1079; rewritten by the gate's own --fix, population unchanged (106 sites / 141 anchors). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 88a1c53943..460dac0c50 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -160,7 +160,7 @@ The largest single consumer — **17 of the 106 sites**. | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | | 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5520`, `:6977`, `:7225`, `:7656`, `:7849` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | -| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:1057`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:552`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | +| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:1079`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:552`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` |