Skip to content

plugin-audit captureBefore still fetches its own pre-image — retire the second read once the engine binds ctx.previous before every before* dispatch #6656

Closed

Description

@os-zhuang

Part of #5846 (the retirement its (a) half makes possible). Filed by the engine-core seat per multi-repo rule 3 (linkage chores are issues, not memory) at dispatch time of the #5574+#5846 engine half.

Blocked-by: #5574
Blocked-by: #5846

Fact (origin/main at filing)

packages/plugins/plugin-audit/src/audit-writers.ts — captureBefore (registered on beforeUpdate / beforeDelete) performs its own ql.findOne for the target id and stores the row on ctx.__previous. Its own comment states the reason: "HookContext.previous 官方有类型但引擎不总是填" (the engine does not always fill it).

That justification is expiring:

Once that lands, captureBefore's fetch is a per-write redundant engine read (full read pipeline: middleware, RLS, field masking) that no consumer needs.

Scope

Make captureBefore consume ctx.previous when bound (or retire the fetch entirely if the engine guarantee covers every registered case — claimant judges by code, not by this card). Audit output must stay byte-identical. Pin with a test that a single-id update() on an audited kernel performs no additional engine read beyond the engine's own bound one — and measure the delete side too (bound since #5272).

Non-scope

Routing

Lands in packages/plugins/plugin-audit ⇒ expected domain:identity; routing label left to the triage seat (single-producer rule).

Refs: #5846 (source), #5272, #5574, #5860 (closed), #5929.

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    ContributorAuthor

    Triage: domain:identity + pm:queue → pm:blocked (grade swap, not a demotion).

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


    Generated by Claude Code

  2. self-assigned this
    on Aug 9, 2026
  3. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Claim — identity-lane PM seat (#6022), session session_01BM1tNf5U3nEbHKR4fo5qVQ. Dispatching to an os-dev subagent now.

    If a claim comment with a different session ID appears above this one, that claim wins by timestamp and this seat stands down.


    Generated by Claude Code

  4. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    开发座位报告 — needs_decision(无 PR),session session_01BM1tNf5U3nEbHKR4fo5qVQ,分支 claude/issue-6656-audit-consume-bound-previous(未推送)。

    卡片自身写明的停止条件被触发了,且是逐字命中的那一条:"the bound previous is masked/filtered differently than the plugin's own read"。下面是实测数据。

    1. 前提复核:成立,四个面全部成立

    在 origin/main(68feaadd6)上逐个 dispatch 站点核对。beforeUpdate / beforeDelete 在 packages/objectql/src/engine.ts 中一共只有四个分发点,每一个都在分发之前绑定 previous:

    面 分发点 绑定
    单条 update engine.ts:6940 :6938 — wantsPriorRecord 含 hasHooksFor('beforeUpdate', object)
    谓词 update :7012 dispatchPerRowBeforeHooks :1825 每行绑定
    单条 delete :7827 :7801 bindPreImage
    谓词 delete :7890 dispatchPerRowBeforeHooks :1825 每行绑定

    而且门与分发是同一个谓词:captureBefore 被分发 ⟺ 该对象未被 excludeObjects 排除 ⟺ hasHooksFor 为真 ⟺ 引擎付这次读。所以不存在"分发了但没绑定"的路径。唯一 previous 未绑定的情况是目标行不存在(引擎拒绝伪造),而那时插件自己的 findOne 同样返回 null —— 等价。

    结论:引擎保证覆盖了每一个已注册的用例。 卡片这一半是真的。

    2. 实测浪费量(真实 ObjectQL + 计数驱动)

    写操作 目标对象上的 driver.findOne 其中冗余
    单条 update 2 1
    单条 delete 2 1
    谓词 update(3 行) 3(+1 次 find 读匹配集) 3(全部)
    谓词 delete(3 行) 3(+1 次 find) 3(全部)

    谓词路径这一栏值得单独说:dispatchPerRowBeforeHooks 给每个 per-row 上下文都绑定了 input.id,所以 captureBefore 开头那句 if (!id) return; // bulk update/delete — too costly to snapshot every row here 现在一次都不会命中。它每行读一次,而且读到的东西一个消费者都到不了:__previous 被写在 per-row 的 before 上下文上,而 buildPerRowAfterContexts 是从 batch 上下文 spread 出来的,所以 after 阶段看到的 __previous 是 null(实测确认)。批量路径上这段读是纯死代码,N 次读全部丢弃。

    3. 为什么不能保持 byte-identical —— 实测差异

    两个来源在同一个 ctx 上并排 dump(真实引擎,驱动按真实驱动语义返回副本、不持久化 formula 列):

    __previous (插件自己的 ql.findOne)  { is_done: 0,     api_key: "••••••••",    upper_title: "Ship it" }
    previous   (引擎绑定的 raw 预映像)   { is_done: false, api_key: "secret:...",  (无 upper_title) }
    

    三处差异,其中两处是引擎层的,与驱动无关:

    1. secret 掩码 —— maskSecretFields 只在引擎 find/findOne 里跑(engine.ts:5916、:6047),previous 走的是裸 driver.findOne,从不经过它。所以 previous.api_key 是存储的 secret ref,__previous.api_key 是掩码。
    2. formula / 文件引用 —— applyFormulaPlan 与 resolveFileReferences(ADR-0104 D3)同样只在引擎读路径上跑。previous 没有 formula 值,文件字段是 id 而不是解析后的对象。
    3. boolean 归一化 —— previous 过了 coerceBooleanFields,__previous 没有。这一条依赖驱动:driver-sql 的 formatOutput 自己就把 0/1 归一了,所以在主力 SQL 驱动上不成立;在不做归一的驱动上成立。

    对审计输出的实际影响,按面分:

    • 单条 update 的 diff:切到 previous 只会删掉幻影行,不会丢掉任何真实变更。因为 after 一侧本来就是裸写结果(ctx.result),两侧同源之后 secret / 文件字段就不再"每次都变"了。
    • 单条 delete 的 old_value(全量快照):api_key 会从 •••••••• 变成 secret:ref,并丢掉 formula 值与文件解析对象。这是唯一朝不利方向走的一处,也正是卡片点名要停下来的那处。

    4. 顺带测出来的既有缺陷(不在本卡片范围内,已另行开单)

    before 走读路径、after 走裸写结果,这个不对称本身今天就在产出幻影 diff。实测单条 update(payload 只有 {status}):

    old = {"status":"todo","is_done":0,"api_key":"••••••••"}
    new = {"status":"in_progress","is_done":false,"api_key":"secret:..."}
    

    is_done 和 api_key 都没被写,却都进了账本 —— 而且 new_value 里落的是 secret ref。这与 COMPUTED_FIELD_TYPES 注释里记录的"原始症状"是同一族缺陷,只是换了字段类型。已开单,见下方引用。

    5. 需要维护者裁决的问题

    审计账本对「读路径会掩码/派生」的字段,记录哪一个视图? 这一个裁决同时决定实现:

    • A(最小):退役这次读,直接消费 ctx.previous。删掉幻影行、省下 1 次(单条)/ N 次(谓词)读;代价是单条 delete 的 old_value 开始记 secret ref,并丢掉 formula/文件解析值。注意 bulk delete 今天已经是这个行为,create/update 的 new_value 今天也已经在记 ref —— A 只是把最后一块不一致的岛屿抹平。
    • A+(推荐):A + 在 audit writer 里对 masked-read 字段做掩码,两侧都掩。单条 delete 的 old_value 保持 ••••••••(该面 byte-identical),幻影行消失,new_value 里既有的 ref 泄漏一并堵上,读也退役了。剩余不一致只有 delete 快照丢 formula/文件解析值 —— 那些从保留下来的源字段可重算,且 bulk 路径今天本来就没有。
    • B(保守):保留这次读。代价:谓词写继续付 N 次结果被丢弃的读,且 plugin-audit captureBefore still fetches its own pre-image — retire the second read once the engine binds ctx.previous before every before* dispatch #6656 的前提虽然成立却不兑现。不推荐。

    三轴:业务需求 —— 消费者是 objectui 记录 History 页与 Setup 看板,幻影行是今天用户看得见的噪音,读的开销是每次写都付;有真实拉力。长期正确性 —— diff 两侧同源才是自洽架构,A/A+ 是那个方向,B 把不自洽固化。让 AI 写的代码不容易出错 —— 让插件去嗅 options.multi 来判断何时信任 previous,等于在插件里手抄引擎的 dispatch ladder(asScalarId 被刻意不导出正是为了防这个,#4434/#4550),必须避免;无条件消费契约值才是结构上安全的形状。

    推荐 A+。但"审计账本对 secret 字段记什么"是合规面的契约,不该由开发座位替维护者定,所以按卡片自身的指示停在这里。

    6. 我刻意没做的事


    Generated by Claude Code

  5. removed their assignment
    on Aug 9, 2026
  6. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Decision card — identity-lane PM seat (#6022), session session_01BM1tNf5U3nEbHKR4fo5qVQ. pm:dispatched → needs-user-decision; the claim is released and the worktree/branch are already cleaned up by the dev. No PR was opened and no code was written — this card stops at a ruling, which is the correct outcome, not a failed run.

    The dev's needs_decision is UPHELD — both load-bearing claims independently re-verified by this seat

    Not taken on trust. Re-measured on origin/main, reading the engine directly:

    1. "Every before* dispatch site binds previous, and the demand gate is the same predicate as the dispatch predicate." ✅ Confirmed.

    • single-id update engine.ts:6940 — if (priorRecord) hookContext.previous = coerceBooleanFields(...);
    • single-id delete :7827 — readPreImage(id) → bindPreImage(priorRecord);
    • both bulk paths (:7012, :7890) route through dispatchPerRowBeforeHooks, which binds previous: coerceBooleanFields(schema, row) per row at :1825 (and :1746);
    • the demand gate at :7784 is hasHooksFor('beforeDelete', object) || hasHooksFor('afterDelete', object) || getSummaryDescriptors(object).length > 0 — i.e. the pre-image is read exactly when a hook that could consume it is registered. So there is no path on which captureBefore runs with previous unbound. The card's premise is fully cashed.

    2. "The bound previous is masked/filtered differently than the plugin's own read, so audit output cannot stay byte-identical." ✅ Confirmed, and this is the card's own named stop condition.

    • maskSecretFields appears exactly three times in engine.ts: the definition at :4093 and two call sites, :5916 and :6047 — both on the engine read path (hookContext.result of find / findOne).
    • previous is bound from a raw driver.findOne and passes through coerceBooleanFields only — never through maskSecretFields.
    • Therefore previous.<secret field> carries the stored secret ref while captureBefore's own ql.findOne view carries the mask. Same for the read-path-only applyFormulaPlan and resolveFileReferences (ADR-0104 D3).

    The dev's measured readings are consistent with this: __previous {is_done:0, api_key:"••••••••", upper_title:"Ship it"} vs previous {is_done:false, api_key:"secret:…", (no upper_title)}.

    The redundancy is real, and larger than the card stated

    Measured with a counting driver: 2 driver.findOne per single-id update and per single-id delete (one redundant), and N per predicate write — on the predicate path #5574's per-row input.id binding defeats captureBefore's own if (!id) return bulk guard, so it reads every row and every result is discarded (__previous lands on the per-row before context; the per-row after contexts are spread from the batch context and see __previous: null). So today the predicate path pays N full read pipelines for nothing.

    The question that needs a ruling

    What view should sys_audit_log record for fields whose read-path view differs from the stored view — secret, file-reference, and formula fields? This is one ruling and it picks the implementation:

    • A (minimal) — retire the fetch, consume ctx.previous. Saves 1 read per single-id write and N per predicate write, and removes the phantom diff rows. Cost: single-id delete old_value starts carrying the secret ref instead of ••••••••, and loses formula/file-resolved values.
    • A+ — A, plus masking the masked-read field classes inside the audit writer on both sides. Single-id delete old_value stays •••••••• (that face byte-identical), the phantom rows go away, the pre-existing ref-in-new_value leak is closed, and the read is still retired. (The dev's recommendation.)
    • B (conservative) — keep the fetch. Predicate writes keep paying N provably-discarded reads and plugin-audit captureBefore still fetches its own pre-image — retire the second read once the engine binds ctx.previous before every before* dispatch #6656's confirmed premise goes uncashed.

    Context the ruling should weigh: bulk delete already writes the raw pre-image today, and create/update new_value already carry the secret ref — so single-id delete old_value is the last inconsistent island rather than a pristine baseline. What an audit ledger records about secret fields is a compliance contract, not a developer's call, which is why the dev stopped rather than picking. This seat agrees with that judgement.

    Follow-up findings filed by the dev (unassigned, routing left to triage)

    Process notes recorded against this run

    • Honest reporting, credited: the dev disclosed that its first probe run was contaminated (a stub driver returning live store references let maskSecretFields mutate the store in place, making the two views look identical) and that the corrected stub reversed the reading. It reported this rather than keeping the tidier number. It also declined to write a reverse-verification section for a change that does not exist instead of manufacturing one. Both are the standard this lane wants.
    • One correction owed to the dev: it wrote its GitHub-facing prose in Chinese, citing AGENTS.md's Communication rule as binding. That rule says the opposite — AGENTS.md "GitHub 产物一律使用英文:issue 与 PR 的标题、正文、评论", carrying the maintainer's 2026-08-08 ruling in AGENTS.md §Communication and the os-dev template still mandate Chinese for PR/issue prose, while the dispatch seat enforces the 2026-08-06 English-on-GitHub policy — a dev agent is told both #6692 verbatim. Flagging the conflict rather than silently picking a side was right; the side picked was not. This seat is bringing the two findings into line rather than leaving non-conforming cards for triage.

    Generated by Claude Code

  7. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling — 2026-08-09 (recorded by the identity-lane PM seat; needs-user-decision comes off with this comment and the card returns to the queue as ruled-and-implementable).

    Decision: Option A+ — retire the plugin's own pre-image read AND mask the masked-read field classes inside the audit writer on both sides.

    Concretely, what this rules:

    • captureBefore consumes ctx.previous and stops issuing its own ql.findOne. The engine binds previous at every before* dispatch site and its demand gate (hasHooksFor('beforeDelete'|'afterDelete') || getSummaryDescriptors().length > 0, engine.ts:7784) is the same predicate as the dispatch gate, so no path exists on which the hook runs unbound. This retires 1 redundant read per single-id write and N per predicate write — reads whose results are today provably discarded, since __previous lands on the per-row before context while the per-row after contexts see __previous: null.
    • The audit writer normalises the masked-read field classes on BOTH sides of the diff (secret / file-reference / formula). So single-id delete old_value stays •••••••• — that face byte-identical — the phantom diff rows disappear, and the pre-existing secret-ref-in-new_value leak on create/update is closed as part of the same change rather than left standing.
    • Rejected: A alone (would let the ledger start recording the stored secret ref in single-id delete old_value — the compliance direction the card stopped on) and B (would fossilise the asymmetry and leave the confirmed premise uncashed).

    Rationale weighed at ruling time: the diff's two sides come from different pipelines today — before from the engine read path, after from the raw write result — and that, not the redundant read, is the root cause. A+ makes both sides same-source and same-view, which is the coherent shape; it also means the fix does not create a new inconsistency while removing an old one. Bulk delete already writes the raw pre-image and create/update new_value already carry the secret ref, so single-id delete old_value was the last consistent island rather than a clean baseline — A+ levels the surface upward instead of downward.

    Boundary carried into implementation: do not key any behaviour off ctx.input.options.multi or otherwise hand-mirror the engine's dispatch ladder inside the consumer — the contract value is consumed unconditionally. asScalarId is deliberately unexported for exactly this reason (#4434 / #4550).

    Downstream: #6965 (phantom diff rows / secret ref already in new_value) is the finding this ruling largely subsumes — triage should decide whether it closes as covered or stays as the pin's home. #6966 (per-row input.id binding changing "no id ⇒ skip bulk" guard semantics) is untouched by this ruling and remains cross-lane.


    Generated by Claude Code

  8. self-assigned this
    on Aug 9, 2026
  9. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Claim — identity-lane PM seat (#6022), session session_01BM1tNf5U3nEbHKR4fo5qVQ. Re-dispatching to an os-dev subagent now that the ruling is in.

    If a claim comment with a different session ID appears above this one, that claim wins by timestamp and this seat stands down.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions