Skip to content

fix(automation): resume gate follows map: too; the route stops accepting engine-internal variables (#3853) - #3860

Merged
os-zhuang merged 1 commit into
mainfrom
claude/run-resume-auth-gate-w67lvk
Jul 28, 2026
Merged

fix(automation): resume gate follows map: too; the route stops accepting engine-internal variables (#3853)#3860
os-zhuang merged 1 commit into
mainfrom
claude/run-resume-auth-gate-w67lvk

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3853. Follow-up to #3822 — two holes in the gate that PR shipped, both demonstrated with a repro before fixing, not reasoned.

1. The chain walk missed map:

resumeInternal handles the two linked-run correlations oppositely (engine.ts:2080):

if (run.correlation?.startsWith('map:')) {
    await this.executeNode(node, flow, variables, context, steps);   // RE-RUN the map node
} else {
    await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
}

The gate's chain walk followed only subflow:. A run parked on a map node was therefore judged on map itself — resumeAuthority: 'any' — and let through even while the item it was waiting on sat on an approval.

map is the batch-approval shape (ADR-0037 A2, showcase_release_signoff), and the map parent's run id is the one a launcher holds. $mapState.started is advanced past the in-flight item before the suspend (map-node.ts:155-156), and the re-entry records a result only when $mapItemDone is set (map-node.ts:113-118), so an empty-body resume of the parent skipped that item's approval outright:

before:  { started: 1, results: [] }
POST .../runs/{parentRunId}/resume   {}
after:   { started: 2, results: [] }      ← item 1 skipped, never decided
suspended child runs: 2                   ← item 1 orphaned (request still pending) + item 2

Knock-on: item 1's eventual real decision bubbles into a parent now waiting on item 2, so its output lands under the wrong index and the misalignment cascades.

Fix. The walk follows both prefixes. The unifying rule is that a linked-run pause is waiting on a child, so the child's node carries the authority — the gate reads the item, not the loop. They differ only in what continuing would mean (lands on the child vs. advances past it), which the refusal message now distinguishes.

2. Resume inputs could write the engine's $ namespace

signal.variables are applied as bare flow variables, so a caller could set the exact handoff keys bubbleToParent uses — with the node id readable from GET /automation/:name (an SDK route) — and have the map record a per-item result for a decision nobody made:

POST .../runs/{parentRunId}/resume
{ "inputs": { "signoffs.$mapItemDone": true,
              "signoffs.$mapItemOutput": { "result": "FORGED — never approved" } } }
→ { started: 2, results: [ { "result": "FORGED — never approved" } ] }

The same reached $runId, which approval / wait nodes read to correlate external state back to a run. $ is the engine's namespace throughout ($runId, $flowName, $flowLabel, $record, $error, $parentRunId, $parentMapNode, $parentOutputVariable, <nodeId>.$mapState); authors never write there.

Fix. The route answers 400 for a reserved name ($… or a .$ segment). Deliberately at the transport and not in the engine: bubbleToParent legitimately writes those keys in-process, and this is the same trust split the gate itself uses — strict at the untrusted boundary, unrestricted for code that already holds the authority. Refuse rather than silently strip, so a mis-authored screen input fails at the door instead of many nodes downstream.

Note (2) is not fixed by (1): a map whose pending item sits on an ungated screen/wait is still resumable, and the handoff keys were still writable there.

Consumer impact

Author-declared variables are unaffected — { new_assignee: 'ada' } and dotted names like collect.note still pass (covered by a test). If you were driving a batch-approval map by resuming the map's own run id, resume the item's run through its owning service instead (client.approvals.approve) — the map advances itself when the item completes.

Tests

resume-authority-gate.test.ts grows a through a map pause block (14 total): the map parent is refused while an item is gated and nothing is consumed ($mapState unchanged, both runs still suspended); the item completing through its owning service still advances the map and runs the item's downstream (no regression to the bubble); a map over ungated items stays resumable. http-dispatcher.test.ts covers the 400 for the three reserved shapes plus the "ordinary inputs still pass" control, and asserts the engine is never reached on refusal.

Verified: service-automation (35 files), plugin-approvals (12 files), runtime (47 files / 696 tests) all green; eslint --no-inline-config clean; check:doc-authoring and check:docs in sync.

Docs: the flows.mdx gate section now describes the linked-run walk in both directions plus the $-namespace rule; the batch-approval section says the map's own run id is refused while an item is open; ADR-0019 gains a #3853 addendum and the #3801 addendum's chain-walk bullet is corrected.

Distinct from #3823 (revise-window wait), which needs a per-suspension owner claim rather than a chain walk.


Generated by Claude Code

…pting engine-internal variables (#3853)

Two holes in the #3801 gate, both demonstrated with a repro before fixing.

1. The chain walk missed `map:`. `resumeInternal` handles the two linked-run
   correlations oppositely — `subflow:` DELEGATES the signal to the child,
   `map:` RE-RUNS the map node — and the gate followed only the first. A run
   parked on a `map` node was therefore judged on `map` itself
   (resumeAuthority 'any') and let through even while the item it was waiting
   on sat on an `approval`.

   `map` is the batch-approval shape and the map parent's run id is the one a
   launcher holds. Since `$mapState.started` is advanced past the in-flight
   item before the suspend, and the re-entry records a result only when
   `$mapItemDone` is set, an empty-body resume of the parent skipped that
   item's approval outright and orphaned its still-pending request; a later
   real decision then bubbled into a parent already waiting on the next item,
   cascading the misalignment.

   The walk now follows both prefixes. The unifying rule: a linked-run pause is
   waiting on a CHILD, so the child's node carries the authority — read the
   item, not the loop. They differ only in what continuing would mean (lands on
   the child vs. advances past it), which the refusal message now says.

2. Resume `inputs` could write the engine's `$` namespace. They are applied as
   bare flow variables, so a caller could set the exact handoff keys
   bubbleToParent uses (`<nodeId>.$mapItemDone` / `$mapItemOutput`) and have the
   map record a per-item result for a decision nobody made — the node id is
   readable from GET /automation/:name. The same reached `$runId`, which
   approval/wait nodes use to correlate external state back to a run.

   The route now answers 400 for a reserved name (`$…` or a `.$` segment).
   Enforced at the transport, not in the engine, so the in-process bubble keeps
   working — the same trust split the gate itself uses. Refuse rather than
   silently strip, so a mis-authored screen input fails at the door.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gvN32u1EiuvY9uQEMJiMR
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 12:03pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, packages/services.

23 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/automation/webhooks.mdx (via packages/services)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime, packages/services)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 12:50
@os-zhuang
os-zhuang merged commit 70a1ce1 into main Jul 28, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/run-resume-auth-gate-w67lvk branch July 28, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: the #3801 resume gate follows subflow: but not map:, and the resume body can write engine-internal variables

2 participants