Filed unassigned and unlabelled for triage, by the domain:services dev seat delivering #16608. Category (a), a reproducible defect, with the repro below. It is the SIBLING of #16608 one verb over, and it survives #16608's fix.
What #16608 fixed, and what it did not
#16608 ruled (maintainer, verbatim 「同意」, 2026-09-07, director seat, summon #17, decision batch #3) that the INSERT-side write check is evaluated on the hook-mutated payload — the row that will be stored — "so insert and update judge the same thing". The ruling's reference point was the update path, which "already merges its pre-image".
Delivering that revealed that the update path merges its pre-image with the caller's change set, and then the beforeUpdate hooks run and can rewrite exactly the field the check just judged. So after #16608 the two verbs still do not judge the same thing: insert judges the row that will be stored, update judges the row the caller asked for.
The repro
Measured on origin/main @ 941232040 plus #16608's branch, on both driver-sql (better-sqlite3 :memory:) and driver-sqlite-wasm, with a real ObjectQL engine, a real SecurityPlugin, a real IRlsMembershipResolver publishing current_user.employer_org_ids, and the #16608 policy shape:
check: 'record.employer_org in current_user.employer_org_ids'
The app stamps the denormalised scoping field on BOTH write events, the way ats does — a runAs: 'system' handler that reads the parent OUTSIDE RLS and overwrites employer_org from it, whatever the caller sent.
Caller holds employer_org_ids: ['org_a']. The row mem_u starts inside that scope (employer: 'emp_a', employer_org: 'org_a'), so the using pre-image gate lets the caller touch it. The caller then repoints the parent at emp_b, which belongs to org_b, while sending an in-scope value for the checked field:
PATCH qa_employer_member/mem_u { employer: 'emp_b', employer_org: 'org_a' }
|
measured |
| verdict |
ADMITTED (no refusal) |
| stamp |
ran, and read emp_b — recorded, not assumed |
| stored row |
{ id: 'mem_u', employer: 'emp_b', employer_org: 'org_b' } |
The row has been moved into an organization the caller does not hold. The check was satisfied by org_a, a value that never landed.
Why
In the write gate (packages/plugins/plugin-security/src/security-plugin.ts, step 3.6) the update post-image is built as { ...preImage, ...opCtx.data } inside the middleware — which runs BEFORE the engine's operation, and therefore before beforeUpdate. The merge closes the "a check on an unchanged field still sees its value" hole (#1994's twin); it does not close the "a hook rewrites the checked field after the merge" hole, which is #16608's sentence exactly: the check judges a value that never lands and ignores the one that does.
What it is NOT
Not #16344 (a readonly field stripped from persistence but still reaching beforeUpdate) — that is about which values a hook may READ. This is about which image the check is evaluated against. Not #14099 (one hook-mutated payload applied to every matched row on a multi: true update) — this reproduces on the by-id path, which is the only path the check gate post-image-validates at all.
Scope note
#16608's PR does NOT fix this and deliberately does not try. Its ruling names the insert side; extending the evaluation point to the update verb is a second accept/refuse change on a verb the ruling treated as the correct reference, so it wants its own decision rather than riding in as a bounded in-place fix. The mechanism #16608 introduces is reusable for it: the engine seam OperationContext.postHookWriteImageCheck, which insert() calls once the beforeInsert chain has produced the row. The update path would need the same call site after beforeUpdate, with the middleware's already-read caller pre-image captured in the closure and merged against the post-hook change set. The check gate skips predicate (multi: true) updates today and would continue to; only the by-id path is in question.
Whoever picks this up should note the priority argument from #16608: a check that admits a row whose stored scope the caller does not hold is a cross-organization write, and #16608 was regraded to priority:p1 on exactly that measurement.
Filed unassigned and unlabelled for triage, by the
domain:servicesdev seat delivering #16608. Category (a), a reproducible defect, with the repro below. It is the SIBLING of #16608 one verb over, and it survives #16608's fix.What #16608 fixed, and what it did not
#16608 ruled (maintainer, verbatim 「同意」, 2026-09-07, director seat, summon #17, decision batch #3) that the INSERT-side write
checkis evaluated on the hook-mutated payload — the row that will be stored — "so insert and update judge the same thing". The ruling's reference point was the update path, which "already merges its pre-image".Delivering that revealed that the update path merges its pre-image with the caller's change set, and then the
beforeUpdatehooks run and can rewrite exactly the field thecheckjust judged. So after #16608 the two verbs still do not judge the same thing:insertjudges the row that will be stored,updatejudges the row the caller asked for.The repro
Measured on
origin/main@941232040plus #16608's branch, on bothdriver-sql(better-sqlite3:memory:) anddriver-sqlite-wasm, with a realObjectQLengine, a realSecurityPlugin, a realIRlsMembershipResolverpublishingcurrent_user.employer_org_ids, and the #16608 policy shape:The app stamps the denormalised scoping field on BOTH write events, the way ats does — a
runAs: 'system'handler that reads the parent OUTSIDE RLS and overwritesemployer_orgfrom it, whatever the caller sent.Caller holds
employer_org_ids: ['org_a']. The rowmem_ustarts inside that scope (employer: 'emp_a',employer_org: 'org_a'), so theusingpre-image gate lets the caller touch it. The caller then repoints the parent atemp_b, which belongs toorg_b, while sending an in-scope value for the checked field:emp_b— recorded, not assumed{ id: 'mem_u', employer: 'emp_b', employer_org: 'org_b' }The row has been moved into an organization the caller does not hold. The
checkwas satisfied byorg_a, a value that never landed.Why
In the write gate (
packages/plugins/plugin-security/src/security-plugin.ts, step 3.6) the update post-image is built as{ ...preImage, ...opCtx.data }inside the middleware — which runs BEFORE the engine's operation, and therefore beforebeforeUpdate. The merge closes the "a check on an unchanged field still sees its value" hole (#1994's twin); it does not close the "a hook rewrites the checked field after the merge" hole, which is #16608's sentence exactly: the check judges a value that never lands and ignores the one that does.What it is NOT
Not #16344 (a readonly field stripped from persistence but still reaching
beforeUpdate) — that is about which values a hook may READ. This is about which image thecheckis evaluated against. Not #14099 (one hook-mutated payload applied to every matched row on amulti: trueupdate) — this reproduces on the by-id path, which is the only path thecheckgate post-image-validates at all.Scope note
#16608's PR does NOT fix this and deliberately does not try. Its ruling names the insert side; extending the evaluation point to the update verb is a second accept/refuse change on a verb the ruling treated as the correct reference, so it wants its own decision rather than riding in as a bounded in-place fix. The mechanism #16608 introduces is reusable for it: the engine seam
OperationContext.postHookWriteImageCheck, whichinsert()calls once thebeforeInsertchain has produced the row. The update path would need the same call site afterbeforeUpdate, with the middleware's already-read caller pre-image captured in the closure and merged against the post-hook change set. Thecheckgate skips predicate (multi: true) updates today and would continue to; only the by-id path is in question.Whoever picks this up should note the priority argument from #16608: a check that admits a row whose stored scope the caller does not hold is a cross-organization write, and #16608 was regraded to
priority:p1on exactly that measurement.