docs(audits): census of same-key/per-row-value beforeUpdate rewrites — zero in-repo, guard precision 0/5 - #15301
Conversation
…14744] Measurement only — implements no guard, changes no write shape, opens no ADR, and edits neither engine.ts nor multi-update-hook-key-divergence.ts. Two independent instruments, each with a firing positive control: - scripts/audits/14744-before-update-per-row-value-census.mjs — static AST enumeration and taint classification of every in-repo beforeUpdate registration (--self-test: 10/10, both directions). - scripts/audits/14744-before-update-per-row-value-probe.mjs — runtime behavioural probe dispatching the real handlers per row of a genuine multi: true update on the real engine, reading the actual SET clause. Result: the in-repo population of same-key / per-row-VALUE rewrites is ZERO across 23 production registration sites. The candidate provenance guard fires on 5 of them and none is an instance (precision 0/5): every in-repo pre-image read decides WHETHER to write, not WHAT to write, and that is already caught by #14099's key-set refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
|
Out-of-scope finding filed while measuring: #15302 — three provenance-stamp ⛔ Not fixed in this PR and ⛔ not an instance of #14744's residue — the value they write is the constant Generated by Claude Code |
`scripts/audits/14744-before-update-per-row-value-census.mjs` declares `runSelfTest`, whose name the compound self-test anchor matches, so the ledger owed it a row and `check:pm-dispatch-gates` was red without one. Classified `accidental: false` — it is a genuine self-test battery, on the same evidence the ledger's own docblock uses for the two existing `runSelfTest` rows it calls genuine: not exported, reached only from the file's `--self-test` guard, and it spawns no other script's self-test. Its inline fixture sources SHOULD be masked away, which is what that classification buys. ⛔ The self-test is not renamed or removed to dodge the anchor — the ledger's docblock refuses that repair explicitly, and this particular self-test is the census's own evidence (10/10 both directions, carrying the firing positive control). Part of #14744 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
ACCEPT — flipping ready and enqueuing. Clause-② is
|
Part of #14744
Measurement only. ⛔ No guard is implemented, no write shape is changed, no ADR is opened or amended, and
packages/objectql/src/engine.tsandpackages/objectql/src/multi-update-hook-key-divergence.tswere read but not edited — per triage's terminal scoping on the card, which put the fix on the human floor because closing this residue reverses ADR-0058 Addendum II D3.Answer
The in-repo population of same-key / per-row-value
beforeUpdaterewrites is ZERO, across 23 production registration sites, derived two independent ways that agree on every subject.The candidate provenance guard fires on 5 of the 23, and none of the 5 is an instance — precision 0/5 on the population it would ship to catch. The structural reason is the main finding: the guard's predicate conflates reading the pre-image to decide whether to write with reading it to compute what to write, and only the second is this residue. Every in-repo pre-image read is the first kind, and a whether-decision that differs between rows is a key-set divergence — already refused by #14099. So on today's tree the guard would refuse only batches #14099 already refuses, plus honest ones.
What is added
docs/audits/2026-09-multi-update-per-row-value-census.mdscripts/audits/14744-before-update-per-row-value-census.mjs--self-test)scripts/audits/14744-before-update-per-row-value-probe.mjs--out path.json)scripts/pm/dispatch-gates.mjsrunSelfTestinCOMPOUND_ANCHOR_LEDGER(see Patch round)Instrument A — static
An AST walk, not grep, and the difference is load-bearing: on this tree a single-line
git grep "registerHook('beforeUpdate'"finds 14 sites while 46 furtherregisterHook(sites are multi-line or take the event through a variable. A line-oriented census would have under-counted by construction.Classification is taint over an alias lattice that propagates across calls —
sys_stamp_audit_updatewrites the payload only throughstampData(hookCtx.input.data, …), so the first revision, which followed only the context, scored the most important handler in the population as "writes nothing". Recorded in the doc because the mistake is available to any re-derivation.--self-testis 10/10 in both directions, including the card's own pinned residue as a firing positive control and the guard's over-fire shape as a negative one.Instrument B — runtime, on the real handlers
Boots the real
ObjectQLagainst a stub driver and dispatches handlers per row of a genuinemulti: trueupdate, reading what actually reachesdriver.updateMany. Four of eight subjects are the shipped handlers themselves, imported and dispatched unmodified (task_logic, and the plugin-email / plugin-sharing / plugin-webhooks provenance stamps); the audit stamp and pinyin projection are labelled replicas, following the pin suite's own convention.Two scenarios per subject — rows that disagree on the pre-image field, and rows that agree — because one batch cannot separate the residue from the clock. The guard is evaluated as an observer, never as enforcement.
sys_stamp_audit_updateexamples/app-todotask_logicThe positive control also reproduces the defect end-to-end on current
main: rowaderivedpriority: 'high', rowbderived'low', the singleSETclause carried'low', and both rows stored'low'. That confirms the card's correction to the #14099 ruling's prose — the surviving value is the last dispatch's, not the first.Blind spots, stated rather than assumed away
Enumerated by the instrument itself, not asserted: 7 production registrations take a non-literal event argument (each read by hand —
webhook-headers-gate.ts:313is a realbeforeUpdatethe walk does not count, and its handler only throws);record-change-trigger.ts:299bindsbeforeUpdatefrom user-authored flow metadata, the one open door, resolved only by source reading; no metadata-declared hooks exist in-tree; 0 TypeScript files mentioning the event outside the walked roots. ⛔ And the largest blind spot is not in the tree at all — a zero in-repo is not a zero in the field, and theduly_taskcorruption that motivated #14099 was measured downstream.Patch round — the gate this diff's own KIND tripped
Lint & Repo Gateswent red atfa01034f8on this PR's own failure:Reproduced verbatim before any fix, then fixed, then shown passing — the same line now reads
✓, verdict✓ dispatch-gates self-test: 1371 cases pass, exit 0, zero✗in the whole run.pnpm check:pm-dispatch-gatesexit 0.Fixed by registering the self-test (
accidental: false), classified on the same evidence the ledger uses for the two existingrunSelfTestrows it calls genuine: not exported, reached only from the file's--self-testguard, spawns no other script's self-test, and holds inline fixtures that should be masked. ⛔ The self-test was not renamed or removed to dodge the anchor — the ledger's own docblock refuses that explicitly, and doing so would have gutted this card's evidence, since that self-test is the 10/10 both-directions run carrying the firing positive control.⭐ Why the original 25-family derivation could not have caught this.
dispatch-gates.mjsprints this class under Convention-triggered gates, naming "adds or edits a GATE SCRIPT (a file some discovered check family runs)" — judged from the change's KIND, not from a path filter. The tool maps paths → gates, so a path derivation cannot reach it by construction. The 25-family list reported before the patch round was honestly derived and correct for what a path derivation can see.Verification
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no path arguments) re-derived 28 runnable families for the now-4-path change set — up from 25, the gate-script edit addingcheck:declared-population-live,check:pm-dispatch-gatesandcheck:ratchet-remedy-authority. All 28 exit 0 ataf1826708, exit codes captured by redirecting to a file before any pipe.main,check:doc-formula-expressionsexited 3 withPREREQUISITE NOT MET — @objectstack/formula is not built— which the gate itself labels "Nothing was measured … NOT a finding", giving exit 3 precisely to distinguish it from a finding's 1. Resolved by the AGENTS.md §9 post-merge build refresh (@objectstack/formula, then@objectstack/lint, a second prerequisite the gate named only after the first was met). It then became a real measurement: exit 0, 58 self-test cases, 22 record-scoped formula examples across 427 files / 1369 TS blocks judged clean.Gate-script clause: because the diff now edits a gate script, its own suite was run beyond the derived families — that script's
--self-testpluscheck-dispatch-gates.mjs, both above. The 5 test files that grep-matchdispatch-gateswere read and are incidental docblock cross-references in unrelated suites, not tests of this script.Regression check on the deliverable itself at
af1826708: instrument A--self-teststill passes 10/10 in both directions with its firing positive control, and the census numbers are unchanged — 23 production sites,writesPayload8,readsPreImage8,guardWouldFire5,taintedWrite/INSTANCE 0, unclassified 0.Full-repo
pnpm lint(eslint . --no-inline-config): 5884 files, 0 errors, 0 warnings, exit 0.check:parse-guardcaught a real defect in the first draft — rawts.createSourceFilecalls that would have scored an unparseable file as "no handlers found". All parses now route throughscripts/ts-parse.mjs'sparseSourceFile; the counts are identical through the checked parser and the run completes without refusal, which is the positive evidence that no file went unread.Sync: merged
origin/mainand verified the merge-base actually advanced twice this round —0e24b0c2c→5b09356b7. The ledger row survived the merge and the contribution todispatch-gates.mjsis still exactly +1 line.No changeset: this publishes nothing from any released package — the surface is
docs/audits/**,scripts/audits/**and one line ofscripts/pm/dispatch-gates.mjs, and touches nopackages/path at all. Labelledskip-changeset.Why this PR says
Part of, not a closing keywordcheck:partof-closing-keywordrun against the live PR body exits 0 — "this PR carries no Part-of/closing-keyword contradiction" — and no commit message on this branch carries a closing keyword for #14744. ThePart-of PR must not also close its cardcheck is green.Generated by Claude Code