Skip to content

fix(services): a paused run's variable snapshot is readable on run-detail (#7639) - #7896

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7639-paused-run-variables
Aug 12, 2026
Merged

fix(services): a paused run's variable snapshot is readable on run-detail (#7639)#7896
huangyiirene merged 1 commit into
mainfrom
claude/issue-7639-paused-run-variables

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Closes#7639

What was wrong

While an automation run was paused, GET /api/v1/automation/{flow}/runs/{runId} carried no variables key at all. A run stopped at an approval, a screen or a wait — precisely the state an operator most often needs to inspect — answered with no variable state, so "what did the previous node actually produce, and why did the next one route the way it did?" could only be inferred backwards from whatever the next node happened to resolve.

Structural, not a data gap. The engine's two status: 'paused'recordLog call sites passed id/flowName/flowVersion/startedAt/durationMs/trigger/steps and stopped there — while sitting a few lines below the suspend bookkeeping that already computes Object.fromEntries(variables) for the continuation.

The premise this was dispatched under, and what measuring it found

The dispatch gated implementation on a falsifiable claim: the same run-detail read already serves output and steps under the same access control, so adding variables is consistency with the existing exposure envelope rather than a new disclosure surface. If variables turned out to carry a strictly wider class of data — credentials output/steps provably cannot hold, or a bypass of a redaction the other fields go through — the instruction was to stop and escalate.

The premise holds, and the code settles it more directly than the framing did:

  • packages/runtime/src/domains/automation.ts:489-496 — the handler answers deps.success(run) with the ExecutionLogEntryverbatim. There is no per-field projection, redaction or masking anywhere on this path, for any field. So output, steps and variables are not three policies; they are one object.
  • The only gate on this read is the [17.0-rc2验收] 安全:REST /actions 与 /automation 派发路由缺少匿名拒绝门 —— 未认证调用者可触发 system 提权的 RLS/FLS 绕过写入 #5519 anonymous baseline at automation.ts:135-147, applied to the whole/automation domain. Access control is identical by construction, not by coincidence.
  • Stronger than the premise as stated: variables was already declared on this surface.packages/spec/src/automation/execution.zod.ts:258 declares variables: z.record(z.string(), z.unknown()).optional()"Final state of flow variables" — and the engine's internal ExecutionLogEntry declares the same optional field. Neither has ever had a producer. The disclosure decision the card worried about was taken at schema-authoring time; only the writer was missing. Same declared-with-no-writer shape as StepLogEntry.retryAttempt ([Decision] A failed try_catch try-region produces no step at all — after a caught failure an operator cannot tell what failed, how many attempts ran, or which node threw #7546).
  • No engine path injects platform credentials into the variable map: connector credentials are held by the connector registry and never handed to a flow, and the http node's signingSecret/headers are config inputs read from variables, never written back to output (builtin/http-nodes.ts:115-170, whose output is {response, status}). A secret reaches variables only if a flow author puts one there — and by that same route it can reach output, which is a projection of the identical map.

One thing the framing got wrong, recorded because it is a real delta the premise's wording glosses:output and steps are not as wide as variables. output is an author opt-in projection of the same map (output[v.name] = variables.get(v.name) for isOutput variables only), and StepLogEntry carries no values at all — node ids, types, statuses, timings, error messages, warnings, metrics. The paused snapshot is unconditionally broader: every node output under <nodeId>.<key>, the triggering record and its flattened fields, previous, and the seeded flow inputs. That is unconditional vs. opt-in breadth of the same data class — not a wider class, and not a redaction bypass, so it clears the STOP condition. The residual worth naming for a future card is that this route is gated only by "authenticated", whereas the identical snapshot's other door — sys_automation_run.variables_json, which already persists it verbatim for every paused run — is gated by that system object's permissions.

The change

Both status: 'paused'recordLog sites now carry the snapshot:

  • the initial-execution suspend (execute's isSuspendSignal catch)
  • the resume-path re-suspend (resumeInternal's catch) — a multi-stage approval re-pauses here on every stage after the first, so covering only the other site would leave exactly the stages an operator needs to inspect unreadable

Each site takes one snapshot expression and hands the same object to both persistSuspendedRun and recordLog, so the state run-detail shows cannot disagree with the state the run will resume from — and there is no extra retained copy.

Snapshot semantics

Point-in-time copy taken at the suspend — not a live read. The map is dead by the time the log entry exists: the run has unwound to the catch, and resume rebuilds a fresh map from the continuation rather than reusing this one. So there is nothing later for the snapshot to diverge from, and because the continuation receives the very same object, the snapshot is by construction the state the run resumes from. Only paused gains the key; completed/failed keep exactly the fields they had, since widening those would be a disclosure change with no card behind it.

Redaction — what was mirrored

The existing surface applies none, to any field. So the mirror is to invent none, and to pin that fact rather than trust it: automation-run-detail-passthrough.test.ts drives one nested value through output (terminal run) and through variables (paused run) and asserts they read back deep-equal, and paused-run-variables.test.ts does the same at the engine level. If a future change starts shaping either field without the other, those assertions fail.

Tests

packages/services/service-automation/src/paused-run-variables.test.ts (real AutomationEngine, real pause/resume — no engine double):

  • the snapshot is written at the initial-execution site — node outputs under <nodeId>.<key>, the triggering record, declared flow variables, run identity
  • the snapshot is written at the resume-path re-suspend site, carrying stage-1's outputs, the resume signal's own write, and what stage 2 resolved before pausing
  • run-detail's snapshot deep-equals the persisted continuation's
  • shaping parity with output

packages/runtime/src/domains/automation-run-detail-passthrough.test.ts (the wire half):

Reverse-verified: all four engine assertions fail on main's code, and reverting only the resume-path hunk fails exactly the resume-path test — so each site is independently pinned.

Deliberately not done

  • getRun after a restart. A paused run's log entry lives only in the in-memory ring buffer (recordLog mirrors terminal runs to the store; paused ones by design are not terminal). After a restart, getRun on a still-paused run returns null even though sys_automation_run holds the row — a pre-existing gap, orthogonal to the two call sites this card names, and closing it would change getRun's answer for paused runs from 404 to a run.
  • No packages/spec change, so no gen:schema / gen:docs regeneration is required — the key was already declared.

Verification

  • service-automation: 936 tests / 78 files pass
  • runtime domain suite: 426 tests / 21 files pass
  • plugin-approvals (consumes getRun): 456 tests pass
  • ESLint clean on all changed files; tsc --noEmit reports nothing on them

Generated by Claude Code

…tail (#7639)
While a run was paused, `GET /api/v1/automation/{flow}/runs/{runId}` carried no
`variables` key at all — so a run stopped at an approval, a screen or a wait,
the state an operator most often needs to inspect, answered with no variable
state. "What did the previous node produce, and why did the next one route the
way it did?" was answerable only by inference from what the next node resolved.
Structural, not a data gap. `ExecutionLogSchema` has declared `variables`
("Final state of flow variables") since the schema was written, and the engine's
own log entry declared it too — with no producer anywhere, so the key the
run-detail read publishes was never populated. The same declared-with-no-writer
shape as `StepLogEntry.retryAttempt` (#7546). The engine already held the
answer: both `status: 'paused'` recordLog sites sit just below the suspend
bookkeeping that computes `Object.fromEntries(variables)` for the continuation.
Both paused sites now write it — the initial-execution suspend AND the
resume-path re-suspend, so a multi-stage approval is readable at every stage and
not only the first. Each site takes ONE snapshot expression and hands the same
object to the continuation and to the log entry, so what an operator reads
cannot disagree with the state the run will resume from.
Snapshot semantics: point-in-time at the suspend, not a live read. The variable
map is dead by then (the run has unwound; resume rebuilds a fresh one from the
continuation), so there is nothing later to diverge from.
The exposure envelope is unchanged. The run-detail read serves the log entry
verbatim — no projection, redaction or masking on any field — so `variables`
gets exactly the treatment `output` and `steps` already get, under the same
#5519 anonymous baseline that gates the whole /automation domain. No new
redaction policy is invented; the tests pin that the three fields keep ONE
shaping policy. Terminal runs keep exactly the fields they had.
Closes#7639
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 2:42am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation.

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

  • content/docs/automation/flows.mdx(via @objectstack/service-automation)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/service-automation)
  • content/docs/plugins/packages.mdx(via @objectstack/service-automation)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/service-automation)
  • content/docs/releases/v9.mdx(via @objectstack/service-automation)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: a suspended flow run's variables are unreadable on run-detail — the paused recordLog omits them while the continuation keeps them

2 participants

@huangyiirene@claude