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.

Activity

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions