Repository navigation
fix(runtime): a forwarded api action keeps the target's whole refusal, and decision outputs pass the param check - #27915
Conversation
…, 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>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head b23188c · 2026-10-30T03:48Z |
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Contract reviewServed-tier: 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 Check-runs on the head at 2026-10-30T03:56Z: every completed non-skipped run is ① Derived judgmentsEvery accept-set and public-surface change the diff implies, read in the tree:
② Semver level
③ Boundary flagsDev-flagged deviations (report 6514467517), each answered:
G1 security reading (the relay of ④ Design judgment
Findings: no must-fix. No should-fix on the face files. Two notes for the seat, neither a defect in this PR: the MCP Implemented-by: VERDICT: PASS |
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL 维护者速读
席位意见:ACCEPT。 席位核对Head-sha:
|
|
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 |
|
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: |
Requested by Elon · project thread
Fixes #27738
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
POST /api/v1/actions/:object/:action与 MCPrun_action)运行同源type: 'api'动作时,目标接口的拒绝信息现在原样带回(details、userMessage等),带审批决策输出(outputs.KEY)的批准/驳回也不再被参数校验拦下。type: 'api'action click throughPOST /actions/:object/:actioninstead of fetching itstargetdirectly (ruling on #27506, step 3 of 3) #27611 要把控制台的 api 点击改走这道门,让人和 agent 走同一条路;不修的话三条现有路径会倒退:流程的内层拒绝原因丢失、额度弹窗没有升级按钮和用量、带决策输出的批准/驳回直接 400。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 个用例全过。What changes
Both action doors forward a same-origin
type: 'api'action in process to its declaredtarget(#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: newapiActionRefusalMembers(answer), the one reader of an enveloped refusal ({ success: false, error: { code, ... } }). It returns every member of the target'serrorbesidecode,messageandhttpStatus, unchanged, orundefinedwhen 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 throughapiErrorResponse(the one envelope builder) with the target'scode,message, status and those members as written:details(innerRefusal,summary; the entitlementupgrade_url,contact_url,plan,current,limit),userMessage,declaredCode. It used to rebuild the envelope fromcodealone. A refusal the target did not envelope keeps today's answer throughdeps.error. No new code is minted.apiActionForwardRefusal(the thrown shaperun_actionrenders) now carries the envelope'sdetails, whichrun_actionrenders beside the code, so both doors answer the same refusal with the samedetails.G2 —
outputs.KEYpasses the param check on a forwardedtype: 'api'action (ADR-0104 D2 widened).isApiDecisionOutputParam(key)names the family (theoutputs.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, nobodyShape.wrap. These are exactly the actions whose request folds the family.enforceActionParamsjudges the bag throughparamBagUnderContract, which leaves out undeclaredoutputs.KEYparams for those actions only. A declared param is judged as declared, whatever its name. Every other undeclared key is refused with the same400 VALIDATION_ERROR, and the family stays refused on script, flow, declarative-update and wrapped-api actions.OS_ALLOW_LAX_ACTION_PARAMSis untouched. Nopackages/specsource changed: the admission sits on the runtime side ofvalidateActionParams, 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 forwardedtype: 'api'whose request folds it)..changeset/27738-api-action-door-refusal-and-decision-outputs.md:@objectstack/runtimepatch. 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 shipspatch, widening or not. No package export changes (action-execution.tsis not re-exported from the package root).Tests
packages/runtime/src/domains/api-action-forward.test.ts, new:FLOW_FAILEDkeepsdetails(deep-equal,innerRefusalincluded) anduserMessage.details(deep-equal).detailsinvented.detailsride therun_actionthrow.outputs.next_reviewerspasses and reaches the target folded intooutputs, with a blank output omitted.outputs.*fold pin asked for by cross-seat note 6510971608 item 1: members fold, blanks are omitted, the bareoutputs.key and other dotted keys stay flat, and nothing folds underbodyShape.wrap.packages/runtime/src/action-params-enforcement.test.ts, new, on the gate itself:bogus,outputs.,output.reasonandoutputs_reasonare still refused;outputs.reasonparam is judged as declared.Red first, then green
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.Test Files 2 passed (2) · Tests 46 passed (46).error-envelope.conformance.test.ts,flow-failed-inner-refusal.test.ts):Test Files 4 passed (4) · Tests 109 passed (109).@objectstack/runtimefull suite at 0f292d5: 480 files, 7842 passed, 19 skipped. b23188c only merges the two refusal answers inactions.tsinto one response site.Ablation (scripts/ablation-replace.mjs, each restored to the HEAD blob)
actions.tsconst refused = members→const refused = false && members(head b23188c)api-action-forward.test.ts)git diff HEADemptyaction-execution.tsconst details = apiActionRefusalMembers(answer)?.details;→const details = undefined;action-execution.tsvalidateActionParams(resolved, paramBagUnderContract(...))→validateActionParams(resolved, bag)The last two ran at 0f292d5;
action-execution.tsis unchanged at b23188c (same blob). The tests import../action-execution.js/../http-dispatcher.jsrelatively, so each leg ran against source; no dist was involved.Acceptance notes
apiActionResultOfreading 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.refusal: truemessage the producer declared. An un-enveloped refusal still goes throughdeps.errorexactly as before.check:route-envelopeledger note drifts by one word. Both refusal answers share the api branch's one non-2xxresponse:site, so theactions.tscount (handBuilt: 3) holds and the gate is green. The ledger note inscripts/check-route-envelope.mjsstill calls that site "the non-2xx relay (deps.errorspread)", while the spread is nowapiErrorResponsefor an enveloped refusal anddeps.errorotherwise. 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/verifyin-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`)
POST /api/v1/apps/showcase/recalc:400 FLOW_FAILED, withdetails.summaryanddetails.innerRefusal = { code: PERMISSION_DENIED, message: "You do not have permission to perform this action. ..." }.400 FLOW_FAILED, same message, error memberscode,message,httpStatusonly.detailsdropped.400 FLOW_FAILED,detailsbyte-identical to the direct answer,innerRefusalincluded.G1b — a coded 403 entitlement refusal
403 DEV_ENV_LIMIT,details = { current: 3, limit: 3, plan: "team", upgrade_url, contact_url }.403 DEV_ENV_LIMIT,detailsmissing.403 DEV_ENV_LIMIT,detailsidentical to the direct answer.G2 — approve with a decision output (`showcase_dynamic_approval`, required `next_reviewers`)
approval_approvewithparams: { comment, "outputs.next_reviewers": [userId] }:400 VALIDATION_ERROR"Invalid action params: Unknown action param "outputs.next_reviewers" ...". The request stayedpending.200,data.decision = "approve",finalized: true,resumed: true. The request is nowapproved. The step declares the outputrequired, which the decide route enforces, so the folded value reached it.Local gates (head b23188c)
dispatch-gates --commandsderives 69 families. 67 ran with exit 0. 2 are NOT MEASURED (exit 3, prerequisite not met here):check-plugin-teardown-shape --self-testneeds an unshallowedorigin/main, andcheck:dual-build-cjs-loadsneeds every package's dist. CI runs both.dispatch-gates --ranreconciles 69/69.pnpm lint: exit 0.@objectstack/runtimecheck:test-typecheck: OK (test layer: 188 errors / 68 pinned signatures held, unchanged).Generated by Claude Code