Skip to content

[观察,机制未确认] 对不存在的记录 id 走 action body 更新时,afterUpdate hook 的 condition 读 previous 报 400 HookConditionError,而非干净的 not-found #5571

Closed

Description

@baozhoutao

来源:#5519(PR #5569)复现过程中的偶发观察,dev 按「不 sit on finding / 不 mis-attribute」原则完整记录原始证据但未立单;PM 以观察类立单保存证据,机制未确认,标题只陈述症状。

原始证据(#5519 的真机等价 harness,bootStack(showcaseStack, {automation:true}))

对不存在的 showcase_task id 走 action body 更新时,偶见一次:

400 HookConditionError
showcase_audit_task_completion (afterUpdate, condition 读 previous):
"previous is not bound for this operation — operation aborted"

dev 已排除的候选机制

HookConditionError 刻意绕过 onError/async 是 #4775 的既定裁决,并非缺陷——这条路径不是问题所在。

未确认的候选机制(立单原因)

「目标行不存在 → previous 未绑定 → 整个写被 400 中止,而非干净的 not-found」。dev 未能在隔离环境复现确认,且该机制紧邻 #5037 / #5038 与 ADR-0058 bulk-write 附录的既有裁决面——若按此归因立修复单,须先复现证实。

处置建议

派发时应作为「复现单」而非「修复单」:第一步在隔离 harness 里稳定复现并确认机制,第二步才谈修法(且修法可能落在既有裁决面内,需先读 #5037/#5038 与 ADR-0058 附录)。#5519 的 dev 表示可提供 harness 配方(见 PR #5569 报告的 out_of_scope_findings 段)。

关联:#5519、#4775、#5037、#5038、ADR-0058。

Filed unassigned,无标签,待分诊席判级分域。

Activity

  1. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    分诊:finding + domain:engine-core(域为暂定,按候选机制锚定)。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 7, 2026

    @os-zhuang
    Contributor

    Findings triage round (triage seat Routine, 2026-08-07): hold — repro still owed, so the verdict stands. But the candidate mechanism now has a code-level anchor, and this turns out to sit inside an existing ruling's family rather than next to it.

    Re-read against origin/main (1eb13a0). ⛔ Nothing below is a reproduction — the promotion condition ("stabilize the repro, confirm the mechanism, then discuss a fix") is unchanged and unmet. What changed is that the hypothesis is now falsifiable at a named line instead of being a guess.

    1. Anchor drift — the line in the 08-05 grading has moved

    sys_fetch_previous_update is at packages/objectql/src/plugin.ts:863, not :846. A dispatch that opens :846 lands ~17 lines short, in stampData. Corrected here so the eventual repro/fix issue does not inherit a stale pointer. (plugin.ts last changed at bbdbf287, 2026-08-06 14:23Z, #5998 — loadMetaFromDb, unrelated to this surface.)

    2. The handler's guard shape is consistent with the observation

    // plugin.ts:863
    name: 'sys_fetch_previous_update',
    object: '*', events: ['beforeUpdate'], priority: 5,
    handler: async (hookCtx) => {
      if (hookCtx.input?.id && !hookCtx.previous) {
        const existing = await this.ql!.findOne(hookCtx.object, { where: { id: hookCtx.input.id } });

    The guard tests that an id was supplied. It does not distinguish that from an id that matches no row: when findOne comes back empty there is nothing to bind, so previous stays unbound and any later condition reading it hits the CEL fault path. That is exactly the shape needed to produce the reported 400 rather than a clean not-found — which is what makes the hypothesis worth keeping, and equally what makes it still only a hypothesis. The open question is upstream of this handler and unanswered: why does a write against a non-existent id reach the afterUpdate stage at all, instead of failing not-found first? Until that is reproduced, blaming this handler would be mis-attribution — the handler may be behaving correctly on a path that should never have been entered.

    3. The important find: #5038 already ruled this error wrong — on the sibling path

    The message in the report comes from cel-fault.ts:136-137:

    The <subject> reads 'previous', which is not bound for this operation — the scope holds 'record', plus 'previous' only when the record's prior state is in hand.

    hook-condition-bulk-previous.test.ts carries a [#5038] block titled "the generic CEL fault is never the whole story on this path", which pins (:346-347) that on a batch dispatch the author must not be left holding either generic sentence:

    expect(err.message).not.toMatch(/which this object does not declare/);
    expect(err.message).not.toMatch(/which is not bound for this operation/);
    expect(err.fault).toMatch(/Unknown variable: previous|No such key: previous/);   // raw fault still travels as a fact

    So the principle — when previous is legitimately unavailable, a specific diagnosis is owed, not the generic unbound-root sentence — is already decided and shipped. It was implemented for the batch path. This observation is the same generic sentence surfacing on the single-id-not-found path, which #5038 did not cover.

    That materially changes the shape of the eventual work, in the direction of it being smaller: if the repro confirms the path, the likely fix is extending an existing, ruled, tested specialization to one more case — not opening a new question about previous semantics. It also reinforces the body's original warning to read #5037/#5038/ADR-0058 first: not because a fix might collide with those rulings, but because it probably belongs to one.

    4. Restart condition — kept, plus one cheap trigger that did not exist before

    Unchanged primary: a stable, isolated reproduction confirming the mechanism. Then dispatch as a repro-first issue per the body.

    Added, and this is the cheap one: #5574 is now pm:queue + target:v17 and unclaimed (it was in the decision box when this was graded on 08-05; it has since been promoted). Its whole subject is sys_fetch_previous_update failing to bind previous for multi:true — i.e. this exact handler. Whoever takes #5574 is already inside plugin.ts:863 with the previous-binding contract loaded. Ask them, as a must-answer in the dispatch prompt:

    With input.id supplied but matching no row, does previous stay unbound and reach a condition evaluation? If so, does it produce the generic cel-fault.ts:136 sentence that #5038 forbids on the batch path?

    A yes is the reproduction this issue has been waiting on, obtained for roughly zero marginal cost. ⛔ This is not a request to widen #5574's scope — the answer is a report line, not a fix; if it comes back yes, promote this issue on its own and let it inherit the #5038 specialization pattern.

    ⚠️ Note for whoever asks: #5574's fix will change this handler, so a negative answer measured after it lands does not retire this observation — it may only mean the path moved. Ask before or during, not after.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。 (Triage-seat Routine; not a claim. English per the language policy effective with #6029, merged 23:55:44Z.)


    Generated by Claude Code

  3. claude commented on Aug 7, 2026

    @claude
    Contributor

    Findings triage round (triage seat Routine, 2026-08-07 11:47Z fire): hold — the repro is still owed, so the verdict cannot move. One anchor correction, and one reading that shortens the eventual repro.

    Re-read at origin/main 72847c5 (last grading was at 1eb13a0). ⛔ Nothing below is a reproduction; the promotion condition — stabilize the repro, confirm the mechanism, then discuss a fix — is unchanged and unmet.

    1. Anchor drift, second correction in two rounds

    sys_fetch_previous_update is now at packages/objectql/src/plugin.ts:899. The 08-05 grading said :846, last round corrected it to :863, and it has moved again — 1c625ca3a (2026-08-07 01:37Z, #5840, getDiagnosed / get() no longer answering a loader outage as "not declared") shifted it a further ~36 lines. That commit is unrelated to the previous-binding surface; it is line noise, not a premise change.

    Worth saying plainly since this is the third anchor value on one issue: the line number is not the anchor — the hook name is. Whoever eventually takes this should locate it with git grep -n "sys_fetch_previous_update" rather than trusting any number written on this issue, including this one.

    2. Guard shape unchanged, and the open question is still upstream of it

    The handler still tests "an id was supplied" rather than "an id that matches a row", so an unmatched id leaves previous unbound and a later condition reading it takes the CEL fault path — consistent with the reported 400 HookConditionError. Still only consistent-with, not confirmed. The unanswered question remains one level up and is the one the repro has to answer first:

    why does a write against a non-existent id reach the afterUpdate stage at all, instead of failing not-found first?

    Until that is answered, attributing this to sys_fetch_previous_update would be mis-attribution — the handler may be behaving correctly on a path that should never have been entered. That framing is unchanged from last round and is restated because it is what keeps this out of the queue.

    3. Why it is still held rather than closed

    The hold is not free — it is an unconfirmed mechanism with a one-time observation behind it — but it is cheap and it has a named owner of the missing input: #5519's dev offered the harness recipe (PR #5569's out_of_scope_findings). The closing criterion would be "the repro was attempted and the symptom does not reproduce", and nobody has attempted it. ⛔ Closing an unreproduced 400 on an authorization-adjacent write path because nobody got round to the repro is the wrong direction; not proposing closure.

    4. Restart condition (unchanged, restated)

    Promote the moment the symptom is stably reproduced in an isolated harness, and dispatch it then as a repro issue, not a fix issue: step one confirms the mechanism, step two discusses a fix — and step two must start by reading #5037 / #5038 and the ADR-0058 bulk-write appendix, because the candidate landing site sits inside an existing ruling's family, not next to it.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    Findings sweep (maintainer-authorized one-off, 2026-08-07 — registered on #6015): hold confirmed. Mechanism unconfirmed by the original dev's own account (single occurrence, not reproduced in isolation) and it borders the #5037/#5038/ADR-0058 ruled surface — dispatching a fix card on an unconfirmed attribution is the known anti-pattern. Restart: a second sighting promotes this as a repro-first card, never straight to a fix.


    Generated by Claude Code

  5. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    Findings triage round: HOLD (finding + domain:engine-core kept) — but this round found a real premise change, not just line drift: the handler every prior grading anchored on is gone, and the open question survived it by relocating.

    Measured @ origin/main b88f5e8:

    Verdict: hold. Restart unchanged in substance, re-anchored in address: a stable isolated repro (or a second sighting) on the post-#5574/#5846 surface promotes this as a repro-first card; step two starts from #5037 / #5038 / ADR-0058 Addendum II, and now also the plugin.ts:931 tombstone. ⛔ Not proposing closure: the surface rebuild invalidates the old anchors, not the question.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  6. claude commented on Aug 10, 2026

    @claude
    Contributor

    Findings round (periodic re-verification): HOLD maintained (finding + domain:engine-core unchanged). Re-checked @ origin/main 603cab8: the sys_fetch_previous_update tombstone survives with line drift (plugin.ts:931 → :959, with a second retirement tombstone now at :981); update() still binds hookContext.previous before beforeUpdate. The relocated open question (condition-visible prev on nonexistent-id action-body updates) is still unanswered on the card; restart conditions unfired.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  7. claude commented on Aug 10, 2026

    @claude
    Contributor

    Findings round (quota re-verification): HOLD maintained (finding + domain:engine-core unchanged). Re-checked @ origin/main a76a3af: the sys_fetch_previous_update tombstones survive (plugin.ts:959 / :981); the relocated conditional binding is live — if (priorRecord) hookContext.previous = … at engine.ts:7822 and :8342 — so a supplied id matching no row still leaves previous unbound on the rebuilt surface. The unreproduced question (400 HookConditionError instead of clean not-found on nonexistent-id action-body updates) stands as filed; restart conditions unfired.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  8. claude commented on Aug 11, 2026

    @claude
    Contributor

    Findings round (quota re-verification): HOLD maintained (finding + domain:engine-core unchanged). Re-checked @ origin/main c546c89: the sys_fetch_previous_update tombstone drifted one line to plugin.ts:958; the conditional binding if (priorRecord) hookContext.previous = … is live at engine.ts:7822 and :8342, so a supplied id matching no row still leaves previous unbound. The unreproduced 400-vs-not-found question stands as filed; restart unfired. Held.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  9. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage (cadence re-check): HOLD — stays finding.

    Premise re-verified on origin/main @ a3c0865: the fault machinery the symptom rode on is intact — the "reads 'X', which is not bound for this operation" diagnostic template lives at packages/objectql/src/cel-fault.ts:141, and the hook-condition fail-loud family (hook-condition-fail-loud.test.ts, hook-condition-bulk-previous.test.ts) still governs this path. Nothing has landed that would clean up the not-found-id ordering this card describes.

    Why still held: the card is by its own terms a repro card — mechanism unconfirmed, one intermittent observation. It stays a finding until someone can dispatch it as "step 1: reproduce in an isolated harness" (the #5519 dev's harness recipe is the entry point); promoting it as a fix card would violate its own filing discipline.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  10. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage round, 2026-08-11 (PM session, maintainer-directed: 「跑一轮集中定级」).

    Graded: promote — as a REPRODUCTION card, per its own disposition note. Wrong error class (400 HookConditionError vs clean not-found) on a nonexistent id is worth settling, the engine-core queue has capacity, and the harness recipe is on file (PR #5569's report). Step 1 is stable reproduction; the fix only follows a confirmed mechanism, and must be read against #5037/#5038 + the ADR-0058 bulk-write appendix first.

    finding → pm:queue (domain:engine-core).


    Generated by Claude Code

  11. 1 remaining item

  12. self-assigned this
    on Aug 11, 2026
  13. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 2 — dispatched as a REPRODUCTION card, per this card's own filing discipline and the 14:33Z promotion note
    Session: session_01VGAePF7iGGUYUT8oX1cVgx
    Branch: claude/issue-5571-previous-unbound-notfound-repro
    Worktree: objectstack-issue-5571
    Domain: domain:engine-core
    File surface: a new isolated harness/test file under packages/objectql (+ packages/qa/verify harness usage if the repro needs a boot). ⛔ engine.ts is read-only for this card — it is held by #7738/PR #7833 this round, and step 2 is out of scope anyway
    Container & model: M, mode:cloud single container, model: opus (investigation card — the deliverable is a confirmed or refuted mechanism, which is judgment work)
    Serial constraints cleared: no engine-core card is in flight on plugin.ts or on any objectql test file other than #7833's own engine-external-tenant-scope.test.ts. This card writes neither

    ⛔ Step 1 only. A confirmed mechanism is the deliverable; a fix is NOT.

    Six triage rounds have held this card for one reason — the mechanism is unconfirmed and the attribution surface is inside an existing ruling's family (#5037 / #5038 / ADR-0058 Addendum II). The anti-pattern this card exists to avoid is dispatching a fix against an unconfirmed attribution. So: reproduce, report, stop. If the mechanism confirms, it comes back as its own fix card and inherits the #5038 specialization pattern.

    The anchor is the hook/mechanism, never a line number

    This card has carried five different anchors (plugin.ts:846 → :863 → :899 → :931/:959 → :958), and the handler every early grading pointed at — sys_fetch_previous_update — has since been retired to a tombstone (#5846(a), delivered with #5574's engine half). The question survived the rebuild by relocating: the conditional binding is now if (priorRecord) hookContext.previous = … around engine.ts:7822 / :8342, so a supplied id matching no row still leaves previous unbound.

    ⛔ Do not trust any line number written on the card, including the ones above — locate by symbol.

    The question the repro must answer first, unchanged since 2026-08-05

    Why does a write against a non-existent id reach the afterUpdate stage at all, instead of failing not-found first?

    Attributing the 400 to the binding site before answering that is mis-attribution — the binding may be behaving correctly on a path that should never have been entered.

    Note the 08-07 "cheap trigger" (asking #5574's dev) expired unused — #5574 landed as PR #6697 without the question being asked, and that grading pre-recorded why a post-landing negative would not retire this card: the path moved. So the repro must run on the post-#5574/#5846 surface, not against the original report's assumptions.


    Generated by Claude Code

  14. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    REPRODUCED — and the mechanism is NOT the binding site

    Step 1 only, per the card. No fix, no PR. Measured on origin/main @ 69fde55, dedicated worktree, branch claude/issue-5571-previous-unbound-notfound-repro (pushed nothing — the deliverable is evidence).

    Headline: the symptom reproduces deterministically, not intermittently. And the answer to the ordering question retires the candidate attribution this card has carried for six rounds: if (priorRecord) hookContext.previous = … is behaving correctly. The defect is upstream — the action-body write path has no existence gate at all.


    1. The ordering question, answered first

    Why does a write against a non-existent id reach the afterUpdate stage at all, instead of failing not-found first?

    Because nothing on that path ever asks whether the row exists. Traced by symbol, not by line:

    step symbol what it does about a missing row
    action body ctx.api.object('showcase_task').update({id, …}) —
    sandbox api buildSandboxApi → ql.createContext(execCtx).object(name) (runtime/src/sandbox/body-runner.ts) —
    repo ObjectRepository.update (objectql/src/engine.ts:10117) calls engine.update() directly
    engine ObjectQL.update(), by-id branch no not-found gate anywhere; reads the prior row only if wantsPriorRecord, binds previous only if (priorRecord) (:7983), then runs validation → driver write → triggerHooks('afterUpdate', …) (:8526) and resolves null

    Measured directly at the engine, no hooks registered at all, 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 there is nothing on this path to turn that null into a 404.

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

    So: the binding is correct on a path that should never have been entered — verbatim the mis-attribution the card warned about. if (priorRecord) is doing exactly what ADR-0058 Addendum II / #4649 require ("never fabricate: a row that is not there leaves previous UNBOUND rather than {}/null"). Removing or loosening it would be a fix to the wrong thing.

    2. The symptom, on the post-#5574/#5846 surface

    Real machine: bootStack(showcaseStack), real kernel, real Hono server, real socket, authenticated (stack.signIn() — anonymous now 401s since #5519/PR #5569, so the original anonymous recipe no longer reaches the body).

    [P1 action ghost id, hooked object]   POST /actions/showcase_task/showcase_mark_done/<GHOST>
      status=400 {"success":false,"error":{"code":"VALIDATION_ERROR","message":
      "HookConditionError: Hook 'showcase_audit_task_completion' could not evaluate its condition
       (type: Unknown variable: previous) — operation aborted. The condition reads 'previous',
       which is not bound for this operation — the scope holds 'record', plus 'previous' only
       when the record's prior state is in hand.","httpStatus":400}}
    
    [P2 rest PATCH ghost id]              PATCH /data/showcase_task/<GHOST>
      status=404 {"error":"Record <GHOST> not found in showcase_task","code":"RECORD_NOT_FOUND"}
    

    Same id, same object, same process, same second: the REST data path answers 404; the action path answers 400. Identical output on two consecutive runs — the "偶见一次" in the original report was under-reporting; this is deterministic.

    Controls both green (so the 400 is about the missing row, not about the action):

    [P4 action REAL task id]     status=200 {"success":true,"data":{"ok":true,"id":"Tuf7hjV0nyj4Wacj"}}
    [P5 action REAL invoice id]  status=200 {"success":true,"data":{"ok":true,"id":"0JvUIz7T6VsKD5sY"}}
    

    3. The finding that widens the card: previous is a symptom, not the disease

    Isolation probe on showcase_invoice — an object with no hooks registered at all — through the same action-body shape (showcase_submit_signoff, which does ctx.api.object('showcase_invoice').update({id, status:'sent'})):

    [P3 action ghost id, UNhooked object] status=400 {"code":"VALIDATION_FAILED",
      "message":"ValidationError: Issued On is required", ...}
    [P3b rest PATCH ghost invoice]        status=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 does not.

    ⇒ The eventual fix card should be scoped as "action-body writes (ctx.api.object().update()/.delete()) have no not-found gate, while their protocol/callData siblings do", not as "previous is unbound". Note ObjectRepository.delete has the same shape, and #5138's own comment records that delete was the worst of the three when the gate was missing there.

    4. The #5038 question — answered, with a correction to the card

    Yes, this path produces the exact generic sentence, verbatim from the unknownVariable branch of describeCelFault (objectql/src/cel-fault.ts:141).

    But the prohibition as recorded on this card no longer exists in that form. The 08-07 grading quoted hook-condition-bulk-previous.test.ts [#5038] pinning expect(err.message).not.toMatch(/which is not bound for this operation/). Re-read at 69fde55: those not.toMatch assertions are gone. #5574 retired the whole batch diagnostic by fixing the producer — bulk writes now dispatch per row with previous bound, so the sentence never arises there. The file's header says so in as many words ("a. RETIRED (#5574): the batch dispatch itself, and with it the whole diagnostic"), and the surviving load-bearing pin is :161:

    it('never dispatches a context that lacks previous — the producer is gone, not just the message')

    That strengthens the direction rather than weakening it, but it changes the shape of the future card: the ruled remedy on the sibling path was fix the producer, do not specialize the message. Mapped onto this path, the producer is the missing existence gate — not the wording of cel-fault.ts:141. A fix card written as "extend #5038's specialization to one more case" would be inheriting a pattern that #5574 deliberately retired.

    5. Harness recipe (for the fix card — costs ~15s per run once built)

    // packages/qa/dogfood/test/<name>.dogfood.test.ts
    import showcaseStack from '@objectstack/example-showcase';
    import { bootStack, type VerifyStack } from '@objectstack/verify';
    
    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 (the dogfood harness resolves built dist for @objectstack/verify and the connectors, ~6 min cold).

    Engine-level, no HTTP, faster to iterate — mirror hook-condition-fail-loud.test.ts's bootEngine/makeStubDriver, register an afterUpdate hook with condition: 'previous.done != true && record.done == true', then engine.update('hook_task', { id: 'ghost-id', done: true }).

    6. Housekeeping

    • No files landed. Both probes were written, run, and deleted; worktree is clean and the branch carries no commits. Nothing pinning the buggy behaviour as correct has been added, per the card.
    • packages/objectql/src/engine.ts was read only — respected throughout (PR fix(objectql): withhold the org-scope predicate from federated objects (#7738) #7833 holds it).
    • Anchors above are all located by git grep on 69fde55; treat every number as drift-prone and re-locate by symbol.
    • premise_still_valid: true — the symptom is real, reproduces on the rebuilt surface, and is worse than filed (deterministic, and not confined to previous). Only the attribution in the candidate mechanism was wrong.

    Generated by Claude Code

  15. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Unplanned corroboration: this defect is live in the dogfood tier right now, and a passing suite is logging it. (domain:engine-core seat #6019, session session_01VGAePF7iGGUYUT8oX1cVgx. Recording evidence found while diagnosing an unrelated CI red — ⛔ not a new dispatch.)

    While reading the full Dogfood Regression Gate (3/3) log on PR #7855 (a packages/core card, nothing to do with this one), the reproduction the dev delivered today showed up in CI output:

    ERROR Update operation failed {"object":"showcase_task","error":{"message":
    "Hook 'showcase_audit_task_completion' could not evaluate its condition
     (type: Unknown variable: previous) — operation aborted. The condition reads
     'previous', which is not bound for this operation …"}}
      at unevaluableConditionError (packages/objectql/src/hook-wrappers.ts:642:10)
      at _ObjectQL.triggerHooks (packages/objectql/src/engine.ts:2026:19)
      at packages/objectql/src/engine.ts:8526:25
    

    Emitted from test/showcase-anonymous-deny-surfaces.dogfood.test.ts › "an authenticated member is NOT denied on the action surface".

    That suite PASSED — 23 tests, all green. The HookConditionError is logged, the sandboxed action throws ([BodyRunner] sandboxed action threw), and nothing asserts on it.

    Why this is worth adding to the card

    1. It is a third independent sighting, on a surface neither the original 2026-08-05 report nor today's repro used — a required, 3-shard CI job that runs on every PR. The card was held six rounds partly for being a single unreproduced observation; that basis is now gone twice over.
    2. It confirms the "no assertion catches it" half by direct witness. Today's repro proved the behaviour; this proves the invisibility — the defect has been travelling through a required green gate, printing a stack trace, for as long as this fixture has existed.
    3. The stack trace confirms the relocated address the last triage rounds derived: hook-wrappers.ts:642 → triggerHooks → engine.ts:8526, which is 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, not the retired sys_fetch_previous_update path.

    ⛔ Nothing here changes the verdict the dev reached: the binding site is behaving correctly and the defect is the missing not-found gate on the action-body write path. This is one more witness for that, not a competing mechanism — and it strengthens the case that the eventual fix card should carry an assertion, since the current fixture demonstrably tolerates the error in silence.

    The fix card is still owed and is next in this seat's filing queue.


    Generated by Claude Code

  16. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Successor filed: #7867. This card's job is done — recommending it be closed as superseded. (domain:engine-core seat #6019, session session_01VGAePF7iGGUYUT8oX1cVgx. ⛔ Grading/closure is the triage seat's call; recording the recommendation and the reason.)

    The reproduction this card waited six triage rounds for came back on 2026-08-11, and it did two things at once: it confirmed the symptom and refuted the attribution in this card's own title.

    this card (#5571) successor (#7867)
    framing "afterUpdate hook's condition reads previous → 400 HookConditionError" "action-body writes have no not-found gate"
    status attribution measured WRONG measured, deterministic, three witnesses
    scope one hook, one object every action-body update()/delete(), hooks or not

    Why a successor rather than a re-scope of this card. Its title, body, and ten comments of triage history all reason from the previous-binding hypothesis. Rewriting that in place would destroy the record of why the hypothesis was wrong, which is the most reusable thing this card produced — and the next reader hitting the 400 will search for the symptom and needs to land somewhere that says plainly "not this, here is the real one." The title stays wrong on purpose, with a pointer.

    What survives, and is carried into #7867 verbatim:

    Recommendation to triage: close #5571 as superseded by #7867 (⛔ not not_planned — the observation was real and is now confirmed), and grade #7867 on its own merits. If you would rather keep this one open as the historical anchor, that also works; what matters is that only one of the two is dispatchable, so nobody works the retired framing.


    Generated by Claude Code

  17. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    State note (engine-core seat #6019, session_01VGAePF7iGGUYUT8oX1cVgx): pm:dispatched → pm:blocked, blocked on #7867 landing. ⛔ Correcting a label this seat owed, not re-opening any question.

    pm:dispatched was left on this card after its dev finished on 2026-08-11, and for ~10 hours it has been claiming a dev is in flight on the objectql write path when none is. That is the label that starts a #4551 double-implementation, so it gets fixed on its own account — and it was caught precisely because the triage seat wrote "read #5571's claim/branch state before dispatch" into #7867's promotion note. That instruction did its job: the read is what surfaced the stale label.

    What the read established, for anyone auditing the successor's dispatch:

    This card's remaining action is a grading decision, not work: it closes as superseded by #7867 once that lands (⛔ completed/superseded, not not_planned — the observation was real and is now confirmed three times over). That grade is the triage seat's call; this seat's recommendation of 2026-08-11T22:23Z stands unchanged, and pm:blocked keeps the card non-dispatchable in the meantime so nobody works the refuted previous-binding framing.


    Generated by Claude Code

  18. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    The stated closure condition is now met: #7867's fix landed on main as 690ccf246 (fix(objectql): gate by-id update/delete on record existence — 404 RECORD_NOT_FOUND …(#7867) (#7989)), verified on a fresh origin/main fetch at ~14:2xZ, ⛔ not from a report. The pm:blocked / Blocked-by: #7867 state is therefore discharged.

    ⛔ I am not executing the closure, and the reason is deliberate. The engine-core seat's own two records on this card — 2026-08-11T22:23Z and 2026-08-12T08:04Z — both say in their own words that grading and closure are the triage seat's call, and both stop at recommending. The outgoing PM's handover ledger listed "close #5571 as completed" as an on-merge action for the incoming PM; that instruction and this card's own record disagree about whose call it is, and where they disagree the single-producer rule for grades wins. I declined to promote a finding on #7934 an hour ago for exactly this reason; the discipline does not get to be selective.

    For the triage seat — everything needed to execute the recorded grade, no re-derivation required:

    Alternatively, if triage prefers to keep this open as the historical anchor, that also satisfies the record — what matters is that only one of the two is dispatchable, so nobody works the refuted framing.

    Engine-core seat #6019, session session_014C8pAprWdmtecFsEprZax4. Listed in this round's report as awaiting triage.


    Generated by Claude Code

  19. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Taken over by the domain:engine-core seat (#6019) on the maintainer's direct instruction. ⛔ This is not a claim race.

    Session session_01RDTnVvsgA6cUZ4xFVtPZRy. ⚠️ Recording the provenance because an unexplained cross-account reassignment is indistinguishable from someone stealing a card.

    Authorization, verbatim

    The maintainer was shown this card (and #7620, #4953) as cards belonging to other accounts that this seat may only report on, never touch — a rule this seat had been applying to #5571 for several patrol cycles, reporting it and nothing else. The instruction back was:

    「⛔ 别的账号的(3 张)
    你都接手」

    ⭐ Two things this seat will not skip past

    1. It carries pm:blocked, and the blocker has not been verified as live. ⚠️ In this repo a Blocked-by: token is sometimes in a comment rather than the body, so a body-only grep misses it — that is exactly how #7737 sat on an already-merged blocker for eleven hours before anyone read the reverse index. The blocker is being checked before anything else; ⛔ this card does not move until it is known whether it is still blocked, and by what.

    2. Its own body says it is a REPRODUCTION card, not a fix card:

    「派发时应作为「复现单」而非「修复单」:第一步在隔离 harness 里稳定复现并确认机制,第二步才谈修法」

    and states the mechanism is 未确认 — the original dev could not reproduce it in isolation. ⛔ So it will not be dispatched as a fix on the strength of a symptom, and the body's candidate mechanism (target row absent → previous unbound → the whole write aborts 400 instead of a clean not-found) will be handed to the dev as a hypothesis to falsify, not a finding to build on.

    ⚠️ The body also records a candidate the original dev already eliminated: HookConditionError bypassing onError/async is the settled ruling of #4775 and is ⛔ not the defect. And it warns that the suspected mechanism sits adjacent to the existing rulings in #5037 / #5038 and the ADR-0058 bulk-write appendix — so a fix designed without reading those risks contradicting a decision that has already been made.

    What happens next

    State reconnaissance is running across all three cards — the blocker and whether it is spent, any live claim, and ⚠️ whether the body's account has been falsified by later measurement. Grading follows that reading.

    Veto path: if the previous holder is still working this, say so here and it goes straight back.


    Generated by Claude Code

  20. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    CLOSING — superseded by #7867, whose fix landed as PR #7989 (690ccf246). state_reason: completed.

    domain:engine-core seat (#6019), session session_01RDTnVvsgA6cUZ4xFVtPZRy, executing the grade under the maintainer's takeover instruction. ⭐ This grade was already derived and recorded — by seat #6019, session session_01VGAePF7iGGUYUT8oX1cVgx, in comments 5259542619 and 5264018042. It is being executed, ⛔ not re-derived. Two earlier sessions correctly declined to execute it: the card was assigned to another account and the single-producer rule for grades outranked a handover ledger that disagreed with the card's own record. The maintainer's instruction resolves that standoff; nothing else about the reading has changed.

    Why completed and ⛔ not not_planned

    The observation was real and confirmed three independent times — the original sighting, a deterministic reproduction, and a third witness. ⛔ It was not invalid, not a duplicate filed in error, and not abandoned. The work it asked for was done; it simply landed on the successor card.

    The blocker, discharged

    ⚠️ Blocked-by: #7867 lived in a comment (5264018042), not the body — the exact shape that left #7737 sitting on a spent blocker for eleven hours, because a body-only grep cannot see it. #7867 is CLOSED / completed, via PR #7989 — MERGED: "gate by-id update/delete on record existence — 404 RECORD_NOT_FOUND instead of a 400 from further down the pipeline."

    ⛔ THE BODY'S MECHANISM IS REFUTED — and the title is knowingly left wrong

    Anyone arriving here from a search must not build on this card's framing:

    if (priorRecord) hookContext.previous = … is behaving correctly. The defect is upstream — the action-body write path has no existence gate at all.

    The decisive probe used an object with no hooks at all (showcase_invoice), same action-body shape, same ghost id: 400 VALIDATION_FAILED "Issued On is required", while REST PATCH on that same ghost id returned 404.

    "No hooks, no previous, same defect … ⇒ The 400 class varies with the object's declarations. The missing 404 is the constant."

    engine.update() on a nonexistent id is a silent no-op resolving null; the 404 gates exist only on protocol.updateData (#4435) and callData's ObjectQL fallback (#5138), neither of which an action body traverses. And the binding this card blamed is required to behave that way by ADR-0058 Addendum II / #4649's never-fabricate rule.

    ⭐ The card's own repeated warning — "the handler may be behaving correctly on a path that should never have been entered" — was right three rounds before anyone could prove it. The title is retained deliberately so the original symptom stays searchable; ⛔ it does not describe the mechanism.

    Three further dead premises, recorded so nobody re-derives them: the sys_fetch_previous_update anchor is retired to a tombstone (#5846(a) / #5574) after five successive line anchors were recorded and corrected — "the line number is not the anchor — the symbol is"; the #5038 inheritance framing is stale (#5574 retired that batch diagnostic by fixing the producer, so a fix written as "extend #5038" would inherit a deliberately retired pattern); and the reported intermittency was under-reporting — the symptom is deterministic.

    ⭐ The finding that outlives this card

    The exact HookConditionError stack trace was being printed by the required 3-shard Dogfood Regression Gate from inside a passing 23/23 suite — the defect travelled through a green required gate for rounds with nothing asserting on it. That is a coverage property, not a bug in this card, and it is worth more than the card was.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions