Repository navigation
security: POST /automation/:name/runs/:runId/resume never checks WHO is resuming — an authenticated stranger with a run id continues another user's paused screen run, which then runs under the starter's stored context #19987
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actions分诊首次定级:
priority:p1·security·bug·domain:cli·pm:queue—— 暂停流程的"恢复"接口不检查是谁在恢复,拿到别人 run id 的已登录用户可以替原发起人继续执行Path:
packages/runtime/src/domains/automation.ts(恢复分支:2141→automationService.resume:2285;读取侧的对照门refuseUnrelatedScreenRead:1067)Triage: lands in the runtime automation HTTP domain ⇒
domain:cli(domain table:packages/runtime/src/domains/automation.ts),security,bug,priority:p1,pm:queue; rationale: the resume write door admits any authenticated caller holding a run id, and the resumed run continues under the STARTER's stored context (engine.ts:6783), so a stranger can drive another user'srunAs: 'user'flow with values they submitted — while the read twin on the same pause already refuses that stranger by the maintainer's #7968 Option B; not p0 because run ids are not guessable and listing them needs thesys_automation_rungrant; direction = execution, not a new decision (below).分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T15:26Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在main(a7581b326c)上核对了代码。本席核对
automation.ts:2141的恢复分支到:2285调automationService.resume之间,只校验请求体形状,没有读取userId/executionContext/getRun,也没有拒绝逻辑。与卡面一致。engine.ts:6783:const context = run.context,恢复后按发起人存下的上下文继续执行。- 同一次暂停的读取接口
GET /:name/runs/:runId/screen已由refuseUnrelatedScreenRead(:1067)拒绝陌生人:要么是本次运行的trigger.userId,要么持有sys_automation_run读权限(维护者在 finding:GET /automation/:name/runs/:runId/screenstill discloses record-derived values to any authenticated caller who knows a run id #7968 裁定的 Option B)。写入这一侧没有对应的门。
定级说明
- p1 + security:后果是冒用他人身份继续执行流程、写入数据。之所以不定 p0:run id 猜不到,列出 run id 需要
sys_automation_run权限(运维角色)。 - 方向由分诊定,属于执行,不需要新裁决:把 finding:
GET /automation/:name/runs/:runId/screenstill discloses record-derived values to any authenticated caller who knows a run id #7968 已裁定的 Option B 形状(本次运行的发起人,或持有sys_automation_run权限)套到写入这一侧。卡面担心的"会拒绝流程正在等待的那个人":对 screen 节点来说,等待的就是发起人本人,Option B 不会拒绝他。 ⚠️ 限定条件,实施时必须遵守:不得拒绝节点自身resumeAuthority已经明确授权的人(例如审批节点指定的审批人,他们不是发起人)。也就是说,新门只在节点没有更具体授权的情况下补上"发起人或权限"这一条。实施者先读resumeAuthority各取值的现状,写进 PR。- 按运行实例授权的完整方案(finding:
GET /automation/:name/runs/:runId/screenstill discloses record-derived values to any authenticated caller who knows a run id #7968 记录的 Option A,ADR-0019 类设计)仍是另一件事,本卡不做。
执行要点
- 先把卡面记录的探测写成测试:
u2POSTflow_a/runs/run_1/resume,而getRun().trigger.userId是u1⇒ 应被拒绝(与读取侧同样的状态码和措辞);u1本人和持权限者 ⇒ 放行;审批类节点指定的审批人 ⇒ 放行。 - 拒绝的措辞和状态码与
refuseUnrelatedScreenRead保持一致,最好复用同一个判断。 - PR feat(mcp): resume_run completes a paused screen flow — the caller's own run, behind run_action's gates #19985(MCP
run_actionon a screen flow dead-ends: the screen pauses even when every input is bound, andlist_actionsnever surfaces flow input variables #15705)已在 MCP 的resume_run上单独加了归属检查,⛔ 不在本卡范围内;两边落地后判断口径应一致。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsSerial fence recorded by the
domain:cliseat: this card waits on PR #19985's merge (same file), and on the lane's serial-dispatch orderdomain:cliexecution PM seat #6024 · sessionsession_01TnPAC1UsTGfHPXVUCL6iLn· 2026-09-24T15:37Z. ⛔ Not a claim. The card stayspm:queue, and nothing on it is relabelled.Why it is not dispatched yet. Two readings; both must clear.
- Same-file hard serial. The fix lands in
packages/runtime/src/domains/automation.ts, the resume arm. PR feat(mcp): resume_run completes a paused screen flow — the caller's own run, behind run_action's gates #19985 (MCPrun_actionon a screen flow dead-ends: the screen pauses even when every input is bound, andlist_actionsnever surfaces flow input variables #15705, open, draft) edits that same file and that same arm: it moves the arm's engine-answer mapping, unchanged, into the exportedclassifyResumeResult, which MCPresume_runnow shares. That PR's merge releases the fence, ⛔ not its ready flip. - The lane's standing instruction. The maintainer, verbatim, on seat post [PM seat] domain:cli — ⏳ vacant #6024 (
5815600379): 「当前任务处理完后改为串行派发」 (switch to serial dispatch once the current tasks are done). MCPrun_actionon a screen flow dead-ends: the screen pauses even when every input is bound, andlist_actionsnever surfaces flow input variables #15705 is the last unfinished card of that batch. It is held draft only because itsClause-②: yesat-tier review cannot run: the tier's usage limit refused it at 2026-09-24T14:14Z and again when re-measured at 2026-09-24T15:08Z. The one bypass is the maintainer reviewing it personally.
⇒ This card is next in the lane the moment #19985 merges. Its order is
priority:p1+security, above every p2 in the queue.Known ground for whoever takes it (leads, ⛔ not a spec; re-read on the merged ref):
- The MCP door's gate in feat(mcp): resume_run completes a paused screen flow — the caller's own run, behind run_action's gates #19985 is the in-repo precedent for triage's direction.
resumeActionRun(packages/runtime/src/domains/mcp.tson that PR's head) refuses unlessgetRun(runId).trigger.userIdequals the caller, and it answers unknown, finished and foreign runs with ONE 404, so there is no existence oracle. - The REST read twin
refuseUnrelatedScreenRead(automation.ts:1067) is Option B's shape: trigger identity OR thesys_automation_runread grant as the operator override. Triage directs the write to take that shape, so the override half stays here, unlike on the MCP door. - Watch the ordering the finding:
GET /automation/:name/runs/:runId/screenstill discloses record-derived values to any authenticated caller who knows a run id #7968 docblock records for the read gate: it answers 404-first so that a nonexistent run keeps today's answer. The write door has its own answers for unknown and finished runs (the shared table), and those should not move for honest callers.
- Same-file hard serial. The fix lands in
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsClaim: PM loop round 2 (serial)
Session:session_01TnPAC1UsTGfHPXVUCL6iLn
Branch:claude/issue-19987-resume-caller-gate
Worktree:objectstack-issue-19987
Domain:domain:cli
Seat:domain:cli#1
File surface:packages/runtime/src/domains/automation.ts(thePOST /:name/runs/:runId/resumearm and the caller predicate it shares withrefuseUnrelatedScreenRead), its tests underpackages/runtime/src/, a hand-writtencontent/docs/**page only if it states who may resume a run, and one.changeset/19987-*.md.packages/services/service-automation/**andpackages/spec/**are read-only (stop on breach; explain in the report)
File surface amendment (2026-09-24T17:42Z, same round, on the dev's declared deviation): +packages/runtime/src/route-ledger.ts(the resume row's note, which now names the caller gate) and +content/docs/permissions/system-context.mdx(census row re-anchored to the shared predicate;check:system-context-censusrefused the tree without it).
Container & model:M,mode:subagent,model: default judgment tier(this act'snode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/runtime/src/domains/automation.tsanswered: no path-derived mandate). This is a p1 security fix that must reconcile a new gate with the node-levelresumeAuthorityit may not override, so it gets the default judgment tier.
Clause-②: no
Thread-read: 5817250560
Serial constraints cleared:the maintainer's serial-dispatch order (5815600379) now admits one card: R1's three cards all landed (3fd3a4f9, cdc1ae03, 08b213e0). This card's same-file fence, PR #19985 on domains/automation.ts, merged as 08b213e0. File lists of all open PRs read this act: none touches domains/automation.ts or service-automation/src/engine.ts. Lane in flight: 0.Taken 2026-09-24T16:23Z,
origin/mainf3a3afc6.Clause-②: nobecause refusing a stranger narrows who may resume and reuses the read twin's existing refusal code. A NEW error code would make ityes, so the dev stops and reports if one is needed. The dev posts no second claim; this comment is its identity.objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 19987,
"status": "done",
"branch": "claude/issue-19987-resume-caller-gate",
"pr": "#20005",
"session": "session_01TnPAC1UsTGfHPXVUCL6iLn — the dispatching PM seat session (subagent run: the parent's id); identity = the branch",
"premise_still_valid": true,
"summary": "Premise held on origin/main 6bf7f1c: the resume arm (automation.ts ~2250) reached automationService.resume with no caller check; the card's probe, committed as a test, failed 17/50 before the fix (u2 on u1's run: 200, resume called). The arm now asks the screen read's question through ONE shared predicate, isRunStarterOrRunStateReader (run's trigger.userId === caller, OR the sys_automation_run read grant via mayReadRunState, OR isSystem); refuseUnrelatedScreenRead and the new refuseUnrelatedResume are thin guard clauses over it, the read twin byte-identical. Refusal = 403 PERMISSION_DENIED (the read twin's RUN_READ_DENY_CODE/STATUS, no new code), placed after the body checks and immediately before resume(), so nothing is consumed; it fails closed on a run getRun cannot resolve because getRun's null is not authoritative (degraded store read, ring eviction with no store). PM mechanism assumption 3 is falsified in its premise but not blocking: resumeAuthority is the closed enum 'any'|'service' and names no person — 'any' (screen, wait, map, subflow) authorizes no specific caller, 'service' (approval, approval_revise, undeclared) is refused by the engine to every HTTP caller, and approvers resume in process via ApprovalService — so no node-named caller exists on this door and no engine change is needed; the full table is in the PR. MCP resume_run untouched (starter-only, stated side by side in the PR). Docs: flows.mdx 'Who may resume' rewritten; system-context.mdx census row re-anchored (forced by check:system-context-census). Changeset 19987-resume-caller-gate.md, @objectstack/runtime patch, Clause-②: no. assignee was os-steve on arrival (PM-set), untouched.",
"tests": "All @ HEAD b4cb8a4 unless noted. New suite automation-resume-caller-gate.test.ts (50 cases; every refusal asserts error code PERMISSION_DENIED + status 403 + resume NOT called; parity table drives read and write doors with the same 3 callers x 5 security postures x 2 runs and requires the same verdict, code, status and requirement sentence). Before fix @ base 6bf7f1c: Tests 17 failed | 33 passed (50). After: gate suites green; runtime --project local @ a99d03a: Test Files 276 passed, Tests 3893 passed | 1 skipped; --project repo: 69 passed; the 9 suites reading the route ledger / resume arm re-run @ b4cb8a4: 436 passed; runtime typecheck exit 0 (test layer OK); dogfood flow-durable-suspend against rebuilt runtime dist: 11 passed. Ablation via node scripts/ablation-replace.mjs on committed a99d03a (gate code identical at HEAD): anchor 'if (unrelated) return unrelated;' x1 -› x0, blob 467c4019f123 -› e8f9a4915391 (mutation landed); gate suites went Tests 15 failed | 57 passed (72) — every stranger/unresolved row red, every starter/grant/system row green (direction: red, as expected); restore proven: blob after restore == HEAD blob 467c4019f123, git diff HEAD empty. No dist involved (suite imports the arm by relative source path). Rewrote the screen-read-gate case that pinned the defect (stranger reached resume) into its inverse. Gates: dispatch-gates derived 92 on b4cb8a4, 92 run, all exit 0, --ran reconciliation a DERIVED zero; pnpm lint full union exit 0.",
"mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "2 — (1) pr_create through the fleet-write relay: POST /repos/objectstack-ai/objectstack/dispatches (repository_dispatch, HTTP 204; relay run 36035584063 success) executing POST /repos/objectstack-ai/objectstack/pulls as objectstack-fleet[bot] → PR #20005, draft; (2) this os-dev-report via node scripts/pm/post-stamped.mjs --comment=19987, which also routes over the relay (POST /repos/objectstack-ai/objectstack/dispatches) executing POST /repos//issues/19987/comments as objectstack-fleet[bot]. git push (x3) is not a REST write. Label writes: 0 (order: zero). Reads: REST GET of the card, its comments and the PR list.",
"open_questions": [],
"out_of_scope_findings": [
"class: none (dormant, not a/b/c) · WaitExecutorConfigSchema.webhookUrlPattern (packages/spec/src/automation/node-executor.zod.ts) defaults to /api/v1/automation/resume/{executionId}/{nodeId}, a path no route serves, zero runtime readers (only a spec test) · dedupe words: webhookUrlPattern · wait webhook resume url · automation/resume executionId nodeId · carrier: none — noted in PR Acceptance notes, not filed",
"class: none (incomplete list, not wrong) · the client SDK automation.resume docblock (packages/client/src/index.ts) lists the resume refusals but not the new 403 PERMISSION_DENIED caller refusal · dedupe words: client automation.resume docblock 403 · resume refusal list · carrier: none — noted in PR Acceptance notes, not filed"
],
"gates": {
"node scripts/check-adr-0087-registration.mjs --base origin/main": "exit 0 · ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).",
"node scripts/check-adr-0087-registration.mjs --self-test": "exit 0 · ✓ check-adr-0087-registration --self-test: 441 assertions over real temp git repos (real scan()/assertInputs() path)",
"node scripts/check-changeset-no-major.mjs --base origin/main": "exit 0 · rather than by omission. It is a PR-scoped reading: the RC cut (cut-rc.yml) and a local run reach here, and neither is a PR that could have declared. · nopull_requestpayload was available to read a declaration from",
"node scripts/check-changeset-no-major.mjs --self-test": "exit 0 · dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real temp git repos + the #4690 pins + the LEVEL axis on #16044's two real heads + the wiring).",
"node scripts/check-ci-filter-parity.mjs": "exit 0 · OK: all 184 declared cross-package glob(s) (131 unique) are covered bycoreorcrosspkg, everycrosspkgentry still covers one, and thetestjob'sif:still names both filters.",
"node scripts/check-closing-keyword-parity.mjs": "exit 0 · for a fourth parser did not cover them (every registered file WAS read -- the cutoff does not apply to those): • packages/spec/CHANGELOG.md -- 6080503 bytes exceeds the sweep's 2097152-byte cutoff for UNREGISTERED files",
"node scripts/check-closing-keyword-parity.mjs --self-test": "exit 0 · ✓ check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of the shipped parsers each driven to red.",
"node scripts/check-comment-mask-adoption.mjs": "exit 0 · OK check:comment-mask-adoption — 14 private comment-stripper(s) under packages/** + examples/, all 14 recorded and every recorded row still reached (13 unconverted, 1 specimen). A new one reds here.",
"node scripts/check-comment-mask-adoption.mjs --self-test": "exit 0 · ok POSITIVE CONTROL — the shared module itself reads as a stripper PASS check-comment-mask-adoption --self-test (0 failure(s))",
"node scripts/check-comment-mask-corpus.mjs": "exit 0 · ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 7083 files, 0 disagree, 0 unparseable, 74.9s (comparator self-test: 26 cases pass).",
"node scripts/check-doc-frontmatter.mjs": "exit 0 · e 3 that declare one;authora string on the 3 that declare one;tagsan array of strings on the 3 that declare one (presence of an optional key is not required — the schema marks it optional). Not asserted:date—",
"node scripts/check-doc-frontmatter.mjs --self-test": "exit 0 · d firing, five REFUSALS over real directories (absent root, empty tree, a tree holding no pages, a dangling symlink, a directory wearing a page's name) each proved against a readable tree that still returns a verdict, th",
"node scripts/check-doc-route-spelling.mjs --advisory": "exit 0 · /api/v1/workflow (content/docs/kernel/services-checklist.mdx:363) ✓ route-spelling guard (advisory): population clean — every shape-matched literal spells its ledger row.",
"node scripts/check-doc-route-spelling.mjs --self-test": "exit 0 · roots in), ledger parsing (absolute rows unprefixed, wildcard families, servedBy/note prose not rows), the measured drift class flagging by name (plural, the metadata third spelling, a singularised family, the scoped mir",
"node scripts/check-docs-section-name.mjs": "exit 0 ·⚠️ 0 YAML fence(s) skipped on a syntax error — that boundary has NO live population, so it is carried by --self-test rather than by this corpus.",
"node scripts/check-docs-section-name.mjs --self-test": "exit 0 · ✓ check-docs-section-name self-test: 85 cases pass (real temp trees on disk; both historical misses reproduced as RED, both arms driven RED, the duplicate-key and syntax-error boundaries pinned, every refusal exercised).",
"node scripts/check-empty-changeset.mjs --base origin/main": "exit 0 · ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added). ✓ No changeset from the merge base modified or deleted by this diff (#17712).",
"node scripts/check-empty-changeset.mjs --self-test": "exit 0 · ✓ check-empty-changeset --self-test: 159 assertions over real temp git repos (real scan() path)",
"node scripts/check-keyed-text-bounds.mjs": "exit 0 · ured on fa5d137 (-1/-1/-5/-2/-4 vs the record). ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only the floors decide. Reproduce the record: see this file's header.",
"node scripts/check-keyed-text-bounds.mjs --self-test": "exit 0 · ok packageOf attributes an example path to the example PASS check-keyed-text-bounds --self-test (0 failure(s))",
"node scripts/check-platform-object-tenancy-census.mjs": "exit 0 · ✓ platform-object tenancy census matches the tree: 84 platform-namespace objects, 58 in the machinery's reach, 26 outside it, every exclusion explained by a declaration on its own schema.",
"node scripts/check-platform-object-tenancy-census.mjs --self-test": "exit 0 · ✓ the family inherits the literal packages/objectql/src/tenancy/system-write-organization.ts ✓ check-platform-object-tenancy-census self-test: all checks pass (84 objects, 26 outside the machinery)",
"node scripts/check-plugin-teardown-shape.mjs": "exit 0 · gin implementation(s) across 6514 source(s) under packages/; every teardown-shaped method (stop / shutdown / close / dispose) sits beside a real destroy() (0 known-unreached, ⛔ SHRINK-ONLY, baseline fully burned down).",
"node scripts/check-plugin-teardown-shape.mjs --self-test": "exit 0 · #10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name reds, every excluded name stays green, and all five refusals are paired with a tree that still returns a verdict).",
"node scripts/check-registry-log-declared.mjs": "exit 0 · packages/services/service-automation — S1 constructs a SchemaRegistry in its tests examples/app-showcase — S1 constructs a SchemaRegistry in its tests",
"node scripts/check-registry-log-declared.mjs --self-test": "exit 0 · self-test OK: 16 cases + level-vocabulary read + population declaration + real-tree selection floor.",
"node scripts/check-rest-log-spy-declared.mjs": "exit 0 · OK: 29 of 196 test file(s) beside packages/rest/src/log.ts observe the fault log, and every one of them declares its own OS_REST_LOG level.",
"node scripts/check-rest-log-spy-declared.mjs --self-test": "exit 0 · ✓ the real tree yields a NON-EMPTY observer population that is a strict subset of its test files check-rest-log-spy-declared self-test reached its verdict: 23 case(s), 0 failure(s).",
"node scripts/check-section-landing-index.mjs": "exit 0 · section index block(s) enumerate their meta.json pages, in order, both directions (ai, api, automation, data-modeling, kernel, permissions, plugins, ui); 27 landing page(s) of 35 declare no index block and are not held.",
"node scripts/check-section-landing-index.mjs --self-test": "exit 0 · clared row, wrong order, the pre-fixuigrid, foreign-link curation, sub-heading vs same-level heading, fence and MDX-comment masking,index/---Group---filtering, opt-in skipping, and all seven refusals (empty blo",
"node scripts/check-system-context-census.mjs": "exit 0 · ired, over 128 anchors and 8 file-level citation(s); 21 declared non-read; 9 file(s) hold more than one read in one symbol (the declared precision loss); 20 row reference(s) resolve to their keyed row, 0 declared unheld.",
"node scripts/check-system-context-census.mjs --self-test": "exit 0 · and returns non-zero, and no exit between the two can report a run that never evaluated its floor as one that passed -- {"floorBeforeVerdict":true,"floorAddsToTheCounterTheVerdictReads":true,"flagAfterVerdictLine":true,"",
"node scripts/check-undeclared-dep-imports.mjs": "exit 0 · d on aef1b7e (+3/+525/+267 vs the record). ⚠ The delta is information, not a verdict — the floors are›=and cannot see an upward drift at all, which is why it is PRINTED. Reproduce the record: see this file's header.",
"node scripts/check-undeclared-dep-imports.mjs --self-test": "exit 0 · ok POSITIVE CONTROL — the real sweep reaches its population and extracts specifiers PASS check-undeclared-dep-imports --self-test (0 failure(s))",
"node scripts/docs-audit/check-affected-docs.mjs": "exit 0 · ecided ................ 100 no in-repo declaration, on a ledger that HAS in-repo route sources — absence and an unreadable spelling are not distinguishable here → the unreachable rows themselves: this command with --json",
"node scripts/docs-audit/check-drift-comment.mjs": "exit 0 · ✓ check-drift-comment: 66 cases pass across 5 fixture diff(s).",
"node scripts/pm/release-rehearsal-clone.mjs --self-test": "exit 0 · ✓ C10 lint.yml still runs this self-test ✓ self-test passed",
"pnpm --filter @objectstack/lint run check:doc-formula-expressions": "exit 0 · the "re-authorable" half held a predicate that is correct exactly where it is (#11651, #11673).",
"pnpm --filter @objectstack/lint run check:doc-security-posture": "exit 0 · skills 5 create site(s) in 32 marked block(s) across 53 .md file(s) ✅ 27 ObjectSchema.create example(s) in 227 marked block(s) across 248 prose file(s) in 2 root(s) carry an os validate-clean security posture",
"pnpm --filter @objectstack/spec run check:docs": "exit 0 · ✅ import examples resolve against api-surface/ (62 accepted gap(s) in the baseline) ✅ 225 generated files in sync with packages/spec",
"pnpm --filter @objectstack/spec run check:duration-unit-keys": "exit 0 · 2579 source file(s) all carry their unit in the key name (or in a siblingunit, or under a declared exemption: 6 declared duration type(s) (DurationMs/DurationSeconds), 15 declaredEpochMsinstant(s), 13 declared",
"pnpm --filter @objectstack/spec run check:empty-state": "exit 0 · ecriteria_json: null→ the empty filter → every record of". If it does describe a property, move it within 8 lines of the declaration so the gate can classify it. ✓ all classified (2 closed, 2 open, 4 output, 9 scope)",
"pnpm --filter @objectstack/spec run check:liveness": "exit 0 · s/state-counts.md is current — the same 40 row(s), no count column left in the README. (not a completeness claim about the 572 child key(s) under the declared blanket verdicts above — those are recorded, not classified.)",
"pnpm --filter @objectstack/spec run check:skill-examples": "exit 0 · • client SDK (@objectstack/client-react, @objectstack/client): 23 block(s) ✅ 259 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them",
"pnpm --filter @objectstack/spec run check:strictness-ledger": "exit 0 · strip sites is missing a row, no closed file still carries one, every Class cell resolves. ✓ docs/audits/2026-07-unknown-key-strictness-ledger.counts.md is current — 453 site(s) measured, 1 authorable strip site(s) left.",
"pnpm --filter @objectstack/spec run check:variant-docs": "exit 0 · › tsx scripts/check-variant-docs.mts ✓ variant/doc gate: 18 discriminated union(s) — 8 governed (every variant mentioned in a bound doc), 10 exempt.",
"pnpm --filter @objectstack/spec run check:yaml-examples": "exit 0 · file(s) validate against their declared live spec schemas ↳ 18 component node(s) also judged against their ComponentPropsMap props schema; 1 skipped (no row for the type — SDUI blocks and custom.* are an open namespace)",
"pnpm check:changeset-gate-self-tests": "exit 0 · ntmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real temp git repos + the #4690 pins + the LEVEL axis on #16044's two real heads + the",
"pnpm check:corpus-claim-drift": "exit 0 · Rules: 4 row(s) — exists-key-presence 4, exists-portability 0, regex-retired 0, section-visiblewhen-unbound 0. Ledger: 2 baselined file(s) in scripts/corpus-claim-drift-baseline.json.",
"pnpm check:cross-package-test-inputs": "exit 0 · very declared glob (6 of them on a split "test:repo" task); 13 walked root(s) judged, 3 on ACCEPTED_WALK_RADII; 2096 test file(s) import a workspace sibling by bare specifier over 353 package pair(s), every one declared.",
"pnpm check:dispatcher-error-vocabulary": "exit 0 · LITERAL mode (a/is read as a literal only where the grammar'sInputElementRegExpgoal applies) in all four primitives, andenclosingOpenersanswers each position with WHICH of its two formerundefineds applies.",
"pnpm check:doc-anchors": "exit 0 · m ids, duplicate counters, extraction discrimination and both finding classes verified (375 live fragment links) ✅ check-doc-anchors: 375 internal #fragment link(s) across 410 source file(s) all resolve to a real heading",
"pnpm check:doc-authoring": "exit 0 · 1421 · describe 8804 · functionBuilt 295 · functionDeclared 245). ✓ doc authoring guard: sibling-package prose ids hold the baseline — 816 pinned site(s) across 231 file(s), 91012 string(s) read in 1251 parsed source(s),",
"pnpm check:docs-audit-scope": "exit 0 · under content/docs/releases/ review-only (findings → issues, never edited). ✓ scope injection is live: the workflow audits the list handed in as args.handwritten, and refuses an invocation that hands in no scope at all.",
"pnpm check:docs-redirects": "exit 0 · nd observed silent. check-docs-redirects: OK (apps/docs/redirects.mjs: 98 entries -- 95 page destination(s) resolved against content/docs, 3 wildcard destination(s) resolved to a directory, 0 outside the /docs route spac",
"pnpm check:docs-single-h1": "exit 0 · lings, inline-code equality, indentation, a synthetic carve-out, and both anti-vacuity limbs). ✓ check-docs-single-h1: 405 page(s) under content/docs/ carry no body-level#heading (0 subtree(s) excluded, see --list).",
"pnpm check:docs-spec-enumerations": "exit 0 · enumerations reds on its own; the subpath set (5) and the namespace set (4) are pinned different and non-interchangeable; order is held on the sentence that claims it and nowhere else; count claims read digits and number",
"pnpm check:docs-transcript-drift": "exit 0 · ivation, and the real exit codes 3 / 0 / 1 from a child process). ✓ check-docs-transcript-drift: 4 declared transcript value(s) across 405 page(s) under content/docs/ equal what the registry derives today, and no undecla",
"pnpm check:driver-memory-census": "exit 0 · its #5704 Q2 = B) check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states "#6664 census: 2 ruled consumers". This gate polices the census, never investment.",
"pnpm check:dts-closure": "exit 0 · sure self-test: all assertions passed. check-dts-closure: 73 built package(s) swept - 165/165 declared declaration file(s) present across 73 package(s); 0 built package(s) declare no declaration entry point and owe none.",
"pnpm check:dual-build-cjs-loads": "exit 0 · measured on 8cb96ec (+1/=/+7/= vs the record). ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only the floors decide. Reproduce the record: see this file's header.",
"pnpm check:engine-double-contract": "exit 0 · 0/600 · derived from 3503/2596/911/779 measured on 854639b (+398/+231/+42/+42 vs the record). ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only the floors decide.",
"pnpm check:gitlink-declared": "exit 0 · elf-test: 36 assertions over throwaway git repos (real scan() path) check-gitlink-declared: OK (9412 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare).",
"pnpm check:issue-citations": "exit 0 · heck-issue-citations.mjs --self-test ✅ check-issue-citations --self-test: grammar narrowed, four 404 causes kept apart, both board strategies agree, diff scope red AND green, scope contract pinned (73 cases, 7 batteries)",
"pnpm check:lean-entry-closure": "exit 0 · @objectstack/objectql/core (require): 15 packages, 199 modules Admitted set held exactly (15 packages); 6 denied names absent.",
"pnpm check:logger-receiver-detach": "exit 0 · clared receiver-sensitive sink spelling(s). control corpus fired on all five detach shapes in this same run, and stayed silent on the measuredconsoleand options-callback populations -- so the zero above is a reading.",
"pnpm check:merge-driver": "exit 0 · stand-in repository byte-identical (config, index, refs, HEAD) ✓ check-regen-pending self-test passed.",
"pnpm check:nul-bytes": "exit 0 · ✓ check-nul-bytes --self-test: 75 assertions over a temp git repo (real scan() path) check-nul-bytes: OK (scanned 9405 text file(s) -- 9405 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).",
"pnpm check:objectql-double-limit": "exit 0 · 166 limit-blind, 32 shape-breaking and 55 unjudged double(s) in 251 grandfathered file(s); none new. baseline key set verified against 6bf7f1c: no files added.",
"pnpm check:objectui-changeset": "exit 0 · ✓ --help does NOT leak mid-file implementation comments (#11952) ✓ objectui-range --self-test: all checks passed",
"pnpm check:org-identifier": "exit 0 · ✓ check-org-identifier self-test: 31 cases pass. check-org-identifier: OK (2871 author-facing source file(s), 17 session binding(s) resolved, no removed session.tenantId alias).",
"pnpm check:page-declaration-shape": "exit 0 · e(s) (migration fixtures — anonymous, so nothing to discover), 176 string page-name reference(s) (book/doc nav). blind spot: 1 computed carrier(s) no source scan can enumerate — examples/app-crm/objectstack.config.ts:86.",
"pnpm check:pm-changeset-deadline-census": "exit 0 · sus --self-test: all cases passed across 5 batteries (what counts as a named target, the controls that make a zero a reading, the verdict truth table including the inconclusive refusal, the buckets, and the CLI surface).",
"pnpm check:pm-widening-tells": "exit 0 · er, the three imported/declared surfaces, the four tells, the two accidental variables #16822 removed and the evidence each declines on, the #16943 net member/key delta with its surplus rule and the quiet direction it bu",
"pnpm check:published-files": "exit 0 · oor 50 vs 70 of 70 live), 12 ANNOUNCED cell case(s), 10 subpath-resolution case(s), 12 announcement case(s) and 5 base-read-control case(s) (plus the base reader itself, exercised against this repository), and the shared",
"pnpm check:published-readme-links": "exit 0 · — 177 outbound link(s) across 61 published markdown file(s): 0 root-relative, 0 non-canonical origin(s), 27 docs-site page(s) resolved (0 via redirect), 1 anchor(s) verified, 104/104 relative target(s) found in the tree.",
"pnpm check:query-options-erasure": "exit 0 · t surface: 236 site(s) in 47 file(s) — at the ceiling, outside the blocking rule by the #4918 triage (a rejection test must be able to build off-contract input). baseline key set verified against 6bf7f1c: no files added.",
"pnpm check:react-page-adapter-contract": "exit 0 · 969 deleted (the docs one on the very stringrecordsReadsis pinned to IGNORE), the selector observed refusing the engine.find / useQuery / webhook shapes it must not fabricate on, and an empty sweep of EITHER half obs",
"pnpm check:refd-timer-probe": "exit 0 · obe: 7078 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else. 1 code site(s), all inside the approved module, which is present and still reads it.",
"pnpm check:role-word": "exit 0 · Exempt: 0 os:vendor-wire block(s) suppressed 0 occurrence(s); 3 generated region(s) suppressed 0 occurrence(s). Ledger: 44 baselined file(s) still carrying it (123 occurrence(s)) in scripts/role-word-baseline.json.",
"pnpm check:route-envelope": "exit 0 · edQueryParamswrites —{ error: { code: VALIDATION_ERROR, message } }, nested per ADR-0112 but with nosuccessflag above it read/write discriminator: 11 file(s) skipped as fetch readers (77 zero-argumentres.json(",
"pnpm check:route-ledger-census": "exit 0 · OK packages/runtime/src/route-ledger.ts :: ROUTE_LEDGER — the census sentence above ROUTE_LEDGER (reads 82, array holds 82) ✓ check:route-ledger-census — all 1 census sentence(s) match their arrays.",
"pnpm check:skill-identifier-liveness": "exit 0 · k-skill-identifier-liveness OK — Leg 1: 457 citation(s) over 53 published file(s) checked against 107046 implementation word tokens (0 ledgered exemption(s)); Leg 2: 8 registered exhaustive section(s), 0 ledgered gap(s).",
"pnpm check:slot-lookup": "exit 0 · ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed. Every other file under packages/ is covered bypnpm lint. baseline key set verified against 6bf7f1c: no files added.",
"pnpm check:sourcemap-no-sources-content": "exit 0 · check-sourcemap-no-sources-content self-test: all assertions passed. check-sourcemap-no-sources-content: 69 built package(s) swept - 506 map(s), none embed source text.",
"pnpm check:test-source-alias": "exit 0 · -test-source-alias --self-test OK check-test-source-alias OK — 74 packages with tests scanned; 61 registered as still resolving a workspace dep throughdist/; 49 published subpath(s) resolved through every alias table.",
"pnpm check:tier-file-adoption": "exit 0 · le(s) on disk (70 e2e, 0 live), owned by 1 package(s); every one reads OS_TEST_TIERS. @objectstack/cli — 70 file(s); imports readTierMode, selectTierFiles from scripts/nightly-tiers.mjs (via packages/cli/vitest-tiers.ts)",
"pnpm check:type-check-coverage": "exit 0 · rs 64/2800/2000 · derived from 80/3503/2521 measured on 854639b (+1/+398/+230 vs the record). ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only the floors decide.",
"pnpm check:type-check-debt": "exit 0 · -check-coverage --re-measure: OK — 4 ledger entr(ies) re-measured in 21.4s, 53 raw tsc error(s) total, none above its recorded number. surplus: none — every entry sits exactly at its measurement, so any new error is red.",
"pnpm check:vendor-version-stamps": "exit 0 · rot were already failures above. Restamping the rest without re-measuring would manufacture attestations. Re-verify one and you may restamp it; otherwise it stays a historical fact.",
"pnpm check:watch-hint-literal": "exit 0 · it, all 4 rostered names judged for both spellings, the per-name floor proved against a population that is healthy on every name but one, unrostered spellings of the idiom discovered, the value-side drop proved on a lite",
"pnpm check:where-matcher": "exit 0 · 0 silently-wrong and 0 unjudged matcher(s) in 0 grandfathered file(s); none new. baseline key set verified against 6bf7f1c: no files added.",
"pnpm check:authz-resolver": "exit 0 · ive control (the canonical resolver is still matched by check (1)'s heuristic) and its reverse proof (red and named when a grant table is renamed, fully or partially, or the resolver moves; green when restored), allow-li",
"pnpm check:error-code-casing": "exit 0 · a helper parameter — is out of reach for every pattern here by construction; that half belongs to check:dispatcher-error-vocabulary, which reports its own scope.",
"pnpm check:filter-alias-parity": "exit 0 · ngs, both drift directions, the two-hop$alias, four rot shapes, and the CI wiring) check:filter-alias-parity: OK (4 transport spelling(s) of thewhereslot, identical on both sides: $filter, filter, filters, where)",
"pnpm check:error-status-conformance": "exit 0 · packages/verify/src/handle.ts:264: assignment: code=code status=status ✓ every derivable runtime status is documented, and every documented status is reachable.",
"pnpm lint": "exit 0 · eslint . --no-inline-config over the whole repo, 112s, no findings printed (full union, not a narrowing) @ b4cb8a4",
"pnpm --filter @objectstack/runtime typecheck": "exit 0 · check:test-typecheck: OK — @objectstack/runtime's test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json",
"pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2": "exit 0 · Test Files 276 passed (276) · Tests 3893 passed | 1 skipped (3894) @ a99d03a (b4cb8a4 changes only a ledger note string and a docs row; the 9 suites reading them re-run green on b4cb8a4)",
"pnpm --filter @objectstack/runtime exec vitest run --project repo --maxWorkers=2": "exit 0 · Test Files 2 passed (2) · Tests 69 passed (69)",
"pnpm --filter @objectstack/runtime exec vitest run --project local (9 route-ledger / resume-arm suites) @ b4cb8a4": "exit 0 · Test Files 9 passed (9) · Tests 436 passed (436)",
"pnpm --filter @objectstack/dogfood exec vitest run test/flow-durable-suspend.dogfood.test.ts": "exit 0 · Test Files 1 passed (1) · Tests 11 passed (11) — against the rebuilt runtime dist carrying the gate",
"node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran-with-exit.txt": "exit 0 · ✓ dispatch-gates --ran: 92 derived famil(ies) accounted for — 92 run, 0 NOT-MEASURED (a DERIVED zero — all 92 recorded an exit code and none of them is 3) @ b4cb8a4"
},
"line_budget": "n/a",
"deviations": [
"Refusal sentence: code and status are the read twin's exactly, and the requirement clause is the read twin's word for word (both messages built from one constant PAUSED_RUN_REQUIREMENT), but the verb phrase is the door's own — 'Resuming a paused run requires being the identity that triggered the run, or read access to 'sys_automation_run'.' — instead of the literal 'Reading a paused run's screen ...', which would name an operation the caller did not attempt (the #11666 precedent). The order's card-specific line asked the message to match 'exactly'; the binding ruling says 保持一致. One-line switch if the literal sentence is wanted: RESUME_DENY_MESSAGE = SCREEN_READ_DENY_MESSAGE.",
"Ordering: no 404-first step; the gate fails closed on a run getRun cannot resolve. Unknown and finished runs keep today's engine 404 for every caller the gate ADMITS (starter, grant holder, system — pinned), but a caller who is none of those now gets 403 instead of 404 (the PM's suggested row 'unknown and finished runs unchanged' holds for admitted callers only). Reason: getRun's null is not authoritative — the engine returns null for a parked, resumable run on a degraded durable read and after 1000-entry ring eviction with no suspended-run store — so a null-means-skip gate would be open in exactly those states. Side effect: no paused-vs-unknown existence oracle on this door.",
"File surface: two files beyond the claim's list — packages/runtime/src/route-ledger.ts (the resume row's note now names the caller gate; tracker ids kept out of the string per check:doc-authoring) and content/docs/permissions/system-context.mdx (row 58 re-anchored from #refuseUnrelatedScreenRead to #isRunStarterOrRunStateReader and now names the resume door; check:system-context-census refused the tree without it).",
"Gate runs that first answered PREREQUISITE NOT MET (exit 3): check:skill-examples, check:dual-build-cjs-loads, check:type-check-debt — a full turbo build (73/73) was run under the lock and all three re-ran green before the final union; the final union's 92 records carry no exit 3.",
"Commits carry the model-free trailer pair per AGENTS.md and the order, not the harness reminder's model-named Co-Authored-By line."
],
"files_changed": [
".changeset/19987-resume-caller-gate.md",
"content/docs/automation/flows.mdx",
"content/docs/permissions/system-context.mdx",
"packages/runtime/src/domains/automation-resume-caller-gate.test.ts",
"packages/runtime/src/domains/automation-run-lifecycle-door.test.ts",
"packages/runtime/src/domains/automation-run-read-permission-gate.test.ts",
"packages/runtime/src/domains/automation-screen-read-gate.test.ts",
"packages/runtime/src/domains/automation-write-capability-gate.test.ts",
"packages/runtime/src/domains/automation.ts",
"packages/runtime/src/route-ledger.ts"
],
"not_measured": [
"NOT MEASURED: CI Test Core shards, Temporal Conformance, Dogfood Regression Gate beyond flow-durable-suspend, Dogfood Verify CLI, reason: CI-owned jobs scheduled by path; no local invocation",
"NOT MEASURED: workspace / examples / downstream-contract type-check lanes, reason: CI whole-workspace filters; runtime typecheck ran and the published surface is unchanged (no export added or removed)",
"NOT MEASURED: CI status of PR #20005, reason: report delivered at the end of local verification; CI convergence is the PM's read"
]
}objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsACCEPT — PR #20005 at head
b4cb8a4d13f3ed63ce7b334df86fa2b816d65215domain:cliexecution PM seat #6024 · sessionsession_01TnPAC1UsTGfHPXVUCL6iLn· 2026-09-24T17:43Z · reviewed on GitHub andorigin/main, ⛔ not on the report's narrationReview checklist
- PR form: draft, base
main. The first line isFixes #19987, with exactly one closing binding in the whole body.Clause-②: nois line-initial; one footer; no model identifiers. - Scope: 10 files (+737/−77), NOT governed (
check-governed-merges.mjs --pr 20005exit 0). Eight are within the claim. Two are the dev's declared supplements, amended into the claim in place: theroute-ledger.tsresume-row note, and thesystem-context.mdxcensus row (check:system-context-censusrefused the tree without it). Changeset:@objectstack/runtime: patch. - The gate, read in the diff: one shared predicate,
isRunStarterOrRunStateReader, is true forisSystem, OR the run'strigger.userIdequal to the caller, ORmayReadRunState(thesys_automation_runread grant). The read twinrefuseUnrelatedScreenReadnow calls it with unchanged logic. The newrefuseUnrelatedResumesits in the resume arm after the body checks and immediately BEFOREautomationService.resume(...), so a refused call consumes nothing. The refusal reuses the existingRUN_READ_DENY_CODE/RUN_READ_DENY_STATUS(403PERMISSION_DENIED): no new code, soClause-②: noholds (it narrows who may resume). - Triage's binding limit (「不得拒绝节点自身
resumeAuthority已经明确授权的人」) holds with no member. Verified onorigin/main:resumeAuthorityisz.enum(['any', 'service'])(packages/spec/src/automation/node-executor.zod.ts:442), and neither value names a person.anyauthorizes no specific caller.servicepauses (approval nodes) are refused by the engine to every HTTP caller, because approvers resume in process. ⇒ no node-authorized non-starter reaches this door, and the PR carries the full value table. The PM's mechanism assumption 3 is falsified in its premise; noted here. - Tests: the card's probe went from 17 red to 50 green, and every refusal asserts
code+status+resumeNOT called. A parity table drives both doors with the same callers and postures. The ablation (gate removed: 15 red, starter/grant/system rows green, restore proven by blob) is the dev's, relayed.
Deviations judged
- Message: the same code, the same status, and the same requirement clause from one constant (
PAUSED_RUN_REQUIREMENT). The verb phrase is the door's own (「Resuming a paused run …」 vs 「Reading a paused run's screen …」). ⇒ accepted. Triage asked for the wording to be 保持一致 (kept consistent), and a sentence naming an operation the caller did not attempt would be wrong. - Unresolvable run: the write door fails CLOSED, so a caller who is not admitted gets 403 where the read twin's order gives 404. Admitted callers keep today's answers for unknown and finished runs (pinned). The dev's reason is measured:
getRun's null is not authoritative when a durable read degrades, or when the ring evicts with no store. ⇒ accepted. This door gains no existence oracle, and the difference from the read twin is stated in the PR.
Out-of-scope findings:
webhookUrlPattern's default path, which no route serves, and the client SDK'sautomation.resumedocblock, which does not yet list the 403, both go to Acceptance notes. The dev classed them outside a/b/c; neither is filed.Owed, not a gate: the at-tier face review of the changeset and the
flows.mdxprose. This PR hits neither clause-② limb and is not governed; the tier is unavailable to this session.CI on this head at review time: 12 success, 2 skipped, 18 in progress, 0 failing. ⇒ ⛔ not ready until every check is green or on the skip roster.
- PR form: draft, base
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsLANDED —
b81da66df773b5d237cad04d6b05a61e9544d553(PR #20005), verified by CONTENT onorigin/maindomain:cliexecution PM seat #6024 · sessionsession_01TnPAC1UsTGfHPXVUCL6iLn· 2026-09-24T18:16Zprobe in packages/runtime/src/domains/automation.tsbefore ( 61609edf)origin/mainisRunStarterOrRunStateReader(the shared predicate)0 7 refuseUnrelatedResume(the write-door refusal)0 7 RESUME_DENY_MESSAGE0 3 untouched control: function refuseUnrelatedScreenRead1 1 automation-resume-caller-gate.test.tsin the tree— present - Shape: 2 parent fields, so this is a squash. The pre-merge head
b4cb8a4dis ⛔ not an ancestor (exit 1); the control leg61609edfis an ancestor (exit 0). The commit carries the enqueue instant 2026-09-24T17:58:38Z. - What changed for callers:
POST /automation/:name/runs/:runId/resume(the route-ledger spelling) now refuses any caller who is not the run's starter, not a holder of thesys_automation_runread grant and not a system context. The refusal is the read twin's existing one, 403PERMISSION_DENIED(RUN_READ_DENY_STATUS/RUN_READ_DENY_CODE), and it comes beforeresumeis called, so nothing is consumed. Starters, grant holders, system contexts, and unknown or finished runs keep today's answers. - Triage's limit on
resumeAuthority(「不得拒绝节点自身resumeAuthority已经明确授权的人」): measured, it has no member today.packages/spec/src/automation/node-executor.zod.ts:442declaresresumeAuthority: z.enum(['any', 'service']), and neither value names a person, so there is no node-authorized approver the new gate could refuse. The PR body carries the table. If a person-naming value is ever added, this gate must admit it; that is recorded here, ⛔ not assumed. - Card: closed by
Fixes #19987. In the same stroke as this comment,pm:dispatchedis stripped and the assignee cleared. - Owed, ⛔ not a gate: the five-surface review for the changeset prose and
content/docs/automation/flows.mdx/permissions/system-context.mdx. The at-tier reviewer was refused for this session by the tier's usage limit at 2026-09-24T14:14Z, 2026-09-24T15:08Z and 2026-09-24T16:08Z. It runs when the tier returns and is listed on the seat post. - Still open, the maintainer's call (asked in session): whether this card's security detail should also go to a private advisory. The repo is public, and the fix is now on
main.
- Shape: 2 parent fields, so this is a squash. The pre-merge head
- added a commit that references this issue
on Sep 28, 2026
Gate category ① — a product defect with a named landing point and a named failing probe.⚠️ Self-reported P0 suspect (security, NORTH-STAR 优先级 1): asking for the emergency triage path.
Filed by the
domain:cliexecution PM seat #6024 (sessionsession_01TnPAC1UsTGfHPXVUCL6iLn). The #15705 dev found it (reportos-dev-reporton #15705) while building the MCPresume_runverb. The seat re-read the landing sites onorigin/main2c1011b01bitself. ⛔ Filed without adomain:*,priority:*or type label: triage grades and routes.The defect
packages/runtime/src/domains/automation.ts, the resume arm opens at:2141(parts[3] === 'resume' && m === 'POST') and reachesautomationService.resume(parts[2], signal)about 145 lines later. Between the two it validates the body shape and closed key set only. The arm reads noexecutionContext,userIdorgetRun, and has no refusal helper.resumeAuthority(automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801 / automation:resumeAuthoritydefaults to'any', so every future pausing node ships fail-open — ADR-0044 says this is "tracked separately" and nothing tracks it #5561). The MCPrun_actionon a screen flow dead-ends: the screen pauses even when every input is bound, andlist_actionsnever surfaces flow input variables #15705 dev reports that a screen node answersanythere; this seat has ⛔ not re-read that value.packages/services/service-automation/src/engine.ts:6783readsconst context = run.context. So arunAs: 'user'flow's downstream data nodes run as the user who STARTED the run, with values the stranger submitted.GET /:name/runs/:runId/screenrefuses a stranger throughrefuseUnrelatedScreenRead(:1067): the run's owntrigger.userId, OR thesys_automation_runread grant as an operator override. That is maintainer ruling Option B on finding:GET /automation/:name/runs/:runId/screenstill discloses record-derived values to any authenticated caller who knows a run id #7968, whose acceptance is 「stranger with valid auth + run id ⇒ denied; triggering user ⇒ screen」. The write has no counterpart gate.⇒ One pause, two doors: the read refuses a stranger and the write admits one.
Named failing probe (the dev's, relayed: ⛔ not committed, ⛔ not re-driven by this seat)
An
HttpDispatchertest in which calleru2POSTsflow_a/runs/run_1/resumefor a run whosegetRun().trigger.userIdisu1. Expected: a refusal. Today: 200, andresumeis called.Reachability: a caller needs another user's run id.
GET /:name/runsrequires thesys_automation_rungrant since #7900, so operators can list them. Run ids are not guessable.Why this may not be a plain fix, stated for triage
The docblock above
isFlowAuthoringWrite(automation.ts, around:410-416) says the resume door is deliberately left out of the #7900 metadata gate: 「already fail-closed on the suspended node'sresumeAuthority… a second, unrelated gate in front of it would refuse the very user the flow paused for」. An identity gate shaped like #7968's Option B (trigger identity OR grant) would ⛔ not refuse that user. The #7968 docblock (:1028-1032) records per-run resume authority ("Option A") as 「the coherent end state and ADR-0019 class design work」. ⇒ Triage decides: apply Option B's shape to the write (execution), or treat the write as the Option A design question (the decision box). Both refuse this card's stranger.Already closed on the MCP door, ⛔ not here
PR #19985 (#15705, draft, awaiting its contract review) gives MCP
resume_runits own ownership check:getRun(runId).trigger.userIdmust equal the caller. It deliberately leaves the REST door unchanged, and it is ⛔ not this card's carrier.Who acts on it
After grading: the lane that owns
packages/runtime/src/domains/automation.ts, which isdomain:cliby the domain table, or the maintainer if triage routes it as the Option A decision.Dedup (closed included)
search_issuesrepo-scoped, taken by this filing act:restoreConsumedSuspensioncannot reach a nested run — a stranded child's cascade-failed ancestors are consumed without a snapshot, so restoring the child continues into a dead parent #15222, service-automation: two concurrent resumes of one run on two replicas can both advance it — the idempotency guard is per-process #14333, automation resume: a known top-level key with a type-mismatched value is silently dropped — the submission is still treated as empty #9416, A durable PAUSED run is invisible toGET /automation/:name/runsand to run-detail after a cold restart — while remaining fully resumable #8050, automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801, service-automation: runAs:'user' runs data ops with a credential-less user — trigger context never carries the actor's permission sets/positions (follow-up to #1888) #3356.waitpause is service-owned but type-keyed gating can't see it #3823, automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801.GET /automation/:name/runs/:runId/screenstill discloses record-derived values to any authenticated caller who knows a run id #7968 gated the read twin. ⇒ 0 open cards cover the write door's caller identity.Dedup words: resume door caller identity · stranger resume paused run · runs resume trigger userId · resume ownership runAs user · refuseUnrelatedScreenRead resume twin