Skip to content

fix(runtime): a non-admin approver's decision from the request page reaches the approvals routes instead of answering 404 - #28708

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-8eqzai
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-8eqzai

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28657

Clause-②: yes (widening: on POST /api/v1/actions/:object/:action and MCP run_action, a same-origin type: 'api' action whose subject row the caller's own scope does not deliver, and whose request needs no field of that row, is forwarded and answers its target's answer where it answered 404 RECORD_NOT_FOUND)
Metadata-protocol: none

维护者速读

  • 改了什么:动作门(/api/v1/actions/... 与 MCP run_action)在调用者读不到主记录时,不再先回 404;对不需要该记录字段的同源 type:'api' 动作照常转发,由目标接口自己鉴权并给出答复。
  • 为什么改:审批详情页的同意、驳回、评论、补充信息等按钮都是这种动作。普通成员按设计没有 sys_approval_request 的数据读权限,所以非管理员审批人在详情页做任何决定都被 404 挡住,18.3.0 发不了。
  • 风险与回滚:低。只动 api 转发这一支;目标接口用调用者自己的凭证鉴权,等同于调用者直接调它,不多泄露任何东西。需要记录字段才能拼请求的 api 动作、flow、脚本、声明式更新照旧回 404。回滚即还原 refuseDeniedSubjectLoadBeforeDispatch 两处调用。
  • 先看:packages/runtime/src/action-execution.ts(新的共享判定 refuseDeniedSubjectLoadBeforeDispatch / apiActionRequestReadsSubjectRow / apiForwardSubjectRecord);packages/runtime/src/domains/actions.ts(REST 门那一行);packages/verify/src/action-door-api-forward-denied-subject.test.ts(真实审批栈上的 HTTP 钉子)。
  • 两分钟验证:pnpm --filter @objectstack/verify exec vitest run src/action-door-api-forward-denied-subject.test.ts,预期 3/3 通过(main 上 2 个失败,都是 404 RECORD_NOT_FOUND)。
  • 需要你做的:无,席位落地。

What changes

  • packages/runtime/src/action-execution.ts
    • refuseDeniedSubjectLoadBeforeDispatch(action, objectName, recordId, subject): contract point 3 as both doors' dispatch reads it. It returns without refusing exactly when the load was denied and forwardsOnDeniedSubjectLoad(action) holds; otherwise it is refuseDeniedSubjectLoad, unchanged.
    • forwardsOnDeniedSubjectLoad(action): not a declarative update (operation before type), type: 'api', a same-origin target, and apiActionRequestReadsSubjectRow(action) false.
    • apiActionRequestReadsSubjectRow(action): true for a {field} path token in target other than {id}, or a recordIdParam read from a recordIdField other than id. Read off the declaration before substitution, so a token that only looks like a field errs toward the 404.
    • apiForwardSubjectRecord(recordId, subject): the row the forward's request is built from. A delivered row as it was; on a denied load { id: recordId } and nothing else.
    • dispatchAdmittedBusinessAction (MCP run_action) calls the two at the same point the REST door does.
    • apiActionForwardRefusal reads a target's bare string error (the approvals routes' { code, error }) as the refusal's message, before the "its target answered HTTP N" fallback. This is the card's 400 prescription relay: it was this code path, so it rides along (ruling 6).
  • packages/runtime/src/domains/actions.ts: dispatchAdmittedRestAction calls refuseDeniedSubjectLoadBeforeDispatch where it called refuseDeniedSubjectLoad, and builds the api request from apiForwardSubjectRecord.
  • .changeset/28657-api-action-forward-denied-subject.md: @objectstack/runtime patch (files[] ships dist, which carries the change).

How ruling 2 is enforced: the forward never sees the load's leftovers. buildApiActionRequest is handed apiForwardSubjectRecord(...), which on a denied load is built from the routed recordId alone. A declaration that needs a row field is caught one step earlier by apiActionRequestReadsSubjectRow, so it is refused with today's 404 before the loop check, the injector or the builder run. Pinned by the helper case (a denied load whose leftover row carries email yields { id } only) and by the door cases for {email} in the path and recordIdField: 'email' (404, no forward, on both doors).

Untouched, as ruled: pending-approver-read-grant.ts, the member grant on sys_approval_request, packages/spec/**, ui/**, the flow, script/body and declarative-update branches, and resume_run.

Tests

The HTTP-layer pin on a real stack: red on base e4ec4f2, green on head

packages/verify/src/action-door-api-forward-denied-subject.test.ts boots the real stack with bootStack (requires: ['automation', 'triggers', 'approvals'], the providers serve mounts for those tokens): the runtime dispatcher, the real approvals service and its decision routes, the real in-process forward. No fetch is doubled. A private object, a two-approver unanimous flow; the admin creates the record, and plain members act.

  1. Control: the approver cannot read the request row through the data door (403 PERMISSION_DENIED).
  2. The approver's POST /api/v1/actions/sys_approval_request/approval_approve {"recordId":AREQ,"params":{"comment":…}} answers 200; pending_approvers goes from both approvers to the other one; one approve row with the approver as actor_id and the comment; GET /api/v1/approvals/requests/AREQ reads decision_progress { behavior: 'unanimous', got: 1, need: 2 }.
  3. A member on no slate, through the same door, gets exactly the status and code the decision route answers them directly (403 FORBIDDEN), the route's own sentence as the message, and nothing moves.

Base (packages/runtime/src/action-execution.ts and domains/actions.ts written from e4ec4f22c5, runtime rebuilt, ablation-dist-preflight --absent on the new helper's name):

× Approve answers 200 and the request moves: 1 of 2, the approver off the slate, an approve row
× a member on no slate gets the decision route's own refusal through the door — never a 200, never the row
AssertionError: {"success":false,"error":{"code":"RECORD_NOT_FOUND","message":"Record areq_… not found in sys_approval_request","httpStatus":404}}: expected 404 to be 200
AssertionError: {"success":false,"error":{"code":"RECORD_NOT_FOUND",…}}: expected 404 to be 403
Tests  2 failed | 1 passed (3)

Head: Test Files 1 passed (1) · Tests 3 passed (3) · VERDICT command-exit 0. Restore proven by blob hashes equal to HEAD, git status --porcelain empty, rebuild, and the preflight finding the marker in dist/index.js and dist/index.cjs.

Ablation: only the api-branch move reverted, the HTTP pin goes red

node scripts/ablation-replace.mjs --file packages/runtime/src/domains/actions.ts --anchor "actionExec.refuseDeniedSubjectLoadBeforeDispatch(actionDef, objectName, recordId, subject);" --replacement "actionExec.refuseDeniedSubjectLoad(objectName, recordId ?? 'ablation-28657', subject);" -- bash ablate-inner.sh

ablation-replace: ok mutation landed: anchor 1 -> 0, blob a762e11688a6 -> 3d61252a815c
build exit 0
✓ dist/: marker present in 2 built files -- the ablation is live in the artifact the suite consumes.
× Approve answers 200 and the request moves: 1 of 2, the approver off the slate, an approve row
× a member on no slate gets the decision route's own refusal through the door — never a 200, never the row
Tests  2 failed | 1 passed (3)
ablation-replace: ok restored: blob == HEAD (a762e11688a6) and `git diff HEAD` is empty
tree after restore (want empty): []
✓ dist/: marker absent from all 12 built files
verify pin (restored): Tests 3 passed (3)
Runtime pins (doors over fakes): ruling 2, parity, the 400 relay

New describe('[#28657] …') in packages/runtime/src/domains/api-action-forward.test.ts, 10 cases:

  • REST: the approve shape on a row the caller cannot read is forwarded to /api/v1/approvals/requests/areq_hidden/approve with the caller's bearer, and the target's 200 is the answer.
  • REST: the target's 403 { code: 'FORBIDDEN', error } is the answer, its sentence as the message.
  • REST: a recordIdParam read from id is seeded from the routed id alone.
  • REST, ruling 2: {email} in the path and recordIdField: 'email' on a denied load answer 404 RECORD_NOT_FOUND and nothing is forwarded; the firing control on a readable row forwards with the row's field.
  • REST, ruling 6: a 400 { code: 'VALIDATION_FAILED', error } is relayed with its sentence, not "its target answered HTTP 400".
  • MCP run_action: the same approve shape on a denied load is forwarded and answers ok: true; the two row-field shapes are thrown 404 RECORD_NOT_FOUND with nothing forwarded; the { code, error } sentence rides the throw.
  • The shared helpers, case by case.

On base (-t 28657): 7 failed, 3 passed. The 3 that pass on base are the ruling-2 404 cases and their firing control, which pin behaviour that must not change. On head: Tests 68 passed (68).

Ruling 3 (every non-api branch keeps the 404) stays pinned by the existing cases, all green on head: action-door-record-load-denied.test.ts "[#16370] flow door — a denied caller-scope load is refused before the run exists" (REST and MCP), "[#16370] script/body door — the same verdict, the same refusal" (REST and MCP), and http-dispatcher.actions-type-dispatch.test.ts "[#16370] a ROW-SCOPED flow whose record never loaded is REFUSED — no run is created". The declarative update is excluded by forwardsOnDeniedSubjectLoad (pinned in the helper case) and runs ahead of the call in both doors.

MCP parity with approval_approve itself is not reachable: the sys_approval_request actions declare no ai.exposed, so run_action refuses them at the system-object exposure gate before the subject load, on base and on head alike. Parity is pinned on the same declaration shape on an exposed object.

Live probe: the card's reproduction on a stock showcase, step 7

Boot (no --ui: the probe is HTTP only): OS_PORT=PORT pnpm -C examples/app-showcase exec objectstack dev --seed-admin --no-compile -p PORT -d file:DIR/data.db, after pnpm exec turbo run build --filter='!@objectstack/docs' (117 tasks). Then, with curl and an Origin header: admin POST /api/v1/auth/sign-in/email; POST /api/v1/auth/organization/invite-member {"email":X,"role":"member"}; X POST /api/v1/auth/sign-up/email; admin POST /api/v1/data/sys_user_position for contributor and finance; X POST /api/v1/data/showcase_invoice {"name":…,"account":ACTIVE_ACCOUNT_ID,"owner":X_EMAIL} and PATCH …/ID {"status":"sent","issued_on":"2026-11-01"}; then step 7.

Base (runtime rebuilt from e4ec4f22c5, fresh database):

before: {"status":"pending","decision_progress":{"behavior":"unanimous","got":0,"need":2},"can_act":true}
STEP 7 action door approve: 404 {"success":false,"error":{"code":"RECORD_NOT_FOUND","message":"Record areq_dab05fb4-… not found in sys_approval_request","httpStatus":404}}
after: {"status":"pending","decision_progress":{"behavior":"unanimous","got":0,"need":2},"can_act":true}
actions: [{"action":"submit",…}]

Head (fresh database):

before: {"status":"pending","decision_progress":{"behavior":"unanimous","got":0,"need":2},"can_act":true}
STEP 7 action door approve: 200 {"success":true,"data":{"request":{"id":"areq_70509601-…","status":"pending",…
after: {"status":"pending","decision_progress":{"behavior":"unanimous","got":1,"need":2},"pending":["(the other approver)"],"can_act":false}
actions: [{"action":"submit",…},{"action":"approve","actor_id":"(X)"}]

X leaves pending_approvers, an approve row exists, and X's can_act is false, which is the card's step 6.

Packages, gates, lint
  • pnpm --filter @objectstack/runtime test: Test Files 504 passed (504) · Tests 8196 passed | 19 skipped (8215).
  • pnpm --filter @objectstack/runtime typecheck and pnpm --filter @objectstack/verify typecheck (each includes check:test-typecheck, so the new test files are type-checked): exit 0.
  • eslint --no-inline-config packages/runtime packages/verify: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands on the final head f3768a46: 61 commands, every one exit 0 (--ran reconciliation: "61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED"). The list with each exit code is in the card's report.

Acceptance notes

  • The approval actions are not ai.exposed, so an agent cannot run them through run_action before or after this change. Exposing them is a plugin-approvals decision outside this card.
  • The approvals routes answer { code, error } rather than the ADR-0112 envelope. This PR only reads that sentence at the forward; the routes' own shape is unchanged.

Verification

  • Real-stack HTTP pin (PR CI runs it in Test Core: @objectstack/verify is downstream of runtime): pnpm --filter @objectstack/verify exec vitest run src/action-door-api-forward-denied-subject.test.ts.
  • Live: the probe above, on the showcase.

Generated by Claude Code

… that needs nothing from the row

The REST /actions door and the MCP run_action bridge answered their own
404 RECORD_NOT_FOUND ahead of the api forward when the caller's scope did
not deliver the subject row, so a non-admin pending approver's decision
from the request page never reached the approvals routes that admit them.
Both doors now call one shared rule: an api forward whose request needs no
field of the row is forwarded and answers its target's answer; the request
is built from the recordId alone; every other branch keeps the 404. The
forward also relays a target's { code, error } sentence.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mze6z6hhTUauqWMmZ
…e member outright

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mze6z6hhTUauqWMmZ
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json da6f0ce35da60bf81e79890b227815a989f9b165 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 39f9768f4971b29ce015860c8a7b5dd134e16172 — the merge of head f3768a460e0ea970462b374dc23b592c7dc8f63c into base da6f0ce35da60bf81e79890b227815a989f9b165, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 39f9768f4971b29ce015860c8a7b5dd134e16172 && git checkout 39f9768f4971b29ce015860c8a7b5dd134e16172
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin da6f0ce35da60bf81e79890b227815a989f9b165 f3768a460e0ea970462b374dc23b592c7dc8f63c && git checkout -B drift-repro da6f0ce35da60bf81e79890b227815a989f9b165 && git merge --no-ff f3768a460e0ea970462b374dc23b592c7dc8f63c

node scripts/docs-audit/affected-docs.mjs --json da6f0ce35da60bf81e79890b227815a989f9b165

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract review

Served-tier: TIER_DEFAULT
Head-sha: f3768a460e0ea970462b374dc23b592c7dc8f63c
Local-runs: none

Inputs read: card #28657 (its body and all 3 comments: triage, claim, os-dev-report 6559402196), PR #28708 (body, 5 files, net diff against merge-base e4ec4f22c5), and the 48 latest check-runs on the head. Every check-run is success or a path-filtered skipped (UI, docs, opt-in harnesses; the diff touches no ui/** and no docs). Test Core 1-8/8, Lint & Repo Gates, TypeScript Type Check, Check Changeset, Dogfood Regression Gate and Governed Surface Queue Guard are all green.

① Derived judgments

The accept-set change: on the REST /actions door and the MCP run_action bridge, a same-origin type:'api' action whose caller-scope subject load was denied used to answer 404 RECORD_NOT_FOUND. It now forwards and answers its target's answer, when the declaration needs no field of the row. The forward-refusal relay also reads a target's string error. The widening is judged right, point by point.

  • (a) No unreadable row field reaches the request: holds, twice over.
    • buildApiActionRequest (action-execution.ts:1313) reads the row in exactly two places: the {field} path substitution (:1341) and the recordIdParam / recordIdField seed (:1387).
    • The ${...} scopes are param and ctx only (:1221). ctx.recordId is the routed id. Every substituted value goes through encodeURIComponent, so a param cannot inject a {field} token into the path.
    • The outputs.* fold (:1376) reads params. bodyExtra (:1420) reads the declaration. organizationId reads ec.
    • apiActionRequestReadsSubjectRow (:3434) puts exactly those two row reads ahead of the forward. It applies the same regex as the builder to the raw target, and it errs toward the 404: {ID} and a bare ${x} both count as row reads.
    • Independently, apiForwardSubjectRecord (:3454) hands the builder { id: recordId } and nothing else on a denied load. By construction, the leftover row on a denied load was already only the stamped { id } (loadActionSubjectRecord, :3295), so the second guard is defence in depth, not the only line.
    • Both doors use it: REST at domains/actions.ts:915, MCP at action-execution.ts:3999.
    • Pinned for both doors by the {email} path case and the recordIdField: 'email' case (404, nothing forwarded), with a firing control on a readable row and the helper table.
  • (b) The 404 holds on every other branch, at both doors.
    • forwardsOnDeniedSubjectLoad (:3416) is false for a declarative update (it reads operation before type), for flow, for script/body and for an off-origin api action. In each of those cases refuseDeniedSubjectLoadBeforeDispatch is refuseDeniedSubjectLoad unchanged.
    • The declarative update also runs ahead of the call at both doors and keeps its own refuseDeniedSubjectLoad (:3676).
    • The REST actionType is actionDef.type when it is a string (actions.ts:689), so the forward predicate and the actionType === 'api' branch (:903) cannot disagree. Every arm of that branch returns, so a skipped refusal can never fall through to the trusted actionContext. The same holds at MCP (:3986).
    • The existing [#16370] flow and script/body pins and the ROW-SCOPED flow pin are green in Test Core.
  • (c) The forward opens nothing beyond a direct call with the same credential.
    • forwardApiAction (:1810) carries only the allow-listed caller headers.
    • The mark header (:1849) carries no trust. Its only readers are the loop refusal and the audit hint, and action-invocation-audit.ts:25 says so.
    • No system or elevated context is built on the api branch. It sits ahead of actionContext, buildActionEngineFacade and buildActionExecutionContext.
    • apiForwardCredentialRefusal and the scope-family rule run unchanged.
    • One non-blocking observation: an OAuth agent at a platform target is carried by the ADR-0090 D10 rule 5 minted credential, which resolves to the same agent principal. That path existed before this PR and is unchanged by it. The approval actions are not ai.exposed, so no agent reaches them.
    • Existence: a filtered row and an absent id still collapse into one answer at the door. Whatever the target answers, it would answer the same to a direct call.
  • (d) Reach: right.
    • Only the REST /actions door and MCP run_action call the new function (actions.ts:888, action-execution.ts:3977).
    • resume_run (domains/mcp.ts:1200) and the declarative executor keep refuseDeniedSubjectLoad.
    • The new helpers are not re-exported from packages/runtime/src/index.ts, so the published type surface is unchanged.
    • The rule is generic, not approvals-only. That matches the claim's declared Clause-② on the card ("a same-origin api action on a caller-denied subject row answers its target's answer"), and it follows ADR-0066 D4: the target authorizes the caller itself.
    • pending-approver-read-grant.ts, the member grant on sys_approval_request, packages/spec/** and ui/** are untouched, as the triage ruled.
  • Relay (:1991): right. A target's string error is now read before the "its target answered HTTP N" fallback. The sentence is the target's own answer to this caller, so it discloses nothing a direct call would not. This was the card's same-path ride-along. It is pinned on both doors, for the 400 case and the 403 case.
  • Card pin owed: met. packages/verify/src/action-door-api-forward-denied-subject.test.ts boots the real stack: real dispatcher, real approvals routes, real in-process forward, no doubled fetch. A non-admin pending approver approves through the action door, and the request moves. A stranger gets the route's own 403. Test Core is green on this head.

② Semver level

  • .changeset/28657-api-action-forward-denied-subject.md declares @objectstack/runtime: patch. The diff publishes only runtime dist; @objectstack/verify gains a test file only. Metadata-protocol: none and a non-protocol widening each mean patch (AGENTS.md Post-Task Checklist 3). Right.
  • Clause-②: yes (widening: ...) appears line-initial in both the PR body and the changeset, and matches the accept-set change judged in ①. Nothing is narrowed, so there is no BREAKING banner and no ADR-0087 disposition is owed. Check Changeset is green.

③ Boundary flags

  • open_questions[0] (the approval actions are not ai.exposed; the run_action parity is pinned on an exposed object): answered A, leave them unexposed.
    • Exposing decision actions to agents is a plugin-approvals product decision outside this card.
    • Base and head refuse them at the same exposure gate, so nothing regresses.
    • The MCP parity this card owes is the door's rule, and that is pinned on the identical declaration shape.
    • No card is opened from this review. If the product wants agent decisions, that is a separate plugin-approvals card.
  • out_of_scope_findings[0] (the approvals routes answer { code, error }, not the ADR-0112 envelope): accepted as noted, not filed. It is pre-existing, and this head only reads that sentence at the forward. It is no must-fix here.
  • dogfood_battery n/a: right. The title has no ! and the change has no (narrowing) arm.
  • mcp_calls 0 / api_writes 2: consistent with the inputs read.
  • No other dev flag is raised.

No must-fix findings.

Implemented-by: claude/project-thread-8eqzai
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-01T14:58Z

Head f3768a46, report 6559402196 (on #28657), claim 6558321170, triage record (p1, 18.3.0 release blocker). Clause-②: yes (widening): the contract review owed at TIER_DEFAULT is on this head, 6559614320, PASS.

item reading
Shape Draft, base main, Fixes #28657. Five files (+540 / −5): runtime/src/action-execution.ts, runtime/src/domains/actions.ts, two test files, one @objectstack/runtime patch changeset. Labels documentation, size/l, tests, tooling. Metadata-protocol: none. No packages/spec/**, no ui/**, and pending-approver-read-grant.ts untouched (order ruling 4).
Diff check (seat) One shared refuseDeniedSubjectLoadBeforeDispatch, called at the same point by the REST /actions door and the MCP run_action bridge (ruling 5). A denied load forwards only a same-origin type:'api' action that is not a declarative update and whose request needs no row field (apiActionRequestReadsSubjectRow: a {field} path token other than {id}, or a recordIdField other than id, keeps the 404). On a denied load the request is built from { id: recordId } alone (apiForwardSubjectRecord), at both doors (ruling 2). The declarative, flow and script/body branches keep refuseDeniedSubjectLoad unchanged (ruling 3). The { code, error } string sentence relay is the same code path, so it rides along as ruling 6 allows.
Evidence CI green on head f3768a46 (filter=latest: 35 success, 13 skipped, 0 failed; Test Core, TypeScript Type Check, Lint & Repo Gates and Build Core all success). The real-stack HTTP pin in packages/verify is red on base (404 RECORD_NOT_FOUND) and green on head. The ablation goes red and restores clean. @objectstack/runtime passes 504 files / 8196 tests, and 61 of 61 derived gates exit 0. The live showcase probe of the card's step 7 answers 404 on base and 200 on head, with decision progress 0/2 → 1/2.
Open question approval_* actions are not ai.exposed, so run_action parity is pinned on an exposed object of the same shape. Answered A (leave them unexposed), as the contract review also answers. No card.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with the contract review PASS on this head and every check green or an expected skip.

Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant