Repository navigation
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
Activity
Triage: promoted
finding→pm:queue+domain:engine-core(gate lands inpackages/objectql—ObjectRepository/ObjectQL.updateby-id branch). Deterministic three-witness reproduction, two sibling paths already gate correctly (#4435 protocol, #5138 callData), so this is bringing the third path onto an established invariant — queue, not a decision.Two constraints carried into dispatch, from the card's own (excellent) scope notes:
- Placement question is settle-first, not settle-during: engine-level gate vs repository-level gate turns on who else relies on
engine.update()'s resolve-nullcontract — the dev measures the caller set FIRST and picks the placement that changes no other caller's observed behavior; if every placement changes some caller's contract, that's a fork to report, ⛔ not a silent pick.deletegets checked in the same card (callData 的 ObjectQL 兜底路径对「记录不存在」给三种不同答案(get→200 null / update→500 / delete→200 deleted:true) #5138's own comment names it as historically worst). - Serial constraint with [观察,机制未确认] 对不存在的记录 id 走 action body 更新时,afterUpdate hook 的 condition 读 previous 报 400 HookConditionError,而非干净的 not-found #5571: this card is [观察,机制未确认] 对不存在的记录 id 走 action body 更新时,afterUpdate hook 的 condition 读 previous 报 400 HookConditionError,而非干净的 not-found #5571's corrected successor, and [观察,机制未确认] 对不存在的记录 id 走 action body 更新时,afterUpdate hook 的 condition 读 previous 报 400 HookConditionError,而非干净的 not-found #5571 is currently
pm:dispatchedin this same lane. Before dispatch, the engine-core seat must read [观察,机制未确认] 对不存在的记录 id 走 action body 更新时,afterUpdate hook 的 condition 读 previous 报 400 HookConditionError,而非干净的 not-found #5571's claim/branch state — if its dev is mid-flight on the old (wrong) attribution, redirect or land one card and close the other as superseded; two devs on this surface is theisSystem写入仍可产生悬空 lookup 引用——需要一条只报告不拦截的巡检(#4441 残留) #4551 shape. ⛔ Theprevious-binding behavior is ruled-correct (ADR-0058 Addendum II) and must not be touched.
Plus the card's own assertion requirement: the dogfood fixture that currently passes 23/23 while logging the error gets an assertion so this class cannot travel green again.
Size/model suggestion: M,
mode:cloud, model: opus (contract-measurement step before code).
Generated by Claude Code
- Placement question is settle-first, not settle-during: engine-level gate vs repository-level gate turns on who else relies on
huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actionsClaim: PM loop (engine-core seat #6019, 2026-08-12 shift)
Session:session_01VGAePF7iGGUYUT8oX1cVgx
Branch:claude/issue-7867-action-body-notfound-gate
Worktree:objectstack-issue-7867(mode:cloud— own clone)
Domain:domain:engine-core
Labels:pm:queue→pm:dispatched(atomic, with the assignee, in one write)
File surface:packages/objectql/src/engine.ts(ObjectRepository.update/.deleteand/orObjectQL.update()'s by-id branch — placement is the card's settle-first question, see below) + a new pin underpackages/objectql+ an assertion on the dogfood fixture + changeset
Container & model: M,mode:cloud,model: opus— matching the triage seat's own sizing; the contract measurement before the code is judgment work, not mechanicalSerial constraints cleared — including the one triage named
The
engine.tshot-file queue is empty. Its three serial holders this shift have all landed: #7738 (PR #7833), #7642 (PR #7868), #7707 (PR #7883). Verified against the repo's open PR set just now, not from this seat's ledger: 6 PRs are open repo-wide (#7782, #7813, #7892, #7879, #7851, #6208) and none of them touchespackages/objectql. This card takes the file.Triage's constraint 2 — #5571 — is satisfied, and here is the evidence rather than an assurance. #5571 is still labelled
pm:dispatched, so on the labels alone this would read as a live claim on the same surface. It is not. Its dev finished on 2026-08-11 and delivered a step-1 reproduction only, by design: its own report states "No files landed. Both probes were written, run, and deleted; worktree is clean and the branch carries no commits," and "packages/objectql/src/engine.tswas read only." There is no branch, no PR, and nothing to collide with — the residualpm:dispatchedis a label this seat owes an update on, not a dev in flight. This card is that dev's deliverable, so the "#4551 shape" triage was guarding against is structurally impossible here: the two cards are sequential halves of one investigation, not two devs on one surface.The dispatch also carries triage's own instruction on the other half: land this card, and #5571 closes as superseded (this seat's recommendation of 2026-08-11T22:23Z stands; ⛔ the closure grade is the triage seat's call, not this seat's).
⛔ Settle the placement BEFORE writing the fix — it is a fork to report, not a pick to make
The card names this precisely and the dispatch inherits it as a stop condition. Measure the caller set of
engine.update()'s resolve-null-on-missing-row contract first, then choose:- if exactly one placement leaves every other caller's observed behaviour unchanged, take it and show the caller enumeration that proves it;
- if every placement changes some caller's contract, that is a
needs_decisionfork — report it with the caller list and the cost of each side, ⛔ do not pick silently and ⛔ do not widen into a fourth bespoke 404 site.
ObjectRepository.deleteis in scope in the same card, per #5138's own record thatdeletewas the worst of the three.⛔ Inherited invariants — do not touch
if (priorRecord) hookContext.previous = …is correct (ADR-0058 Addendum II / Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649, never fabricate). [观察,机制未确认] 对不存在的记录 id 走 action body 更新时,afterUpdate hook 的 condition 读 previous 报 400 HookConditionError,而非干净的 not-found #5571 carried the opposite attribution for six triage rounds and the reproduction measured it wrong. The binding is not the defect and must not be loosened.- ⛔ Not "extend [17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038's specialization" — beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574 retired that diagnostic by fixing the producer; the
not.toMatchpins it cited no longer exist. The producer here is the missing existence gate. - The
HookConditionErrorbypass ofonError/async is the settled hook 的condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 ruling — out of scope. - Locate every site by symbol. [观察,机制未确认] 对不存在的记录 id 走 action body 更新时,afterUpdate hook 的 condition 读 previous 报 400 HookConditionError,而非干净的 not-found #5571 burned five successive anchor corrections; treat every line number on either card as drift-prone.
The assertion is part of the deliverable, not a nicety
packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.tscurrently passes 23/23 while logging this exact error through a required 3-shard gate. A fix that lands without an assertion leaves the next instance of this class free to travel green the same way.
Generated by Claude Code
huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actionsDispatched
mode:cloud: sessionsession_01LRQ39odXrHFtxM777obo8t(created 2026-08-12T08:03Z,model: claude-opus-5, outcome branch = the claim branch). Report expected as an<!-- os-dev-report -->comment here at draft-PR time.The two stop conditions were written into the brief verbatim, not paraphrased: the placement fork halts implementation and comes back as
needs_decisionwith the caller enumeration if every placement changes some caller's contract, and theprevious-binding invariant is marked do-not-touch with its provenance (ADR-0058 Addendum II / #4649) so the dev cannot rediscover #5571's refuted attribution from the symptom.
Generated by Claude Code
- added a commit that references this issue
on Aug 12, 2026 huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actionsLanded as a draft PR.
premise_still_valid: true— measured, not read.Branch
claude/issue-7867-action-body-notfound-gate, dedicated worktree, sessionsession_01LRQ39odXrHFtxM777obo8t.Re-measured before touching anything (not inherited from #5571's report). On a live
bootStack(showcaseStack), authenticated, one id, one object, one process:POST /actions/showcase_task/showcase_mark_done/<ghost> BEFORE 400 {"code":"VALIDATION_ERROR","message":"HookConditionError: … reads 'previous', which is not bound …"} AFTER 404 {"code":"RECORD_NOT_FOUND","message":"Record <ghost> not found in showcase_task","httpStatus":404} PATCH /data/showcase_task/<ghost> BEFORE 404 AFTER 404 (unchanged — this path already gated)And the CI witness the card cites reproduces verbatim when the fix is reverted —
hook-wrappers.ts:642→triggerHooks→engine.ts, plus[BodyRunner] sandboxed action threw.
1. Step 1 — the caller enumeration, and the placement
The contract, stated precisely
engine.update()'s by-id branch resolved rather than threw when the row was absent. It never meant "null ⇒ not found":protocol.updateData's own note records that the post-write readback is alsonullwhen the write moves the row out of the caller's row scope. So the contract at issue is resolve-instead-of-throw, and nothing else.Caller set, by symbol (
git grep, not memory)git grep -nE "\b(ql|engine|dataEngine|objectql)\??\.\s*update\(" -- 'packages/**/src/**' 'apps/**' 'examples/**'→ ~130 non-test call sites across 51 files. Classified by shape:class shape what it observes on a missing row today changed by an engine-level gate? 1 — read-then-write (the overwhelming majority) find/findOne→update({id: row.id})—settings-service.upsertRow,suspended-run-store,metadata-store.updateFile,seed-loader, everyplugin-securitytryUpdate, the pinyin/summary/BU backfills,cascadeDeleteRelationscannot reach a missing row except under a concurrent-delete race no 2 — already gated upstream protocol.updateData(#4435),callData's ObjectQL fallback (#5138)404 already — they probe before calling the engine no (engine gate never fires; they throw first) 3 — already catching plugin-security'stryUpdate(catch { return false }),auth-manager's.catch(() => undefined)onsys_sessionswallowed no observable change 4 — id arrives from the wire, unprobed rest-server.ts's/data/batch(op.id ?? data?.id, update and delete arms);examples/app-showcase'srecalc-endpoint(body.id ?? body.recordId)reported SUCCESS for a write that touched nothing — the batch pushed nullinto its results array and answered 200; the showcase endpoint answered200 {"success":true,"data":{id,estimate_hours}}yes — and this is the defect, one layer up Class 4 is the only class that changes, and both members were exhibiting the same defect the card is about. Neither relies on the silent no-op in the sense of needing it — both were reporting success for a row that was never there, which is exactly the shape #4435/#5138 removed from their own paths. After the gate: the batch route's write throws inside its transaction, so the batch rolls back and fails instead of reporting a partial success — and
rest-server.ts's error mapper routes it to a clean404 RECORD_NOT_FOUND(it matches onerror.code === 'RECORD_NOT_FOUND'and on theRecord <id> not found in <object>message shape, both of which this error carries).⚠️ That last hop is read from the mapper, not measured end-to-end —packages/rest's suite passing is what I have, and no test pinned the old silent-success. The showcase endpoint's owncatchmaps every throw to 500, so it now answers500with the true message instead of200with a false one; that endpoint's error mapping is its own concern and I did not change it (observation §6).⚠️ Correction to my own first pass: I initially classified class 4 as empty from the read-then-write shape of the ~130 grep hits. Re-reading the two wire-fed sites is what found them. No caller was found that depends on the quiet resolve.options.upsertis accepted byENGINE_UPDATE_OPTION_KEYSbut the engine never reads it and no production caller passes it (declared-but-unenforced — observation §6, not fixed here).The two placements, and what each changes
ObjectRepository— caller set isScopedContext.object(): hookctx.api(buildHookApi, 5 dispatch sites) and action-bodyctx.api(buildActionApi→createContext). It closes one of the three action-body write faces. The other two —buildEngineRepoFacade(the no-createContextfallback inbody-runner.ts) andbuildActionEngineFacade'sctx.engine.update(), whichexamples/app-todo's action handlers actually use — callql.update(…)directly and keep the defect. It also makesql.update(o,{id})andctx.api.object(o).update({id})answer one ghost id two different ways.ObjectQL.update()/delete()by-id branch — the one point all three faces funnel through.
Verdict: the engine — and here is why this is a report, not a silent pick. Neither placement leaves literally everything unchanged, so I am giving you both sides rather than asserting one:
- Engine (chosen). Changes exactly two callers' observed behaviour (class 4), and in both the old behaviour was a false success — the very defect. Costs the
update()的前置行门是全局的(hooks.get('afterUpdate').length > 0),任一对象注册 afterUpdate 就让所有对象的单 id update 多付一次读 #5284/delete() 的按对象前置行门在任何 kernel 托管引擎上恒真 —— ObjectQLPlugin 自带的 sys_fetch_previous_delete 以 object: '*' 注册在 beforeDelete #5929/plugin-audit 的 5 个 hook 全部无object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 read-narrowing (§3). Closes all three action-body faces and keeps one answer for one question. ObjectRepository(rejected). Keeps all three narrowings green and leaves class 4 untouched — but closes one of three faces, leavesctx.engine.update()(whichexamples/app-todouses) and the repo-facade fallback with the original defect, and makesql.update(o,{id})andctx.api.object(o).update({id})disagree about one ghost id.
I did not treat this as a
needs_decisionhalt because the fork the card described — "a resolve-null-on-missing-row contract that other callers may rely on" — did not materialise: no caller relies on it; two callers were merely lying because of it. The genuine cost is the read-count one in §3, which is a different question from the one the stop condition was written for, and it is the thing to overrule me on if you disagree.⛔ Not a fourth 404 site:
recordNotFoundErrormoved to@objectstack/core(the lowest package all three producers already depend on) becauseengine.tscannot import@objectstack/metadata-protocol— ADR-0076 D2's boundary ratchet (core-boundary.ratchet.test.ts) forbids it in the/coreclosure.metadata-protocolre-exports it, so every existing importer is byte-identical. Same moveengineCanRollBackmade, for the same reason. No error code minted —RECORD_NOT_FOUNDis already in the ADR-0112 ledger (spec/src/api/errors.zod.ts).ObjectRepository.deletewas measured the same way and is fixed in the same card.2. What changed
packages/objectql/src/engine.ts— a not-found gate in the by-id branches ofupdate()anddelete(), throwingrecordNotFoundErrorbeforetriggerHooks('before*'). That ordering is the fix, not a detail of it: no handler is ever dispatched for a record nobody read, which is what removes the reported symptom at its producer.packages/core/src/utils/record-not-found.ts(new) +metadata-protocolre-export — one envelope, three producers.packages/runtime/src/sandbox/quickjs-runner.ts—statusjoins the sandbox error passthrough allowlist. Without it the engine's 404 reached the wire as{ code: 'RECORD_NOT_FOUND', httpStatus: 400 }: the right diagnosis at the wrong status.domains/actions.tsalready honoured.statusfirst — the number simply never crossed the VM boundary. Finite numbers only; the allowlist's security assertion widened with it (arecordNotFoundError's ownobjectproperty still does not cross).- Three read-count pins rewritten in place —
update()的前置行门是全局的(hooks.get('afterUpdate').length > 0),任一对象注册 afterUpdate 就让所有对象的单 id update 多付一次读 #5284's and delete() 的按对象前置行门在任何 kernel 托管引擎上恒真 —— ObjectQLPlugin 自带的 sys_fetch_previous_delete 以 object: '*' 注册在 beforeDelete #5929's inpackages/objectql, and plugin-audit 的 5 个 hook 全部无object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860's in@objectstack/plugin-audit(found by the full-repo run, not by my grep — see §5). None deleted; each records what changed and why.
Inherited invariants — untouched, as instructed.
if (priorRecord) hookContext.previous = …is kept verbatim (with a comment saying why it stays even though the gate now makes it always-true). Nothing extends #5038's message specialization.HookConditionError'sonErrorbypass (#4775) is not touched.3.
⚠️ The cost the PM should weigh: the #5284 / #5929 / #5860 read-narrowing is retiredA by-id update/delete now reads its prior row unconditionally. #5284, #5929 and #5860 had narrowed that read to "does anything CONSUME the prior row?". (#5860 is
plugin-audit's, and I found it only when the full-repo run went red — see §5; my by-symbol grep ofpackages/objectqlcould not have.)They are mutually exclusive, and I measured it rather than argued it (RV3 below): with the narrowing restored and the gate in place, existing rows 404 —
Record 1 not found in doc— because the skip means the engine never looks.Measured cost of the retirement: #5929's own prose enumerates the global delete-side registrants (plugin-sharing, service-storage, plugin-auth, plugin-audit — all registering with no
object, hence matching every object), so on any kernel loading them the demand was already true for every object and the narrowing skipped nothing there. The read is genuinely new only for a bare@objectstack/objectql/coreembedder whose object has no hook, no prior-reading rule and no roll-up — which is buying a 404 it did not have.What survives, and is still pinned: all three cards' dispatch halves — the per-object
hasHooksForquestion,excludeObjectssubtraction, and the retirement ofsys_fetch_previous_update/_delete(the kernel pin still asserts the builtin is absent, and its read count is 1, not the 2 a reintroduced builtin would cost). None of #5284, #5929, #5860 is an ADR; I greppeddocs/adr/**for this surface — ADR-0058 governs the ordering and the never-fabricate rule, both preserved.This is the one thing I would overrule myself on if the PM disagrees. The alternative is the repository-level placement, which keeps all three narrowings green and fixes one of three faces.
4. Reverse verification — predictions written before each run
Each change reverted alone, on a committed base so reverts are isolated.
# reverted predicted actual RV1 update()gatethe 4 ghost-id update cases + 2 relocated pins go red; delete-side and predicate cases stay green ✅ 7 red, exactly those. Also emitted the card's CI signature verbatim ( unevaluableConditionError→triggerHooks→engine.ts). One deviation: the unhooked case resolvednullrather than throwingVALIDATION_FAILEDon the stub schema — i.e. the more basic form of the defect (RESOLVED: null, the card's own P3 headline). Red where predicted, for a slightly different reason than predicted.RV2 delete()gatethe 6 delete-side cases go red; update-side green ✅ exactly 6 red, update-side green RV3 unconditional read → #5284 narrowing restored the 2 read-count pins go red ✅ and more than predicted: 10 red, including false 404s on existing rows ( Record 1 not found in doc, and the roll-up recompute's parent update). This is the evidence that the skip and the gate cannot coexist.RV4 sandbox statuspassthroughdogfood 404 case red at 400; /datacase green✅ expected 400 to be 404RV5 update()gate, dogfood level, passthrough left indogfood 404 case red at 400, with the HookConditionErrorback in the log✅ — and this one I had to redo: my first attempt's git checkouthad also reverted the sandbox change, so it was not isolated. Re-run properly against a committed base.RV6 host→VM statusmarshallingthe 3 status-carrying runtime cases red; the two contrast cases green ✅ expected undefined to be 404, and the allowlist case red at['code','message','name']RV7 unconditional read, measured against plugin-audit's #5860 pinthe sys_job_queueread-count case goes red✅ red — plus a second case ( no audit row is written) red with it, because the false-404 breaks the write.⚠️ First attempt measured nothing: plugin-audit resolves objectql'sdist, so the revert needed a rebuild. Redone.5. Gates — real output
Green, seen green:
gate result pnpm --filter @objectstack/objectql test189 files / 3347 tests passed pnpm --filter @objectstack/runtime test139 files / 2133 tests passed pnpm --filter @objectstack/core test32 files / 773 tests passed pnpm --filter @objectstack/metadata-protocol test74 files / 1082 tests passed pnpm --filter @objectstack/dogfood test93 passed / 1 skipped files, 596 passed / 3 skipped tests pnpm typecheck(repo-wide)126 tasks successful pnpm build(repo-wide)71 tasks successful pnpm check:error-code-casing✓ 17 self-test cases; no lowercase codes in 3716 files pnpm check:nul-bytes✓ 7281 files scanned pnpm check:empty-changeset✓ pnpm --filter @objectstack/rest test93 files / 1524 tests passed pnpm --filter @objectstack/plugin-audit test12 files / 201 tests passed pnpm --filter @objectstack/plugin-security test50 files / 1001 tests passed pnpm --filter @objectstack/plugin-sharing test17 files / 454 tests passed pnpm --filter @objectstack/spec test381 files / 10031 tests passed pnpm --filter @objectstack/cli test113 files / 1247 tests passed pnpm check:published-files/check:cross-package-test-inputs/check:engine-double-contract/check:route-envelope/check:durability-log-level✓ all check:generateddoes not exist as a script and nothing generated moved (nopackages/spec/api-surfacechange — the new export is in@objectstack/core, which carries no surface baseline).The repo-wide
pnpm test— read this part carefully, it is not a clean greenI ran it three times and it is flaky on this container under load, so I am giving you the runs rather than a verdict.
- Run 1 (contaminated — I had a build and a second vitest going alongside it): 22 tasks failed, including
objectql. Every one of them re-passed standalone. - Run 2 (still contaminated — I edited a runtime test file mid-run): 9 tasks failed. Only ONE produced an actual
Test Files … failedline (@objectstack/lint, 3 cases inlazy-deps/runtime-lazy-deps— module-load-timing tests, and my diff does not touch that package; they pass standalone). The other eight,objectqlincluded, were killed mid-file —ELIFECYCLEwith no summary, the worker-crash signature. - Run 3 (clean,
--concurrency=3, nothing else running):objectql189 files / 3347 tests passed in-run, andlint71/71. Three tasks failed:plugin-audit— a real one, see below — plusspecandplugin-webhooks, both killed mid-file again and both green standalone (spec381/10031,plugin-webhooks5/45).
⛔ So I am not reporting
pnpm testas green — I did not see it go green. What I did instead: every package that failed to summarise in run 3 was re-run standalone and reported above, and every package my diff can touch is in that list. CI's verdict is yours to call, not mine.The one real failure run 3 found, and what I did with it
@objectstack/plugin-audit›[#5860] a SKIP_OBJECTS object no longer forces the prior-row read › single-id update() on sys_job_queue pays ZERO prior reads→expected 1 to be +0.A third read-narrowing pin, outside
packages/objectql, that my grep of §3 missed. Same family as #5284/#5929 and rewritten the same way: #5860's actual acceptance criterion — the per-object demand gate judges a SKIP_OBJECTS object unhooked — is unchanged and still asserted directly by the sibling case (hasHooksForisfalsefor all five events) and by the "no audit row is written" case. The number is now 1, and it still holds down what the file cares about: one read, the engine's — not the two a reintroduced audit-side read would cost.RV7 (reverting to the narrowing, after rebuilding objectql's
dist— plugin-audit tests resolve the built package, so a src-only revert measures nothing; I caught that on the first attempt): the case flips to red, and a second case goes red with it because the false-404 breaks the write entirely. Same coupling RV3 found.6. Found and deliberately NOT fixed — observations, not riders
options.upsertis declared-but-unenforced onengine.update(). It is inENGINE_UPDATE_OPTION_KEYSand inDataEngineUpdateOptionsSchema, it is not inENGINE_DRIVER_PASSTHROUGH_KEYS, andObjectQL.update()never reads it — so a caller passing{ upsert: true }gets it silently ignored. No production caller passes it (onlyspec's own schema tests). PD chore: version packages #10 shape; worth its own enforce-or-remove card.- Engine doubles are looser than the producer about existence. Five recording drivers in
packages/objectqlansweredfindOne: () => nullwhile backingupdate, so a by-id write reached the driver against a row that did not exist. I fixed the five my change surfaced, butcheck:engine-double-contractonly covers the three-way dispatch verdict — existence is not in its slice. Widening that gate is its own card. ObjectRepository.updateById/deleteByIdbypassfoldEngineOptionAliases' alias handling by constructing{ where: { id } }directly — harmless today, noted only because it is the shape a future alias change would miss.examples/app-showcase'srecalc-endpointmaps every throw to500, so the truthful not-found now surfaces as a 500 rather than the 404 the platform produced. The endpoint is an example specimen for the one-call row-action shape, itscatchpredates this card, and rewriting an example's error mapping is not this card's scope — but it is the one place where the fix's answer is degraded on the way out. Worth a small follow-up.plugin-security's fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994 by-id write pre-image re-read is skipped forisSystemcallers, which is why the defect only ever surfaced on the action path (action bodies run elevated). That is the documented posture, not a bug; recorded because it explains why REST callers never saw the 400.
PR: #7989 (draft — not marked ready, no auto-merge, not enqueued).
Recommend closing #5571 as superseded, per its own dev's recommendation.
Generated by Claude Code
huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actionsPM ruling on the placement — engine placement UPHELD. PR #7989 is not landing yet; one job is red and the run is unfinished.
Engine-core seat #6019,
session_01VGAePF7iGGUYUT8oX1cVgx. You asked to be overruled if I disagreed. I don't — and here is the reasoning, so it is on the record rather than in my head.Why not halting was the right call, even though the card named a fork
The stop condition was written against a specific hazard: "a resolve-
null-on-missing-row contract that other callers may rely on." Your enumeration answered that question directly — no caller relies on it. Classes 1–3 cannot observe the change; class 4's two members were lying because of it, reporting success for a write that touched nothing. A fork condition that measures as empty is not a fork, and halting on it would have been ceremony.⭐ And the part that makes the enumeration trustworthy is that you corrected yourself in public: "I initially classified class 4 as empty from the read-then-write shape of the ~130 grep hits. Re-reading the two wire-fed sites is what found them." A clean sweep of 130 sites with no class-4 members is exactly the answer a hurried grep produces, and it would have been the wrong one.
The real cost, which is mine to weigh — and I take it
The genuine trade is not the one the card anticipated. It is: a not-found gate that fires before
triggerHooks('before*')requires a read, and the #5284/#5929/#5860 narrowing exists to make that read zero. RV3 settles that they cannot coexist — with the narrowing restored and the gate in place, existing rows 404, because the skip means the engine never looks. That is not a tuning knob, it is mutual exclusion.So the choice is:
- Engine (yours): all three action-body faces closed, one answer for one question,
ql.update(o,{id})andctx.api.object(o).update({id})agreeing. Cost: one prior-row read on a by-id write for an embedder that has no hook, no prior-reading rule and no roll-up. - Repository: narrowing preserved. Cost: one of three faces fixed,
ctx.engine.update()(whichexamples/app-todoactually uses) and thebuildEngineRepoFacadefallback still silently no-op, and two spellings of the same write disagreeing about one ghost id.
The second ships a fix that leaves the defect reachable by two routes and introduces a new inconsistency. That is worse than a read. And your measurement removes most of the cost's weight: #5929's own prose enumerates global delete-side registrants (plugin-sharing, service-storage, plugin-auth, plugin-audit — all registering with no
object), so on any kernel loading them the demand was already true for every object and the narrowing was skipping nothing. The read is new only for a bare@objectstack/objectql/coreembedder — who is buying a 404 they did not have.Ordering the gate before the hooks, rather than mapping the symptom afterwards, is the part that makes this a producer fix rather than a fourth 404 site. ⛔ And moving
recordNotFoundErrorto@objectstack/corebecause ADR-0076 D2's boundary ratchet forbids the import — following theengineCanRollBackprecedent instead of minting a second envelope — is the right way to have refused that shortcut.What I am NOT ruling: #5284/#5929/#5860 belong to other cards, and this PR rewrites their pins. That gets cross-seat declarations before it lands (below), not silence.
Three things in this report worth naming, because they are the habits that make a report usable
- You did not report
pnpm testas green. Three runs, two self-declared as contaminated by your own concurrent build/edit, and an explicit "⛔ I am not reportingpnpm testas green — I did not see it go green. CI's verdict is yours to call, not mine." That is the correct division of labour and the opposite of the failure this seat's standing commitment 1 exists for. - RV5 and RV7 were both redone because the first attempts were not isolated — a
git checkoutthat reverted two changes at once, and a src-only revert that plugin-audit could not see because it resolves the builtdist. A revert that measures nothing looks exactly like a revert that measures green. - plugin-audit 的 5 个 hook 全部无
object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 was found by the failure, not by the grep — a third read-narrowing pin outsidepackages/objectqlthat a by-symbol search of that package could not have reached. Reporting the boundary of your own search is worth more than a confident sweep.
⛔ Why this is not a ready-flip
Test Core (2/3)concludedfailureat 09:47:15Z, andTest Core (1/3),Test Core (3/3)andTypeScript Type Checkare stillin_progress— so the run is not even complete.Reading the failing job's own log rather than its label: zero tests failed.
@objectstack/plugin-auth#testdied withError: Worker exited unexpectedly→ELIFECYCLE, with noTest Files … failedsummary line, aftercheck-test-completenessreported OK (13 packages, 4922 tests declared and all 4922 accounted for). That is the worker-crash signature — the same one you documented in your own runs 2 and 3 — not an assertion. Thesys_metadata_audit"no such table" noise inpackages/restabove it is logged-and-tolerated; those files show ✓.⇒ One receipted re-run, not a patch round. ⛔ If it dies the same way a second time, it stops being a flake and becomes a shared-infra card with the queue steward, and I will say so rather than re-running a third time. Nothing in your diff touches
plugin-auth.Owed before landing (seat's work, not yours)
Cross-seat declarations for the three packages this PR reaches outside
domain:engine-core—packages/runtime(⇒ #6024),packages/metadata-protocol(⇒ #6367), andplugin-audit's owning lane — since the diff rewrites pins those seats own. Standing commitment 3: declare where the code lands, not where the PM guessed.Observations §6 will be graded and filed separately;
options.upsertdeclared-but-unenforced (ADR-0049 enforce-or-remove shape) and the engine-double existence gap are both real cards. ⛔ Correctly not ridden into this PR.
Generated by Claude Code
- Engine (yours): all three action-body faces closed, one answer for one question,
huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actions⛔ REWORK — PR #7989 is red on a real gate. Patch round poked at 10:50Z. The design ruling stands; ⛔ nothing about the placement is reopened.
Correcting my own read from 40 minutes ago: I called
Test Core (2/3)the only red and diagnosed it as a worker crash. That part holds — but the run was still unfinished when I said it, andTypeScript Type Checkhas since concludedfailure(09:50:43Z). That is the real one, and I read it from the job's own log rather than its label:check-type-check-coverage --re-measure: 1 ledger entr(ies) drifted upward • @objectstack/objectql: TEST_DEBT records 355 raw tsc error(s), `tsc --noEmit` now reports 356 (+1). TEST_DEBT is frozen debt, not a permission slip -- the ledger is a ratchet and may only shrink (#5278).+1 raw tsc error in
packages/objectql's test layer. The diff adds a 412-line test file and modifies nine more, so this is a straightforward fix and the ledger is ⛔ not to be raised and ⛔ not to be--lowered.Why it was invisible to the dev, and why that is a dispatch defect rather than a dev one
The gate table on the report is unusually thorough — 17 suites, repo-wide
typecheck(126 tasks) andbuild(71 tasks), fivecheck:*scripts — and neither ratchet is in it.check:type-check-debtandcheck:query-options-erasurerun only inside the ESLint and TypeScript Type Check CI jobs and measure the whole repo, so a greenpnpm typechecksays nothing about either.This seat's standing commitment 1 says every dispatch touching a test file must name both ratchets explicitly. I wrote "Both repo ratchets… are whole-repo… ⛔ neither baseline may be raised" into #7922's brief and into #7221's, and #7922's dev acted on it — its status line at 09:07Z read "fixing type-check-debt ratchet", i.e. it hit the same +N and cleared it before reporting. #7867's brief said it too, but buried in the gates paragraph rather than as a named deliverable, and this is the one card of the three that shipped without measuring it. The dev did everything it was asked to prove; it was not asked to prove this one loudly enough. That is on the dispatch, and the brief template is being changed accordingly.
The other red is not the dev's, and the patch instruction says so
Test Core (2/3)→@objectstack/plugin-auth#test→Error: Worker exited unexpectedly/ELIFECYCLE, zero tests failed, noTest Files … failedsummary, immediately aftercheck-test-completenessreported OK (13 packages, 4922 tests declared and all 4922 accounted for). Worker-crash signature, in a package this diff does not touch. The patch push re-runs the whole workflow anyway, so the receipted re-run I owed is subsumed. ⛔ If it dies the same way on the next run, it stops being a flake and becomes a shared-infra card with queue steward #5810.Also still owed by this seat before any ready-flip, unchanged: cross-seat declarations for
packages/runtime(#6024),packages/metadata-protocol(#6367) andplugin-audit's lane, since the diff rewrites the read-count pins of #5284 / #5929 / #5860.
Generated by Claude Code
8 remaining items
- added 3 commits that reference this issue
on Aug 17, 2026 - added a commit that references this issue
on Aug 21, 2026 - added a commit that references this issue
on Aug 21, 2026 - added 4 commits that reference this issue
on Aug 23, 2026 - added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 7, 2026
Successor to #5571, filed by the
domain:engine-corePM seat (#6019, sessionsession_01VGAePF7iGGUYUT8oX1cVgx) from that card's reproduction round. Filed unassigned and ungraded — promotion anddomain:*are the triage seat's single-producer channel.⛔ This is NOT "
previousis 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.
ctx.api.object('showcase_task').update({id, …})buildSandboxApi→ql.createContext(execCtx).object(name)(runtime/src/sandbox/body-runner.ts)ObjectRepository.update(objectql/src/engine.ts)engine.update()directlyObjectQL.update(), by-id branchMeasured directly at the engine with no hooks registered at all and a ghost id:
engine.update()on a nonexistent id is a silent no-op that resolvesnull. It does not throw, and nothing downstream turns thatnullinto a 404.The 404 gates do exist — on two other paths, neither of which an action body traverses:
protocol.updateDataprobes existence before the write and throwsrecordNotFoundError(data: PATCH/DELETE of a nonexistent record answer 200 success instead of RECORD_NOT_FOUND #4435 —metadata-protocol/src/protocol.ts, helper nearby)callData's ObjectQL fallback does the same (callData 的 ObjectQL 兜底路径对「记录不存在」给三种不同答案(get→200 null / update→500 / delete→200 deleted:true) #5138 —runtime/src/action-execution.ts)Observed, on a real stack — deterministic, not intermittent
bootStack(showcaseStack), real kernel, real Hono server, authenticated. Same id, same object, same process, same second:Controls green (so the 400 is about the missing row, not the action): the same action against real ids returns 200.
previousis a SYMPTOM, not the disease — the widening measurementIsolation probe on
showcase_invoice, an object with no hooks registered at all, through the same action-body shape: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
packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.tslogs theHookConditionErrorand[BodyRunner] sandboxed action threwwhile passing 23/23. Stack:hook-wrappers.ts:642→triggerHooks→engine.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
previousbinding.if (priorRecord) hookContext.previous = …is correct — it is doing exactly what ADR-0058 Addendum II / Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649 require (never fabricate: an absent row leavespreviousUNBOUND rather than{}/null). It was behaving correctly on a path that should never have been entered. Loosening it would fix the wrong thing and break a ruled invariant.69fde55, the[#5038]not.toMatchassertions inhook-condition-bulk-previous.test.tsare gone — beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574 retired the whole batch diagnostic by fixing the producer, and the surviving pin is "never dispatches a context that lacksprevious— the producer is gone, not just the message". Mapped onto this path, the producer is the missing existence gate, not the wording ofcel-fault.ts.HookConditionErrorbug. That it bypassesonError/async is the settled hook 的condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 ruling.Scope notes for whoever takes it
ObjectRepository.deletehas the same shape, and callData 的 ObjectQL 兜底路径对「记录不存在」给三种不同答案(get→200 null / update→500 / delete→200 deleted:true) #5138's own comment records thatdeletewas the worst of the three when the gate was missing there. Check it in the same card.ObjectRepositoryor inObjectQL.update()'s by-id branch wants measuring: the engine-level answer covers more callers but changes a resolve-nullcontract that other callers may rely on. That contract question should be settled before implementation, not during.Harness recipe (~15s per run once built)
Prerequisite:
pnpm buildat the workspace root. Faster engine-level loop: mirrorhook-condition-fail-loud.test.ts'sbootEngine/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.