Skip to content

Action-body writes have no not-found gate: ctx.api.object().update() against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867

Description

@huangyiirene

Successor to #5571, filed by the domain:engine-core PM seat (#6019, session session_01VGAePF7iGGUYUT8oX1cVgx) from that card's reproduction round. Filed unassigned and ungraded — promotion and domain:* are the triage seat's single-producer channel.

This is NOT "previous is unbound." #5571 carried that attribution for six triage rounds and the reproduction measured it wrong. See "What this is not" below before scoping any work.

The defect

Nothing on the action-body write path ever asks whether the target row exists.

step symbol not-found handling
action body ctx.api.object('showcase_task').update({id, …})
sandbox api buildSandboxApiql.createContext(execCtx).object(name) (runtime/src/sandbox/body-runner.ts)
repo ObjectRepository.update (objectql/src/engine.ts) calls engine.update() directly
engine ObjectQL.update(), by-id branch no not-found gate anywhere

Measured directly at the engine with no hooks registered at all and a ghost id:

[A no-hooks nonexistent id] -> RESOLVED: null

engine.update() on a nonexistent id is a silent no-op that resolves null. It does not throw, and nothing downstream turns that null into a 404.

The 404 gates do exist — on two other paths, neither of which an action body traverses:

Observed, on a real stack — deterministic, not intermittent

bootStack(showcaseStack), real kernel, real Hono server, authenticated. Same id, same object, same process, same second:

POST /actions/showcase_task/showcase_mark_done/<GHOST>
  -> 400 VALIDATION_ERROR  "HookConditionError: … reads 'previous', which is not bound …"

PATCH /data/showcase_task/<GHOST>
  -> 404 RECORD_NOT_FOUND  "Record <GHOST> not found in showcase_task"

Controls green (so the 400 is about the missing row, not the action): the same action against real ids returns 200.

previous is a SYMPTOM, not the disease — the widening measurement

Isolation probe on showcase_invoice, an object with no hooks registered at all, through the same action-body shape:

[P3  action ghost id, UNhooked object] -> 400 VALIDATION_FAILED "Issued On is required"
[P3b rest PATCH ghost invoice]         -> 404 RECORD_NOT_FOUND

No hooks, no previous, same defect: the ghost-id write sails past the absent gate and dies on whatever the pipeline complains about first — here required-field validation, because with no prior row a PATCH is validated as if it were a whole record.

The 400 class varies with the object's declarations. The missing 404 is the constant.

Three independent witnesses

  1. 2026-08-05 — the original intermittent sighting that opened [观察,机制未确认] 对不存在的记录 id 走 action body 更新时,afterUpdate hook 的 condition 读 previous 报 400 HookConditionError,而非干净的 not-found #5571.
  2. 2026-08-11 reproduction — deterministic, on the post-beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574/单 id update 把同一行前置状态读了 3 次(engine 前置行门 + sys_fetch_previous_update + plugin-audit captureBefore),且后两次不受任何按对象需求门约束 #5846 surface, with the ordering question answered.
  3. CI, right nowpackages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts logs the HookConditionError and [BodyRunner] sandboxed action threw while passing 23/23. Stack: hook-wrappers.ts:642triggerHooksengine.ts:8526. The defect has been travelling through a required, green, 3-shard gate printing a stack trace, with nothing asserting on it.

⛔ What this is not

Scope notes for whoever takes it

  • ObjectRepository.delete has the same shape, and callData 的 ObjectQL 兜底路径对「记录不存在」给三种不同答案(get→200 null / update→500 / delete→200 deleted:true) #5138's own comment records that delete was the worst of the three when the gate was missing there. Check it in the same card.
  • The fix is a not-found gate on the action-body write path, consistent with the two siblings that already have one — ⛔ not a fourth bespoke 404 site. Whether it belongs in ObjectRepository or in ObjectQL.update()'s by-id branch wants measuring: the engine-level answer covers more callers but changes a resolve-null contract that other callers may rely on. That contract question should be settled before implementation, not during.
  • Whatever lands should carry an assertion — the current dogfood fixture demonstrably tolerates the error in silence.

Harness recipe (~15s per run once built)

stack = await bootStack(showcaseStack);   // ⚠️ NOT { automation: true } — the showcase
                                          // 'rest' connector needs extraPlugins or startup fails
const token = await stack.signIn();       // ⚠️ required since #5519 — anonymous is 401 now
await stack.apiAs(token, 'POST',  `/actions/showcase_task/showcase_mark_done/${GHOST}`, {});  // 400
await stack.apiAs(token, 'PATCH', `/data/showcase_task/${GHOST}`, { done: true });            // 404

Prerequisite: pnpm build at the workspace root. Faster engine-level loop: mirror hook-condition-fail-loud.test.ts's bootEngine/makeStubDriver.

⛔ Locate every site by symbol, not by line number — #5571 burned five successive anchor corrections and its own triage concluded "the line number is not the anchor, the symbol is."

Refs: #5571 (origin, with the full reproduction report) · #4435 · #5138 · #4775 · #5574 · #5846 · ADR-0058 Addendum II.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions