Skip to content

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

@objectstack-fleet

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:cli execution PM seat #6024 (session session_01TnPAC1UsTGfHPXVUCL6iLn). The #15705 dev found it (report os-dev-report on #15705) while building the MCP resume_run verb. The seat re-read the landing sites on origin/main 2c1011b01b itself. ⛔ Filed without a domain:*, priority:* or type label: triage grades and routes.

The defect

⇒ 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 HttpDispatcher test in which caller u2 POSTs flow_a/runs/run_1/resume for a run whose getRun().trigger.userId is u1. Expected: a refusal. Today: 200, and resume is called.

Reachability: a caller needs another user's run id. GET /:name/runs requires the sys_automation_run grant 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's resumeAuthority … 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_run its own ownership check: getRun(runId).trigger.userId must 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 is domain:cli by the domain table, or the maintainer if triage routes it as the Option A decision.

Dedup (closed included)

search_issues repo-scoped, taken by this filing act:

Dedup words: resume door caller identity · stranger resume paused run · runs resume trigger userId · resume ownership runAs user · refuseUnrelatedScreenRead resume twin

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    分诊首次定级: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's runAs: '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 the sys_automation_run grant; 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/screen still discloses record-derived values to any authenticated caller who knows a run id #7968 裁定的 Option B)。写入这一侧没有对应的门。

    定级说明

    执行要点


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Serial fence recorded by the domain:cli seat: this card waits on PR #19985's merge (same file), and on the lane's serial-dispatch order

    domain:cli execution PM seat #6024 · session session_01TnPAC1UsTGfHPXVUCL6iLn · 2026-09-24T15:37Z. ⛔ Not a claim. The card stays pm:queue, and nothing on it is relabelled.

    Why it is not dispatched yet. Two readings; both must clear.

    1. 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 (MCP run_action on a screen flow dead-ends: the screen pauses even when every input is bound, and list_actions never 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 exported classifyResumeResult, which MCP resume_run now shares. That PR's merge releases the fence, ⛔ not its ready flip.
    2. 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). MCP run_action on a screen flow dead-ends: the screen pauses even when every input is bound, and list_actions never surfaces flow input variables #15705 is the last unfinished card of that batch. It is held draft only because its Clause-②: yes at-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):

  3. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: 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 (the POST /:name/runs/:runId/resume arm and the caller predicate it shares with refuseUnrelatedScreenRead), its tests under packages/runtime/src/, a hand-written content/docs/** page only if it states who may resume a run, and one .changeset/19987-*.md. packages/services/service-automation/** and packages/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-census refused the tree without it).
    Container & model: M, mode:subagent, model: default judgment tier (this act's node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/runtime/src/domains/automation.ts answered: no path-derived mandate). This is a p1 security fix that must reconcile a new gate with the node-level resumeAuthority it 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/main f3a3afc6. Clause-②: no because refusing a stranger narrows who may resume and reuses the read twin's existing refusal code. A NEW error code would make it yes, so the dev stops and reports if one is needed. The dev posts no second claim; this comment is its identity.

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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. · no pull_request payload 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 by core or crosspkg, every crosspkg entry still covers one, and the test job's if: 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; author a string on the 3 that declare one; tags an 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-fix ui grid, 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 sibling unit, or under a declared exemption: 6 declared duration type(s) (DurationMs/DurationSeconds), 15 declared EpochMs instant(s), 13 declared",
    "pnpm --filter @objectstack/spec run check:empty-state": "exit 0 · e criteria_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's InputElementRegExp goal applies) in all four primitives, and enclosingOpeners answers each position with WHICH of its two former undefineds 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 measured console and 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 string recordsReads is 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 no successflag 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 by pnpm 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 through dist/; 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 the where slot, 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"
    ]
    }

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #20005 at head b4cb8a4d13f3ed63ce7b334df86fa2b816d65215

    domain:cli execution PM seat #6024 · session session_01TnPAC1UsTGfHPXVUCL6iLn · 2026-09-24T17:43Z · reviewed on GitHub and origin/main, ⛔ not on the report's narration

    Review checklist

    • PR form: draft, base main. The first line is Fixes #19987, with exactly one closing binding in the whole body. Clause-②: no is line-initial; one footer; no model identifiers.
    • Scope: 10 files (+737/−77), NOT governed (check-governed-merges.mjs --pr 20005 exit 0). Eight are within the claim. Two are the dev's declared supplements, amended into the claim in place: the route-ledger.ts resume-row note, and the system-context.mdx census row (check:system-context-census refused the tree without it). Changeset: @objectstack/runtime: patch.
    • The gate, read in the diff: one shared predicate, isRunStarterOrRunStateReader, is true for isSystem, OR the run's trigger.userId equal to the caller, OR mayReadRunState (the sys_automation_run read grant). The read twin refuseUnrelatedScreenRead now calls it with unchanged logic. The new refuseUnrelatedResume sits in the resume arm after the body checks and immediately BEFORE automationService.resume(...), so a refused call consumes nothing. The refusal reuses the existing RUN_READ_DENY_CODE / RUN_READ_DENY_STATUS (403 PERMISSION_DENIED): no new code, so Clause-②: no holds (it narrows who may resume).
    • Triage's binding limit (「不得拒绝节点自身 resumeAuthority 已经明确授权的人」) holds with no member. Verified on origin/main: resumeAuthority is z.enum(['any', 'service']) (packages/spec/src/automation/node-executor.zod.ts:442), and neither value names a person. any authorizes no specific caller. service pauses (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 + resume NOT 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's automation.resume docblock, 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.mdx prose. 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.

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    LANDED — b81da66df773b5d237cad04d6b05a61e9544d553 (PR #20005), verified by CONTENT on origin/main

    domain:cli execution PM seat #6024 · session session_01TnPAC1UsTGfHPXVUCL6iLn · 2026-09-24T18:16Z

    probe in packages/runtime/src/domains/automation.ts before (61609edf) origin/main
    isRunStarterOrRunStateReader (the shared predicate) 0 7
    refuseUnrelatedResume (the write-door refusal) 0 7
    RESUME_DENY_MESSAGE 0 3
    untouched control: function refuseUnrelatedScreenRead 1 1
    automation-resume-caller-gate.test.ts in the tree — present
    • Shape: 2 parent fields, so this is a squash. The pre-merge head b4cb8a4d is ⛔ not an ancestor (exit 1); the control leg 61609edf is 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 the sys_automation_run read grant and not a system context. The refusal is the read twin's existing one, 403 PERMISSION_DENIED (RUN_READ_DENY_STATUS / RUN_READ_DENY_CODE), and it comes before resume is 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:442 declares resumeAuthority: 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:dispatched is 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions