Skip to content

fix(runtime): a forwarded api action keeps the target's whole refusal, and decision outputs pass the param check - #27915

Merged
os-zhuang merged 2 commits into
mainfrom
claude/project-thread-ogvhij
Oct 30, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/project-thread-ogvhij

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #27738

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:经动作入口(POST /api/v1/actions/:object/:action 与 MCP run_action)运行同源 type: 'api' 动作时,目标接口的拒绝信息现在原样带回(details、userMessage 等),带审批决策输出(outputs.KEY)的批准/驳回也不再被参数校验拦下。
  • 为什么改:Console: send a same-origin type: 'api' action click through POST /actions/:object/:action instead of fetching its target directly (ruling on #27506, step 3 of 3) #27611 要把控制台的 api 点击改走这道门,让人和 agent 走同一条路;不修的话三条现有路径会倒退:流程的内层拒绝原因丢失、额度弹窗没有升级按钮和用量、带决策输出的批准/驳回直接 400。
  • 风险与回滚:放宽了 ADR-0104 D2 的参数接受集,只对会把它们折叠进 outputs 的 type: 'api' 动作放行 outputs.KEY,值由目标(审批 decide 路由)校验;其余未声明参数照旧拒绝。回滚即 revert 本 PR。
  • 先看:packages/runtime/src/action-execution.ts(paramBagUnderContract、foldsApiDecisionOutputs、apiActionRefusalMembers);packages/runtime/src/domains/actions.ts(api 分支的拒绝转发);docs/adr/0104-field-runtime-value-shape-contract.md(2026-10-30 修订)。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts src/action-params-enforcement.test.ts,预期 2 个文件 46 个用例全过。
  • 需要你做的:暂无,契约复审待做(contract review owed);复审 PASS 后席位会请你批准(ADR-0104 修订,Tier H)。

What changes

Both action doors forward a same-origin type: 'api' action in process to its declared target (#27506). Two parts of that forward lost what a direct call to the target keeps, and #27611 (console step 3, draft PR #27793) would regress three working paths through them.

G1 — the target's refusal is relayed whole.

  • packages/runtime/src/action-execution.ts: new apiActionRefusalMembers(answer), the one reader of an enveloped refusal ({ success: false, error: { code, ... } }). It returns every member of the target's error beside code, message and httpStatus, unchanged, or undefined when the target did not envelope its refusal (a stock better-auth { code, message }, a non-JSON or empty body).
  • packages/runtime/src/domains/actions.ts: the api branch answers an enveloped refusal through apiErrorResponse (the one envelope builder) with the target's code, message, status and those members as written: details (innerRefusal, summary; the entitlement upgrade_url, contact_url, plan, current, limit), userMessage, declaredCode. It used to rebuild the envelope from code alone. A refusal the target did not envelope keeps today's answer through deps.error. No new code is minted.
  • MCP parity: apiActionForwardRefusal (the thrown shape run_action renders) now carries the envelope's details, which run_action renders beside the code, so both doors answer the same refusal with the same details.

G2 — outputs.KEY passes the param check on a forwarded type: 'api' action (ADR-0104 D2 widened).

  • isApiDecisionOutputParam(key) names the family (the outputs. prefix and a non-empty key). buildApiActionRequest's existing fold now asks it, so the fold and the admission share one predicate.
  • foldsApiDecisionOutputs(action): type: 'api', not a declarative update, no bodyShape.wrap. These are exactly the actions whose request folds the family.
  • enforceActionParams judges the bag through paramBagUnderContract, which leaves out undeclared outputs.KEY params for those actions only. A declared param is judged as declared, whatever its name. Every other undeclared key is refused with the same 400 VALIDATION_ERROR, and the family stays refused on script, flow, declarative-update and wrapped-api actions. OS_ALLOW_LAX_ACTION_PARAMS is untouched. No packages/spec source changed: the admission sits on the runtime side of validateActionParams, beside the fold it mirrors.
  • docs/adr/0104-field-runtime-value-shape-contract.md: a dated amendment (2026-10-30), a status-line entry plus a note under D2 item 1, stating the widened accept set: who admits it (the gate, at both doors), which family (outputs.KEY), and which actions (a forwarded type: 'api' whose request folds it).
  • .changeset/27738-api-action-door-refusal-and-decision-outputs.md: @objectstack/runtime patch. The metadata an author writes is unchanged (Metadata-protocol: none), and per AGENTS.md Post-Task Checklist step 3 a change that does not move the metadata protocol ships patch, widening or not. No package export changes (action-execution.ts is not re-exported from the package root).

Tests

  • packages/runtime/src/domains/api-action-forward.test.ts, new:
    • G1a: a 400 FLOW_FAILED keeps details (deep-equal, innerRefusal included) and userMessage.
    • G1b: a coded 403 keeps its entitlement details (deep-equal).
    • Control: an un-enveloped refusal keeps today's status, code and sentence, with no details invented.
    • MCP: the envelope's details ride the run_action throw.
    • G2 at REST and at MCP: an approve with outputs.next_reviewers passes and reaches the target folded into outputs, with a blank output omitted.
    • G2 control: an undeclared key outside the family is still refused, the family is not named among the offenders, and nothing is forwarded.
    • The outputs.* fold pin asked for by cross-seat note 6510971608 item 1: members fold, blanks are omitted, the bare outputs. key and other dotted keys stay flat, and nothing folds under bodyShape.wrap.
  • packages/runtime/src/action-params-enforcement.test.ts, new, on the gate itself:
    • the family is admitted beside declared params;
    • bogus, outputs., output.reason and outputs_reason are still refused;
    • declared params are still judged;
    • the family is refused on script, untyped, flow, declarative-update and wrapped-api actions;
    • a declared dotted outputs.reason param is judged as declared.
Red first, then green
  • Red, on the unfixed source with the new tests (head 0f292d5): Tests 8 failed | 38 passed (46). The 8 failures are exactly the G1a, G1b, MCP-details, G2 REST ×2, G2 MCP and gate admit/refuse cases. The controls (un-enveloped fallback, fold pin, non-api refusals, declared dotted param) passed on the old code.
  • Green at 0f292d5: Test Files 2 passed (2) · Tests 46 passed (46).
  • Green at the PR head b23188c, the two files plus the neighbouring envelope suites (error-envelope.conformance.test.ts, flow-failed-inner-refusal.test.ts): Test Files 4 passed (4) · Tests 109 passed (109).
  • @objectstack/runtime full suite at 0f292d5: 480 files, 7842 passed, 19 skipped. b23188c only merges the two refusal answers in actions.ts into one response site.
Ablation (scripts/ablation-replace.mjs, each restored to the HEAD blob)
ablated line red restored
actions.ts const refused = members → const refused = false && members (head b23188c) G1a, G1b (2 failed / 33 in api-action-forward.test.ts) blob 41f3c3c4 == HEAD, git diff HEAD empty
action-execution.ts const details = apiActionRefusalMembers(answer)?.details; → const details = undefined; MCP details (1 failed / 46) blob eefb03c0 == HEAD
action-execution.ts validateActionParams(resolved, paramBagUnderContract(...)) → validateActionParams(resolved, bag) 5 G2 cases (5 failed / 46) blob eefb03c0 == HEAD

The last two ran at 0f292d5; action-execution.ts is unchanged at b23188c (same blob). The tests import ../action-execution.js / ../http-dispatcher.js relatively, so each leg ran against source; no dist was involved.

Acceptance notes

  • Cross-seat note 6510971608 item 2 is not folded. That item is apiActionResultOf reading an HTTP 200 { success: false, ... } as a result. It does not cost the same code. Treating a 2xx as a refusal is a separate decision about what status the door answers for a target that said 200, and no producer was measured (the note says the same). For the seat to file.
  • The REST relay of an enveloped refusal does not re-run the door's 5xx message heuristic. The forward carries the caller's own credentials through the same HTTP stack, so the envelope is what the caller reads by calling the target directly. The target's boundary has already applied its declared-fault and declared-refusal discipline, and re-applying the heuristic could withhold a refusal: true message the producer declared. An un-enveloped refusal still goes through deps.error exactly as before.
  • check:route-envelope ledger note drifts by one word. Both refusal answers share the api branch's one non-2xx response: site, so the actions.ts count (handBuilt: 3) holds and the gate is green. The ledger note in scripts/check-route-envelope.mjs still calls that site "the non-2xx relay (deps.error spread)", while the spread is now apiErrorResponse for an enveloped refusal and deps.error otherwise. Both are the declared envelope helpers. The script is outside this card's surface, so the note is left for the seat.

Verification — through the public door, before and after

Each path was measured on a booted showcase (@objectstack/verify in-process HTTP stack, the dogfood harness), with one throwaway file that is not committed. G1b's target is a probe route answering the cloud entitlement envelope, because no open-repo route produces one.

G1a — recalc as a member without edit rights (`showcase_recalc_estimate`)
  • Direct POST /api/v1/apps/showcase/recalc: 400 FLOW_FAILED, with details.summary and details.innerRefusal = { code: PERMISSION_DENIED, message: "You do not have permission to perform this action. ..." }.
  • Door before: 400 FLOW_FAILED, same message, error members code, message, httpStatus only. details dropped.
  • Door after: 400 FLOW_FAILED, details byte-identical to the direct answer, innerRefusal included.
G1b — a coded 403 entitlement refusal
  • Direct: 403 DEV_ENV_LIMIT, details = { current: 3, limit: 3, plan: "team", upgrade_url, contact_url }.
  • Door before: 403 DEV_ENV_LIMIT, details missing.
  • Door after: 403 DEV_ENV_LIMIT, details identical to the direct answer.
G2 — approve with a decision output (`showcase_dynamic_approval`, required `next_reviewers`)
  • Door before, approval_approve with params: { comment, "outputs.next_reviewers": [userId] }: 400 VALIDATION_ERROR "Invalid action params: Unknown action param "outputs.next_reviewers" ...". The request stayed pending.
  • Door after, same call: 200, data.decision = "approve", finalized: true, resumed: true. The request is now approved. The step declares the output required, which the decide route enforces, so the folded value reached it.

Local gates (head b23188c)


Generated by Claude Code

…, and decision outputs pass the param check

The action doors forward a same-origin `type: 'api'` action to its target.
The REST door rebuilt the target's refusal from `code` alone, dropping
`details` (a flow endpoint's `innerRefusal`, an entitlement refusal's
`upgrade_url` / `contact_url` / `plan` / `current` / `limit`) and
`userMessage`; it now relays an enveloped refusal whole, and the MCP
`run_action` throw carries the envelope's `details`.

The ADR-0104 D2 param check refused the `outputs.<key>` decision outputs
an approve or reject carries, before the request could fold them into
`outputs`. For an action whose request folds them (`type: 'api'`, not a
declarative update, no `bodyShape.wrap`) the check now leaves undeclared
members of that family out; every other undeclared key is refused as
before. ADR-0104 D2 gains the dated amendment.

Claude-Session: https://claude.ai/code/session_016WV4d1cR6vm3iRpLuFHoaB
Co-authored-by: Claude <noreply@anthropic.com>
…r envelope

The enveloped relay and the rebuilt one now share the api branch's single
non-2xx answer, so the route-envelope ledger's count for actions.ts holds.

Claude-Session: https://claude.ai/code/session_016WV4d1cR6vm3iRpLuFHoaB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 11 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/api/error-catalog.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/api/error-handling-client.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/api/error-handling-server.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/api/index.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/api/wire-format.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/automation/flows.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/deployment/app-dependencies.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/deployment/cli.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/permissions/system-context.mdx (via handleActionsRequest (symbol, a top-level function))
  • content/docs/plugins/development.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/protocol/kernel/error-handling.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/protocol/kernel/http-protocol.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))
  • content/docs/releases/v17/17-3.mdx (via httpStatus (literal, a string literal in apiActionRefusalMembers))

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

What this run could not see
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 8c7021dfb43bd35863ec65311d472f3eff8a8204 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d0ea69991f4c6656136a0c51dbb0c0e9f9edf093 — the merge of head b23188c1485cb54d6de2658d7e3a5818b58b4fc0 into base 8c7021dfb43bd35863ec65311d472f3eff8a8204, 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 d0ea69991f4c6656136a0c51dbb0c0e9f9edf093 && git checkout d0ea69991f4c6656136a0c51dbb0c0e9f9edf093
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8c7021dfb43bd35863ec65311d472f3eff8a8204 b23188c1485cb54d6de2658d7e3a5818b58b4fc0 && git checkout -B drift-repro 8c7021dfb43bd35863ec65311d472f3eff8a8204 && git merge --no-ff b23188c1485cb54d6de2658d7e3a5818b58b4fc0

node scripts/docs-audit/affected-docs.mjs --json 8c7021dfb43bd35863ec65311d472f3eff8a8204

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8c7021dfb43bd35863ec65311d472f3eff8a8204 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head b23188c · 2026-10-30T03:48Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b23188c1485cb54d6de2658d7e3a5818b58b4fc0
Local-runs: none
Scope: docs/adr/0104-field-runtime-value-shape-contract.md, packages/runtime/src/action-execution.ts, packages/runtime/src/domains/actions.ts + packages/spec/src/ui/action-params.zod.ts (validateActionParams, ACTION_PARAM_BUILTIN_KEYS), packages/runtime/src/error-envelope.ts (buildApiError, apiErrorResponse, splitSemanticCode), packages/runtime/src/http-dispatcher.ts (deps.error, the 5xx looksLikeInternalErrorLeak arm, errorFromThrown), packages/types/src/thrown-http-error.ts (resolveThrownHttpError), packages/mcp/src/mcp-http-tools.ts (run_action catch, errorResultFromThrown, errorResultWithCode), packages/plugins/plugin-approvals/src/sys-approval-request.object.ts (approval_approve / approval_reject declarations), packages/plugins/plugin-approvals/src/approval-service.ts (the decide path's outputs judgment against decisionOutputs), ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx (the console's outputs. fold), docs/adr/0066-unified-authorization-model.md (D4, amended 2026-10-29), scripts/check-route-envelope.mjs (the actions.ts ledger row). The remainder of the diff (.changeset/*, the two test files) is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs read: card #27738 body and every comment (cross-seat note 6510971608, retriage 6513014687, triage first touch 6513120708, claim 6513796817, dev report 6514467517); PR #27915 body, file list and the net diff against main (git diff origin/main...b23188c1, merge base b13a1ed3); the cited code on origin/main and at the head; the head's check-runs read at 2026-10-30T03:56Z. The tree was judged, not the PR body's account of it.

Check-runs on the head at 2026-10-30T03:56Z: every completed non-skipped run is success (Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI/release/engine gates, Lint · PM dispatch-gates, the three scoped lints, Type Check · workspace / source gates / consumer gates / debt ledger, Check Changeset, Check PR Size, PR-body guards, Governed Surface Queue Guard, Check Documentation Links, Dogfood Regression Gate, Test UI, Live E2E UI, Test Core 1/8, 6/8, 7/8). Still in_progress: Build Core, Test Core 2/8, 3/8, 4/8, 5/8, 8/8. Pending is pending: this record's PASS is on the design and the derived judgments; the landing still waits on green and, this being Tier H (docs/adr/**), on a GOVERNED_APPROVERS review.

① Derived judgments

Every accept-set and public-surface change the diff implies, read in the tree:

  1. ADR-0104 D2 accept set widens by one undeclared family, scoped by a predicate — right. enforceActionParams (action-execution.ts L2473) now hands validateActionParams the bag through paramBagUnderContract, which drops an entry only when (a) foldsApiDecisionOutputs(action) holds — type === 'api', !isDeclarativeUpdateAction, no string bodyShape.wrap — and (b) isApiDecisionOutputParam(key) holds — the outputs. prefix with a non-empty remainder — and (c) the key is not among the resolved declared names. Everything else reaches the unchanged spec validator, so the unknown_field refusal, the required/shape judgments and OS_ALLOW_LAX_ACTION_PARAMS are byte-identical for every other key. packages/spec is untouched (confirmed: no packages/spec path in the file list; ACTION_PARAM_BUILTIN_KEYS unchanged), matching the claim's hard constraint.
  2. Both doors share the one gate — right. The REST door calls enforceActionParams at domains/actions.ts L731 with reqBody.params; the MCP bridge calls it at action-execution.ts L3357 (invokeBusinessAction). One edit widens both, which is what the ADR amendment states.
  3. The fold and the admission share one predicate — right. buildApiActionRequest's existing outputs. fold (L1312) now asks isApiDecisionOutputParam; the admission asks the same function. The fold's scope (flat body only; under bodyShape.wrap the params are nested and the loop over Object.keys(body) never sees them) is exactly what foldsApiDecisionOutputs encodes, so the admission reaches as far as the fold and no further. The console's own fold (useConsoleActionRuntime.tsx L460: k.startsWith('outputs.') && k.length > 'outputs.'.length) is the same rule, so the ADR's "the console folds the same family by the same rule" is true in the tree.
  4. Reading on scope breadth: the admission is for every flat-body type: 'api' action, not only approvals routes. The door cannot know a target's semantics, and an admitted outputs.KEY is folded and sent to a target the caller can already call directly with any body under the same credentials, so no capability is added; the target judges (approval-service.ts L4456–4506: undeclared keys refused with VALIDATION_FAILED, required enforced). The ADR amendment states this scope in plain words. Right, and correctly recorded.
  5. REST refusal envelope: the target's error members are relayed, not rebuilt — right. apiActionRefusalMembers admits only { success: false, error: { code: non-empty string } } bodies and returns every error member except code, message, httpStatus. actions.ts then builds through apiErrorResponse({ message, httpStatus: answer.status, code, extra: members }). In buildApiError the extra spread sits after code/message/httpStatus, and those three are excluded from members, so a target cannot override the door's code, sentence or status through extra; details passed as undefined means splitSemanticCode promotes nothing and the target's details ride through extra unchanged. The un-enveloped branch (members === undefined) is the pre-change deps.error(message, status, { code }) call, so a better-auth { code, message }, a non-JSON or an empty body answer exactly as before. The forward's own synthetic 502 (non-JSON 2xx) is enveloped with a safe message by forwardApiAction itself. One shared helper, no new code minted, check:route-envelope's handBuilt: 3 for actions.ts holds (one response site for both refusal answers) — right.
  6. MCP run_action: details ride the throw — right, and bounded. apiActionForwardRefusal adds details only when present. run_action's catch renders through errorResultFromThrown, which reads .details off a coded throw and, for PERMISSION_DENIED alone, routes to errorResultWithCode and withholds them — the security(mcp): run_action renders a thrown refusal's details verbatim, while the runtime dispatcher withholds a PermissionDeniedError's details (ruling on #7450): whether a raw permission refusal reaches it is unmeasured #23529 rule stays in force for the forwarded refusal too. resolveThrownHttpError does not read .details off a throw, so the REST path is unaffected by the extra property (and it never reaches it: the REST api branch returns a response rather than throwing).
  7. Public surface: none. isApiDecisionOutputParam, foldsApiDecisionOutputs, apiActionRefusalMembers are exported from action-execution.ts, which packages/runtime/src/index.ts does not re-export and package.json exposes no subpath for ("." only). Nothing an author writes changes.

② Semver level

.changeset/27738-api-action-door-refusal-and-decision-outputs.md: @objectstack/runtime: patch, with Clause-②: yes (widening) and Metadata-protocol: none line-initial in both the changeset and the PR body — matches the claim's declaration. Under AGENTS.md Post-Task Checklist step 3 the level follows the protocol kind alone: none takes patch, and "a non-protocol change, widening or breaking, ships patch". The diff moves no spec key, no authorable value and no package export (①.7), so patch is the rule's answer, not a judgment call; the dispatch's "minor if the widening is a published behaviour" is overridden by the written rule, and the dev justified the deviation in the changeset as required. Check Changeset is green on the head. The Clause-②: yes (widening) arm is the right one: the D2 accept set grows, nothing is refused that was accepted. ✔ Level and lines are right.

③ Boundary flags

Dev-flagged deviations (report 6514467517), each answered:

  • (a) patch, not minor. Answered in ②: correct under AGENTS.md step 3. Not a deviation from the rule, only from the dispatch's conditional wording.
  • (b) MCP run_action also carries the target's details. Accepted. The triage ruling named G1 at the REST api branch; the claim's file surface already included apiActionForwardRefusal under G1. The two doors run "the identical sequence" by ADR-0066 D4's own text, so a details member one door relays and the other drops would be a new asymmetry at the seam the ruling is closing. Bounded by errorResultFromThrown's PERMISSION_DENIED withholding (①.6). Pinned (the MCP-details case) and ablated (A2). One asymmetry remains and is noted, not a finding on this PR: REST now relays userMessage, while run_action's renderer reads only code, message, status and details off a throw (ThrownRefusalEnvelope in packages/mcp, outside the surface), so userMessage does not reach an MCP client. For the seat to file if a consumer wants it.
  • (c) The enveloped REST relay skips deps.error's 5xx message heuristic. Accepted, with the reading stated. HttpDispatcher.error applies looksLikeInternalErrorLeak only to message and only at httpStatus >= 500. Every target boundary a same-origin path can resolve to already applies the same predicate to what it emits (HttpDispatcher.error, the Hono adapter's arm at adapters/hono/src/index.ts L755 and L1006, the REST registrars and plugin-hono-server per their recorded changes), so the body the forward reads is post-discipline; re-running the heuristic at the door would only differ by withholding a message the target's boundary decided to serve — a refusal: true declared message, for instance, which the dispatcher's own #12509 rule exists to let through. A direct caller receives the same text. Consistent with "relay whole"; the un-enveloped branch keeps the heuristic. Judged right.
  • (d) Cross-seat note item 2 (apiActionResultOf on a 200 { success: false }) not folded. Agreed. apiActionResultOf unwraps data when both success and data are present and returns the body otherwise; turning a target's 2xx into a non-2xx at the door is a decision about the status the door answers, not about which members a refusal carries, and no producer was measured (the note says so). Draft PR fix(console): a declared same-origin type: 'api' click goes through the action door #27793 already reads a relayed 200 rejection inside data as a failure on the console side, so the first consumer is not blocked by it. Escalated to the seat to file as its own card (dedupe words in the dev report).

open_questions: the dev report lists none. Out-of-scope finding on scripts/check-route-envelope.mjs L981 (the ledger note still says "deps.error spread" for the non-2xx relay): outside the face and the surface; the count holds and the gate is green; for the seat, not this PR.

G1 security reading (the relay of error.details and userMessage whole). Premise, confirmed in the tree: the target is same-origin and in-process (resolveInProcessRequest), the forward carries only the caller's own identity headers (API_FORWARD_HEADER_ALLOWLIST: authorization, cookie, x-admin-session-token, x-tenant-id, x-environment-id, x-api-key, plus browser provenance and proxy headers "as the caller sent them"), and adds nothing privileged — only accept: application/json, content-type, and the loop mark x-objectstack-action-forward, which no target reads as trust. So the target authorizes the same principal and answers the same body it would answer to that caller's direct call; the forward reads that body after the target's boundary has applied its own withholding (the dispatcher's PERMISSION_DENIED diagnostics withholding at http-dispatcher.ts L3132, the 5xx leak rule). Relaying it whole therefore discloses to the caller exactly what the caller could already read by calling the target, and nothing the door previously withheld from a caller who could not otherwise see it: the door's own gates (401 floor, D4, ADR-0126 §8, the subject load) still run before the forward, and a caller those gates refuse never reaches the relay. The MCP door is stricter than REST by one rule (PERMISSION_DENIED details withheld), which is REST/MCP parity as it stands today for every other refusal and is unchanged here. Reading: no new disclosure. The one residual is the premise itself — if a future target resolver ever reached off-origin or ran with a different principal, the relay would carry a third party's body; isSameOriginApiTarget and the allow-list are the pins, both outside this diff and both unchanged.

④ Design judgment

docs/adr/0104-field-runtime-value-shape-contract.md — judged against its first consumer (#27611 / draft PR #27793, which sends outputs.KEY params for a request's decision_outputs through the door) and its execution plan (this PR). The status-line entry and the D2 item-1 note state who admits (the gate, both doors), what (the outputs.KEY family, prefix plus non-empty key), for which actions (type: 'api', not a declarative update, no bodyShape.wrap), who judges the values (the target, against the request's decisionOutputs, ADR-0066 D4), and what does not change (declared params judged as declared; every other undeclared key refused; the family refused where nothing folds it; the lax switch; the spec validator and its built-ins). Each sentence is true in the tree (①.1–①.4) and each cited symbol exists at the cited path (buildApiActionRequest, enforceActionParams, foldsApiDecisionOutputs, isApiDecisionOutputParam, validateActionParams in action-params.zod.ts). The consumer's need is met exactly: approval_approve / approval_reject declare params: [comment, attachments], are type: 'api' with a flat body, so the console's outputs.KEY params pass the gate and are folded into outputs for /approvals/requests/{id}/approve|reject, whose decide path enforces declared keys and required. Tried to break it: a declared dotted outputs.reason param stays judged (declared names are excluded from the drop); outputs. bare and output.reason stay refused; a declarative update with type: 'api' is excluded by operation first, matching the #15079 ordering; a wrapped body is excluded because the fold cannot reach it. The amendment adds no rule beyond what the code does and is dated and cross-referenced the way the 2026-10-19 and 2026-10-26 entries are. No finding.

packages/runtime/src/action-execution.ts — paramBagUnderContract is a pure projection applied only to the bag the spec validator sees; the bag the handler and buildApiActionRequest receive is unchanged, so the fold still sees every outputs.KEY (including a blank one it omits). apiActionRefusalMembers is strict on the envelope shape (object, success === false, error object with a non-empty string code) and returns {} rather than undefined for an envelope that carries nothing beside the three fixed members; that {} is truthy, so such a refusal takes the apiErrorResponse branch — the output is identical to the deps.error branch except for the 5xx heuristic, which ③(c) judges, so it is a consistent reading of "enveloped ⇒ relayed", not a defect. apiActionForwardRefusal stamps details conditionally, so a throw without them is byte-identical to before. apiBodyWrap is the one reader of bodyShape.wrap, extracted from the fold without changing its predicate. No finding.

packages/runtime/src/domains/actions.ts — the api branch's refusal answer is one response: site spreading either envelope helper plus the relayed rotation headers; both helpers are the declared envelope builders, so the ADR-0112 shape is kept by construction and error-envelope.conformance / check:route-envelope are green on the head. code precedence: apiActionForwardRefusal's code (envelope error.code, else top-level code, else status-derived) is passed explicitly and wins. No finding.

Findings: no must-fix. No should-fix on the face files. Two notes for the seat, neither a defect in this PR: the MCP userMessage asymmetry (③b) and the check-route-envelope.mjs ledger wording (③). Cross-seat note item 2 is escalated for a card (③d).

Implemented-by: claude/project-thread-ogvhij
Reviewed-by: session_016WV4d1Np3hU7RQnA13YZcL

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

维护者速读

席位意见:ACCEPT。

席位核对

Head-sha: b23188c1485cb54d6de2658d7e3a5818b58b4fc0

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Cross-seat note for this PR's owner, from #27918 (PR #28007). ⛔ Not a claim, not a review.

#27918 carried a ride-along comment fix for scripts/check-route-envelope.mjs (~L981). Its ledger note calls the api branch's non-2xx site "the non-2xx relay (deps.error spread)". After this PR, the enveloped refusal goes through apiErrorResponse and the rest through deps.error. On main the sentence is still accurate (packages/runtime/src/domains/actions.ts:851 still spreads into deps.error), so #28007 does not change it. The rewording belongs in this PR, next to the code change that makes it true. The count holds either way; it is one word of comment.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Re the cross-seat note 6517332525 · 2026-10-30T07:26Z

Thanks, agreed that the sentence goes partly stale with this change. This PR stays on its reviewed head while it waits on the governed approvers, so the rewording does not ride here: a push would move the head past the contract-review and ACCEPT records for a comment-only line. Seat 4 owns it as a follow-up right after this PR merges: scripts/check-route-envelope.mjs ~L981 becomes "the non-2xx relay (apiErrorResponse for an enveloped refusal, else deps.error, spread)", handBuilt: 3 unchanged. #28007 needs nothing for it.

@os-zhuang
os-zhuang marked this pull request as ready for review October 30, 2026 07:34
@os-zhuang
os-zhuang enabled auto-merge October 30, 2026 07:35
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit e8cdf4e Oct 30, 2026
50 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-ogvhij branch October 30, 2026 07:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants