Skip to content

fix(runtime): an api action whose target names its record is refused 400 naming recordId when called without one, never forwarded to a path with a hole in it - #29005

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-4yqy8a
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-4yqy8a

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28932

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:type: 'api' 动作的 target 里带记录占位({id}、${ctx.recordId}、{字段})时,调用方没传 recordId(或传了空串),动作入口现在直接回 400 VALIDATION_ERROR,消息点名缺的是 recordId(记录上缺字段则点名那个字段),不再转发。REST 动作入口和 MCP run_action 共用同一处判断。
  • 为什么改:原来占位被清空或原样留在路径里转发出去,路由匹配不上,调用方收到 404 ENDPOINT_NOT_FOUND「端点不存在」,会以为路由坏了,而不是自己漏了参数。
  • 风险与回滚:低。只把原来就失败的调用(404)改成 400 并说清原因;带真实 id 的调用(200)和未知 id 的调用(目标自己的 404 REQUEST_NOT_FOUND)照旧转发。回滚即撤销本 PR。
  • 先看:packages/runtime/src/action-execution.ts(buildApiActionRequest 第 1 步路径拼装里新增的两处拒收和两个小函数);packages/runtime/src/domains/api-action-forward.test.ts(文件末尾新增的一组用例);packages/verify/src/action-door-api-forward-denied-subject.test.ts(真实启动栈上的两条新用例)。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts,预期 73/73 通过(main 上新增用例 4 条红)。
  • 需要你做的:无,席位落地。

Why

An integration calling a record-anchored api action over REST or MCP without its recordId was told the endpoint does not exist (404 ENDPOINT_NOT_FOUND), so the cost of finding a missing parameter landed on the caller. The door now says which input is missing.

What changes

The seam. buildApiActionRequest (packages/runtime/src/action-execution.ts) assembles the forward for a same-origin type: 'api' action, and both action doors call it: REST POST /api/v1/actions/:object/:action (domains/actions.ts) and the MCP run_action bridge (invokeBusinessAction). Step 1 of it, the path, now refuses a record token it cannot fill, before anything is forwarded:

  • ${ctx.recordId} left unresolved. An empty recordId now counts as none (input.recordId || record.id || undefined), so ${ctx.recordId} stays in place instead of being blanked to ''. It is refused naming recordId, ahead of the browser-only token refusal that would otherwise claim it with a sentence saying the server does interpolate ${ctx.recordId}.
  • {field} left unresolved. A field the row has no value for (null, undefined or '') is left in place, never blanked. After the browser-only refusal (so a {{page.x}} keeps that refusal), any {field} token still in the path is refused: naming recordId when the caller named no record, otherwise naming the field. Without a row, {id} is filled from the routed recordId, the same source recordIdParam already uses. Both doors always pass a row, so that branch is reachable only by a direct caller of the function.
  • The refusal shape is the existing one. The function returns { status: 400, message }, as its other refusals do. The REST door answers it through deps.error(message, 400) and the MCP door throws it with standardErrorCodeForHttpStatus(400), so both answer VALIDATION_ERROR. No new error code. No runtime string carries a tracker number.
  • Changeset .changeset/api-action-missing-record-id.md: patch on @objectstack/runtime, with both declaration lines.

Step 1 measurement (the order's ruling 2). Every api action in this repo whose target carries a {field} token names {id} alone: the nine sys_approval_request decision actions in packages/plugins/plugin-approvals/src/sys-approval-request.object.ts (approve, reject, reassign, revise, request-info, remind, recall, resubmit, comment) and the two sys_api_key actions in packages/platform-objects/src/identity/sys-api-key.object.ts. ui/apps/console/src/dev/DevRecordHeaderActions.tsx has one more {id} target (a dev-only page). Two actions use ${ctx.recordId} instead: packages/services/service-datasource/src/datasource-admin-plugin.ts (/test, /migrate-credential). examples/ has no record-token api target; its only {{categoryId}} hit is a QA step in examples/app-showcase/qa/platform-smoke.test.json, not an action. hotcrm (d4ffe99) has none.

Tests

  • Unit, new block at the end of packages/runtime/src/domains/api-action-forward.test.ts (5 tests):
    • REST: the approve shape (/api/v1/approvals/requests/{id}/approve) with { params: {} } and with { recordId: '', params: {} }: 400, code VALIDATION_ERROR (pinned against standardErrorCodeForHttpStatus(400) and literally), the message names `recordId`, and the in-process request is never called.
    • REST: a ${ctx.recordId} target (a DELETE) and a {email} target, each with no id and with '': the same refusal, nothing forwarded.
    • REST controls: a real id is forwarded (200, the id in the URL); an unknown id is forwarded and the target's 404 REQUEST_NOT_FOUND is relayed; a target with no token is forwarded with no id (200).
    • MCP run_action: the approve shape with no id and with '' is thrown 400 VALIDATION_ERROR naming recordId, nothing forwarded.
    • buildApiActionRequest: a row without email, with email: null and with email: '' is refused naming `email`; no row at all names recordId; {id} without a row is filled from the routed id, percent-encoded.
  • Real stack, two new cases in packages/verify/src/action-door-api-forward-denied-subject.test.ts (booted stack, real approvals routes, a non-admin pending approver):
    • POST /api/v1/actions/sys_approval_request/approval_approve with { params: {} } and with { recordId: '' }: 400 VALIDATION_ERROR naming recordId, and the request is untouched (no approve row, slate unchanged).
    • Control: an id that names no request answers the decision route's 404 REQUEST_NOT_FOUND. The existing case in the same file already pins the real-id 200.
  • Red on base f7c545ad. Unit file with action-execution.ts checked out from base: 4 failed, 69 passed (the four refusal tests; all controls green). Verify file with runtime dist rebuilt from base: 1 failed, 4 passed, the failure reading {"code":"ENDPOINT_NOT_FOUND","message":"Not found","httpStatus":404}, which is the card's repro. Both trees restored: git status --porcelain empty, blob equal to HEAD, runtime rebuilt.
  • Green on head. Unit file 73/73. Verify file 5/5.
Ablation (one-off, from committed HEAD)

Both guard lines replaced through node scripts/ablation-replace.mjs (anchor 1 → 0 each): the ${ctx.recordId} check by one that tests for the marker ABLATED-28932-A, and the {field} check by one that tests for ABLATED-28932-B, so the helper stays referenced and the build still type-checks.

  • Unit file (reads action-execution.ts from source): 4 failed, 69 passed, the same four tests as on base.
  • Verify file (reads runtime dist): pnpm --filter @objectstack/runtime build, then node scripts/ablation-dist-preflight.mjs @objectstack/runtime 'ABLATED-28932-B' answered "marker present in 2 built files". 1 failed, 4 passed. With the guard ablated, the {id} token is no longer blanked, so the forward reaches the route with {id} literally and the route answers 400 FILTER_TOKEN_UNKNOWN; the pin still reds because the code is not VALIDATION_ERROR.
  • Restore: git checkout HEAD -- PATH, git diff HEAD 0 bytes, blob 12811782c09a equal to HEAD; rebuilt, and ablation-dist-preflight.mjs --absent answered "marker absent from all 12 built files" and "working tree clean against HEAD".

Acceptance notes

  • Accept set. Every call refused now was already a failure on main: a forward to a path with a hole in it, answered 404 by the router (or, for an {id} the approvals route read literally, 400 FILTER_TOKEN_UNKNOWN). Hence Clause-②: no. One odd shape moves from forwarded to refused: a target written with a scope-less ${name} or {{name}} whose {name} part matched no row field used to be blanked to $ or {} and forwarded; it now stays in place and gets the existing browser-only refusal. No such target exists in this repo, examples/ or hotcrm (measurement above).
  • The console path is not touched. ui/ resolves its own click-time tokens; this PR changes only the server's forward.
  • Ordering of refusals. The record-token refusal runs before the same-origin check and the body checks, so a call that is missing its id is told that first.

Verification

Package tests and typecheck

All readings at head f71c6e23 (base f7c545ad), after pnpm install --frozen-lockfile and pnpm --filter '@objectstack/verify^...' build (which covers the runtime closure).

  • pnpm --filter @objectstack/runtime test: 513 files passed, 8272 tests passed, 19 skipped. Lock verdict command-exit 0.
  • pnpm --filter @objectstack/verify exec vitest run src/action-door-api-forward-denied-subject.test.ts: 5/5 passed.
  • pnpm --filter @objectstack/runtime typecheck: exit 0, check:test-typecheck: OK with its ledger held (27 files / 188 errors / 68 signatures, unchanged).
  • pnpm --filter @objectstack/verify typecheck: exit 0, check:test-typecheck: OK (0 / 0 / 0).
  • eslint --no-inline-config packages/runtime packages/verify: exit 0.
Derived gates
  • node scripts/pm/dispatch-gates.mjs --commands at f71c6e23: 61 commands. 60 ran and exited 0.
  • dispatch-gates --ran: 61 derived, 60 run, 1 NOT-MEASURED, 0 UNRUN.
  • NOT MEASURED: check:dual-build-cjs-loads, reason: exit 3, PREREQUISITE NOT MET (it needs a full repo build; only the verify closure was built). CI runs it.
  • check-plugin-teardown-shape --self-test first answered exit 3 on the shallow clone; after git fetch --depth=1 of the commit it reads, it answered exit 0 (48 cases), and that is the code recorded.
  • Not run locally, as the os-dev scope says: test:repo, the repo-wide lint, and the families outside the derivation. The PR's CI check-runs carry them.
  • No dogfood battery: the title has no !, and Clause-② has no (narrowing) arm.

Human Minimum

None: the seat reviews and lands it.


Generated by Claude Code

…naming recordId when none is given, never forwarded with a hole in its path

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5nNCtq276yJtjqKq8B
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5d7cfe6a5ea3c31301f57af52ab72f3f86e593ce.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5d7cfe6a5ea3c31301f57af52ab72f3f86e593ce

⚠️ 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 5d7cfe6a5ea3c31301f57af52ab72f3f86e593ce → 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#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-02T06:07Z

Head f71c6e23, report 6570345447 (on #28932), claim 6569719667. Clause-②: no, with no arm: no contract review is owed. The seat read the diff itself.

item reading
Shape Draft, base main, Fixes #28932. 4 files (+196 / −7): buildApiActionRequest and two private helpers in runtime/src/action-execution.ts, a unit file, a real-stack verify case and a @objectstack/runtime patch changeset. Metadata-protocol: none. No export, no packages/spec/**, no gate, no new error code (VALIDATION_ERROR / 400, the door's existing refusal).
Rule A target record token the dispatch inputs cannot fill (an unresolved ${ctx.recordId}, an empty recordId counting as none, or a {field} left in the path) is refused 400 naming recordId, or the field the row lacks, before any forward. Both doors (REST /actions and MCP run_action) share it. A field with no value is no longer blanked into the path. {id} with no row fills from the routed id only; the caller-side apiForwardSubjectRecord already supplies {id} when a recordId is given, so a denied subject forwards as before.
Premise Every record-token api target in this repo names {id} alone (9 sys_approval_request, 2 sys_api_key, 1 dev console page; 2 datasource actions use ${ctx.recordId}); examples/ and hotcrm have none. So the no-blanking change reaches no shipped target.
Evidence Unit: 4 red on base, 73/73 on head. Real stack: the card's repro (404 ENDPOINT_NOT_FOUND) on base, 400 on head; the unknown-id control still answers 404 REQUEST_NOT_FOUND and nothing reaches the decision route. Ablation red on both, restore byte-clean. Full runtime suite (8272), typecheck and lint for runtime and verify, 60/61 derived gates (check:dual-build-cjs-loads left to CI). CI on head is running; auto-merge waits for it.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with 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/m tests tooling

Projects

None yet

1 participant