You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-audit captureBefore still fetches its own pre-image — retire the second read once the engine binds ctx.previous before every before* dispatch #6656
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.
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).
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).
Triage: domain:identity + pm:queue → pm:blocked (grade swap, not a demotion).
Routing: fix lands in packages/plugins/plugin-audit (audit-writers.ts — captureBefore verified at :540 on origin/main @ 04476e7, still doing its own findOne + __previous stash) ⇒ domain:identity, matching the card's own routing note.
State: body carries Blocked-by: #5574 and Blocked-by: #5846, both open and in flight (pm:dispatched) — a card whose premise ("the engine binds ctx.previous before every before* dispatch") is not yet true on main is pm:blocked, not dispatchable. The unlock sweep returns it when the engine half merges; re-verify the update-side binding on the merged ref then (the delete side is already bound since 单记录 delete 从不绑定 hookContext.previous —— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272).
Unblock: both blockers are closed/completed — Blocked-by: #5574 and Blocked-by: #5846, closed 2026-08-08T15:36:54Z (engine-core lane). The engine-side guarantee this card waits on (update-side prior read moved ahead of beforeUpdate, previous bound on both single-id and predicate paths) has landed. pm:blocked removed → pm:dispatched.
File surface (expected): packages/plugins/plugin-audit/src/audit-writers.ts + a new/extended pin in the same package.
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(...);
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.)
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)
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.
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:
captureBeforeconsumes 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.
Claim — identity-lane PM seat (#6022), session session_01BM1tNf5U3nEbHKR4fo5qVQ. Re-dispatching to an os-dev subagent now that the ruling is in.
Branch: claude/issue-6656-audit-previous-aplus
Worktree: ../objectstack-issue-6656-aplus (dedicated per-task worktree; the first run's worktree and branch were cleaned up by that dev, so this is a fresh tree)
Prior-run evidence carried forward (measured by the first dev, spot-verified by this seat against origin/main): the engine binds previous at all four before* dispatch sites, its demand gate at engine.ts:7784 is the same predicate as the dispatch gate, and maskSecretFields has exactly two call sites (:5916, :6047), both on the engine read path — which is why the two views diverge and why A+ rather than A was ruled.
If a claim comment with a different session ID appears above this one, that claim wins by timestamp and this seat stands down.
Activity
os-zhuang commentedon Aug 8, 2026
Triage:
domain:identity+pm:queue→pm:blocked(grade swap, not a demotion).packages/plugins/plugin-audit(audit-writers.ts—captureBeforeverified at:540onorigin/main@04476e7, still doing its ownfindOne+__previousstash) ⇒domain:identity, matching the card's own routing note.Blocked-by: #5574andBlocked-by: #5846, both open and in flight (pm:dispatched) — a card whose premise ("the engine bindsctx.previousbefore everybefore*dispatch") is not yet true on main ispm:blocked, not dispatchable. The unlock sweep returns it when the engine half merges; re-verify the update-side binding on the merged ref then (the delete side is already bound since 单记录 delete 从不绑定hookContext.previous—— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272).object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 (closed) as the card itself records.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
Claim — identity-lane PM seat (#6022), session
session_01BM1tNf5U3nEbHKR4fo5qVQ. Dispatching to anos-devsubagent now.claude/issue-6656-audit-consume-bound-previous../objectstack-issue-6656(dedicated per-task worktree; never the shared checkout)domain:identity— label untouched (single-producer rule)Blocked-by: #5574andBlocked-by: #5846, closed 2026-08-08T15:36:54Z (engine-core lane). The engine-side guarantee this card waits on (update-side prior read moved ahead ofbeforeUpdate,previousbound on both single-id and predicate paths) has landed.pm:blockedremoved →pm:dispatched.packages/plugins/plugin-audit/src/audit-writers.ts+ a new/extended pin in the same package.plugin-auditis touched by no other open identity card and by nothing in this batch (Revise ADR-0094: the permission write-through path 403s under the ADR-0005 rollback — move it to the materialized layer or retire the 2026-07-14 direction confirmation (#6609 ruling A follow-through) #6858 →plugin-security+docs/adr; plugin-sharing: INFO line when isSystem writes cover an active sharing rule but materialise zero grants (demand 3 of #4707, maintainer-ruled 2026-08-06) #6783 →plugin-sharing;audience-anchors.test.tsstill namesmember_defaultas the plain-wildcard shape — the same stale illustration #6696 fixes, one package over #6842 →plugin-securitytest file). The engine half is landed and closed, so there is no in-flight counterpart to race.ctx.previousor retire the fetch outright, and audit output must stay byte-identical. plugin-audit 的 5 个 hook 全部无object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 (registration-face exclusion) is done — do not reopen;sys_fetch_previous_deleteis delete() 的按对象前置行门在任何 kernel 托管引擎上恒真 —— ObjectQLPlugin 自带的 sys_fetch_previous_delete 以 object: '*' 注册在 beforeDelete #5929, engine-core lane — out of scope.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
os-zhuang commentedon Aug 9, 2026
开发座位报告 —
needs_decision(无 PR),sessionsession_01BM1tNf5U3nEbHKR4fo5qVQ,分支claude/issue-6656-audit-consume-bound-previous(未推送)。卡片自身写明的停止条件被触发了,且是逐字命中的那一条:"the bound
previousis masked/filtered differently than the plugin's own read"。下面是实测数据。1. 前提复核:成立,四个面全部成立
在
origin/main(68feaadd6)上逐个 dispatch 站点核对。beforeUpdate/beforeDelete在packages/objectql/src/engine.ts中一共只有四个分发点,每一个都在分发之前绑定previous:engine.ts:6940:6938—wantsPriorRecord含hasHooksFor('beforeUpdate', object):7012dispatchPerRowBeforeHooks:1825每行绑定:7827:7801bindPreImage:7890dispatchPerRowBeforeHooks:1825每行绑定而且门与分发是同一个谓词:
captureBefore被分发 ⟺ 该对象未被excludeObjects排除 ⟺hasHooksFor为真 ⟺ 引擎付这次读。所以不存在"分发了但没绑定"的路径。唯一previous未绑定的情况是目标行不存在(引擎拒绝伪造),而那时插件自己的findOne同样返回 null —— 等价。结论:引擎保证覆盖了每一个已注册的用例。 卡片这一半是真的。
2. 实测浪费量(真实
ObjectQL+ 计数驱动)driver.findOnefind读匹配集)find)谓词路径这一栏值得单独说:
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 列):
三处差异,其中两处是引擎层的,与驱动无关:
maskSecretFields只在引擎find/findOne里跑(engine.ts:5916、:6047),previous走的是裸driver.findOne,从不经过它。所以previous.api_key是存储的 secret ref,__previous.api_key是掩码。applyFormulaPlan与resolveFileReferences(ADR-0104 D3)同样只在引擎读路径上跑。previous没有 formula 值,文件字段是 id 而不是解析后的对象。previous过了coerceBooleanFields,__previous没有。这一条依赖驱动:driver-sql的formatOutput自己就把 0/1 归一了,所以在主力 SQL 驱动上不成立;在不做归一的驱动上成立。对审计输出的实际影响,按面分:
previous只会删掉幻影行,不会丢掉任何真实变更。因为after一侧本来就是裸写结果(ctx.result),两侧同源之后 secret / 文件字段就不再"每次都变"了。old_value(全量快照):api_key会从••••••••变成secret:ref,并丢掉 formula 值与文件解析对象。这是唯一朝不利方向走的一处,也正是卡片点名要停下来的那处。4. 顺带测出来的既有缺陷(不在本卡片范围内,已另行开单)
before走读路径、after走裸写结果,这个不对称本身今天就在产出幻影 diff。实测单条 update(payload 只有{status}):is_done和api_key都没被写,却都进了账本 —— 而且new_value里落的是 secret ref。这与COMPUTED_FIELD_TYPES注释里记录的"原始症状"是同一族缺陷,只是换了字段类型。已开单,见下方引用。5. 需要维护者裁决的问题
审计账本对「读路径会掩码/派生」的字段,记录哪一个视图? 这一个裁决同时决定实现:
ctx.previous。删掉幻影行、省下 1 次(单条)/ N 次(谓词)读;代价是单条 delete 的old_value开始记 secret ref,并丢掉 formula/文件解析值。注意 bulk delete 今天已经是这个行为,create/update 的new_value今天也已经在记 ref —— A 只是把最后一块不一致的岛屿抹平。old_value保持••••••••(该面 byte-identical),幻影行消失,new_value里既有的 ref 泄漏一并堵上,读也退役了。剩余不一致只有 delete 快照丢 formula/文件解析值 —— 那些从保留下来的源字段可重算,且 bulk 路径今天本来就没有。三轴:业务需求 —— 消费者是 objectui 记录 History 页与 Setup 看板,幻影行是今天用户看得见的噪音,读的开销是每次写都付;有真实拉力。长期正确性 —— diff 两侧同源才是自洽架构,A/A+ 是那个方向,B 把不自洽固化。让 AI 写的代码不容易出错 —— 让插件去嗅
options.multi来判断何时信任previous,等于在插件里手抄引擎的 dispatch ladder(asScalarId被刻意不导出正是为了防这个,#4434/#4550),必须避免;无条件消费契约值才是结构上安全的形状。推荐 A+。但"审计账本对 secret 字段记什么"是合规面的契约,不该由开发座位替维护者定,所以按卡片自身的指示停在这里。
6. 我刻意没做的事
packages/objectql(delete() 的按对象前置行门在任何 kernel 托管引擎上恒真 —— ObjectQLPlugin 自带的 sys_fetch_previous_delete 以 object: '*' 注册在 beforeDelete #5929 是 engine-core 车道)、没有重开 plugin-audit 的 5 个 hook 全部无object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 的注册面工作、没有动content/docs/releases/。options.multi之类的启发式去"只修批量那一半" —— 见上面第三轴。Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
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_decisionis UPHELD — both load-bearing claims independently re-verified by this seatNot taken on trust. Re-measured on
origin/main, reading the engine directly:1. "Every
before*dispatch site bindsprevious, and the demand gate is the same predicate as the dispatch predicate." ✅ Confirmed.engine.ts:6940—if (priorRecord) hookContext.previous = coerceBooleanFields(...);:7827—readPreImage(id)→bindPreImage(priorRecord);:7012,:7890) route throughdispatchPerRowBeforeHooks, which bindsprevious: coerceBooleanFields(schema, row)per row at:1825(and:1746);:7784ishasHooksFor('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 whichcaptureBeforeruns withpreviousunbound. The card's premise is fully cashed.2. "The bound
previousis 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.maskSecretFieldsappears exactly three times inengine.ts: the definition at:4093and two call sites,:5916and:6047— both on the engine read path (hookContext.resultof find / findOne).previousis bound from a rawdriver.findOneand passes throughcoerceBooleanFieldsonly — never throughmaskSecretFields.previous.<secret field>carries the stored secret ref whilecaptureBefore's ownql.findOneview carries the mask. Same for the read-path-onlyapplyFormulaPlanandresolveFileReferences(ADR-0104 D3).The dev's measured readings are consistent with this:
__previous {is_done:0, api_key:"••••••••", upper_title:"Ship it"}vsprevious {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.findOneper single-id update and per single-id delete (one redundant), and N per predicate write — on the predicate path #5574's per-rowinput.idbinding defeatscaptureBefore's ownif (!id) returnbulk guard, so it reads every row and every result is discarded (__previouslands 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_logrecord 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:ctx.previous. Saves 1 read per single-id write and N per predicate write, and removes the phantom diff rows. Cost: single-id deleteold_valuestarts carrying the secret ref instead of••••••••, and loses formula/file-resolved values.old_valuestays••••••••(that face byte-identical), the phantom rows go away, the pre-existing ref-in-new_valueleak is closed, and the read is still retired. (The dev's recommendation.)Context the ruling should weigh: bulk delete already writes the raw pre-image today, and create/update
new_valuealready carry the secret ref — so single-id deleteold_valueis 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)
before走读路径、after走裸写结果,两侧视图不同源(并把 secret ref 写进 sys_audit_log) #6965 —plugin-auditwrites a phantom diff row on every single-id write for any field whose read-path view differs from the raw write result; also records that the secret ref is already stored insys_audit_log.new_valueon every create and update.Blocked-by: #6656.before*dispatch bindsinput.idon every context, silently changing the semantics of every "no id ⇒ skip, this is a bulk write" hook guard #6966 — beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574's per-rowbefore*dispatch bindsinput.idon every per-row context, silently changing the meaning of every "no id ⇒ skip bulk write" hook guard;service-storage'sbeforeDeleteid-prefetch is now dead code, and two comments inplugin-sharing(rule-hooks.ts:98,bulk-recompute.ts:16) still assert the opposite. Cross-lane — engine-core / storage / sharing.Process notes recorded against this run
maskSecretFieldsmutate 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.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
os-zhuang commentedon Aug 9, 2026
Maintainer ruling — 2026-08-09 (recorded by the identity-lane PM seat;
needs-user-decisioncomes 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:
captureBeforeconsumesctx.previousand stops issuing its ownql.findOne. The engine bindspreviousat everybefore*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__previouslands on the per-row before context while the per-row after contexts see__previous: null.old_valuestays••••••••— that face byte-identical — the phantom diff rows disappear, and the pre-existing secret-ref-in-new_valueleak on create/update is closed as part of the same change rather than left standing.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 —
beforefrom the engine read path,afterfrom 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/updatenew_valuealready carry the secret ref, so single-id deleteold_valuewas 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.multior otherwise hand-mirror the engine's dispatch ladder inside the consumer — the contract value is consumed unconditionally.asScalarIdis 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-rowinput.idbinding changing "no id ⇒ skip bulk" guard semantics) is untouched by this ruling and remains cross-lane.Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
Claim — identity-lane PM seat (#6022), session
session_01BM1tNf5U3nEbHKR4fo5qVQ. Re-dispatching to anos-devsubagent now that the ruling is in.claude/issue-6656-audit-previous-aplus../objectstack-issue-6656-aplus(dedicated per-task worktree; the first run's worktree and branch were cleaned up by that dev, so this is a fresh tree)domain:identity— label untouched (single-producer rule)needs-user-decision→pm:dispatched. Ruled Option A+ by the maintainer 2026-08-09 (comment 5230191737).packages/plugins/plugin-audit/src/audit-writers.ts+ tests in the same package.plugin-auditis touched by nothing else in flight in this lane. The engine halves (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) are landed and closed;packages/objectqlis not in scope (that is delete() 的按对象前置行门在任何 kernel 托管引擎上恒真 —— ObjectQLPlugin 自带的 sys_fetch_previous_delete 以 object: '*' 注册在 beforeDelete #5929, engine-core lane).origin/main): the engine bindspreviousat all fourbefore*dispatch sites, its demand gate atengine.ts:7784is the same predicate as the dispatch gate, andmaskSecretFieldshas exactly two call sites (:5916,:6047), both on the engine read path — which is why the two views diverge and why A+ rather than A was ruled.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
fix(plugin-audit): localize select option labels in the tracked-chang…
fix(plugin-audit): localize tracked-change labels and render lookup t…
fix(plugin-audit): localize select option labels in the tracked-chang…
docs(metadata): re-anchor the single-connection-pool hazard on a veri…
fix(spec): scoped-context.ts sudo() note no longer cites retired capt…
docs(objectql): correct two comments that still assert plugin-audit's…
test(plugin-audit): pin the update-verb capability gates; re-point the …
fix(plugin-audit): ask enable.files/enable.feeds on the update verb t…