diff --git a/docs/audits/2026-09-multi-update-per-row-value-census.md b/docs/audits/2026-09-multi-update-per-row-value-census.md new file mode 100644 index 0000000000..30ec7d65e7 --- /dev/null +++ b/docs/audits/2026-09-multi-update-per-row-value-census.md @@ -0,0 +1,355 @@ +# `beforeUpdate` same-key / per-row-value census — #14744 + +**Date:** 2026-09-04 · **Base:** `origin/main` `0e24b0c2c` (measured on the merge commit +`671f53ff3`) · **Scope:** measurement only — ships nothing, changes no shipped package's +behaviour, and implements no guard. This is the census triage scoped on #14744 (comment +[5518629055](https://github.com/objectstack-ai/objectstack/issues/14744#issuecomment-5518629055)), +whose output is the input to the write-shape decision that follows. + +> **The terminal scope, verbatim (triage, comment 5518629055):** "Dispatchable scope, in +> `packages/objectql/**`: 1. Count the in-repo population of same-key / per-row-**value** +> `beforeUpdate` rewrites. Derive it programmatically; ⛔ do not hand-count and do not +> extrapolate from the three known row-invariant ones. 2. Measure whether the +> pre-image-read + payload-write provenance guard over-fires on the handlers that actually +> exist … ⛔ **Do not implement the guard, and do not change the write shape.** If your +> measurement says the guard is clean and cheap, that is a *finding to report*, not a +> licence to land it — the write-shape question is ADR territory either way." + +⛔ Accordingly: no guard is implemented here, no write shape is changed, no ADR is opened +or amended, and `packages/objectql/src/engine.ts` and +`packages/objectql/src/multi-update-hook-key-divergence.ts` were read but not edited. + +--- + +## Answer in one line + +**The in-repo population of same-key / per-row-value `beforeUpdate` rewrites is ZERO**, out +of 23 production `beforeUpdate` registration sites, derived two independent ways that agree +on every subject and each carrying a firing positive control. **The candidate provenance +guard fires on 5 of those 23, and none of the 5 is an instance — a precision of 0/5 on the +population it would be shipped to catch.** The structural reason, and the census's main +finding for the decision: 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 the first kind is +**already** caught by #14099's key-set refusal, because a per-row decision about whether to +write is exactly what makes key sets diverge. **On today's tree the guard would refuse only +batches #14099 already refuses, plus honest ones.** ⭐ A well-derived zero was named in +advance as a complete result; this is that zero, with the derivation and its blind spots +below. + +--- + +## 1. The predicate — what counts as an instance, and how it is decided + +"Same key, per-row value" is a property of a handler's *behaviour*, not of a syntactic +pattern, so the predicate is stated here to be argued with rather than left implicit in a +script. + +A `beforeUpdate` handler is an **instance** iff, on one `multi: true` update matching two or +more rows: + +| clause | | why it is in the predicate | +|---|---|---| +| (a) | it **writes** the payload (`ctx.input.data`) | a handler that only reads, or only throws, cannot move a `SET` clause | +| (b) | the **set of keys** it writes is the same for every row | if the key sets differ, #14099's refusal already catches it — that batch is not this residue | +| (c) | the **value** it writes for some key is derived from **per-row state** (`ctx.previous`, `ctx.input.id`, or anything carried from them) | this is the discriminating clause | + +**Clause (c) deliberately excludes a value that varies per row because the handler read a +clock.** `sys_stamp_audit_update` writes a different `updated_at` for each row of an +entirely honest batch, and whichever row's copy the single `SET` clause carries is still +true. Refusing that is the non-deterministic failure that killed the value-comparison +variant twice on #14099; a census that scored it as an instance would be re-proposing the +rejected instrument under a new name. + +Separating (c) from the clock is the whole difficulty, and the two instruments settle it +differently on purpose — one by **provenance** (static taint), one by **behaviour** (a +second batch in which the pre-images are equal, so any surviving value difference is not +attributable to the row). + +--- + +## 2. Instrument A — static enumeration and classification + +`scripts/audits/14744-before-update-per-row-value-census.mjs` (`node …`; `--self-test` runs +the controls). + +**Why an AST walk and not grep.** Measured on this tree, a single-line +`git grep "registerHook('beforeUpdate'"` finds **14** call sites, while **46** further +`registerHook(` sites are multi-line, take the event through a variable, or are interface +declarations. A line-oriented census would have under-counted the population *by +construction* and reported a confident number. The walk reads the argument's **position** +in the syntax tree instead. + +**Every parse is routed through `scripts/ts-parse.mjs`'s `parseSourceFile`, not +`ts.createSourceFile`.** A raw parse never throws — the errors are parked on +`parseDiagnostics` and the recovered tree walks like any other — so a file this census +could not read would be scored as a file with **no** `beforeUpdate` handlers and would +quietly lower the population. `pnpm check:parse-guard` caught exactly that in the first +draft of this instrument. Re-run through the checked parser the counts are **identical**, +and the run completes without refusal, which is the positive evidence that no file in the +walked roots went unread. + +**Doors it enumerates:** `registerHook('beforeUpdate', …)`, `on('beforeUpdate', …)`, and +Hook-shaped object literals (`{ events: ['beforeUpdate'], handler }`) — the metadata shape +`bindHooksToEngine` binds and the shape objectql's own builtins use. + +**Classification** is taint over a per-function alias lattice: payload and pre-image each +seed an alias set, the sets grow through local bindings, destructuring and `for…of`, and +they **propagate across calls**. That last part is load-bearing rather than a refinement — +`sys_stamp_audit_update` writes the payload only through +`stampData(hookCtx.input.data, …)` → `applyToRecord(record, …)`, so the first revision of +this script, which followed only the *context*, scored the single most important handler in +the population as "writes nothing". The correction is recorded because the same mistake is +available to any re-derivation. + +### 2.1 Result — all 23 production sites + +`writesPayload` 8 · `readsPreImage` 8 · `guardWouldFire` 5 · **`taintedWrite` (instance) 0** · +unclassified 0 · payload wholesale-replacement 0. + +| site | hook | writes payload | reads pre-image | guard fires | **instance** | +|---|---|:--:|:--:|:--:|:--:| +| `packages/objectql/src/plugin.ts:1137` | `sys_stamp_audit_update` | Y | n | – | no | +| `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts:332` | (anon) | n | Y | – | no | +| `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts:603` | (anon) | n | n | – | no | +| `packages/plugins/plugin-audit/src/audit-writers.ts:1457` | (anon) | n | n | – | no | +| `packages/plugins/plugin-audit/src/audit-writers.ts:1503` | (anon) | n | n | – | no | +| `packages/plugins/plugin-audit/src/comment-access-hooks.ts:446` | (anon) | n | Y | – | no | +| `packages/plugins/plugin-auth/src/identity-write-guard.ts:230` | (anon) | n | n | – | no | +| `packages/plugins/plugin-auth/src/last-admin-guard.ts:1574` | (anon) | n | n | – | no | +| `packages/plugins/plugin-auth/src/last-admin-guard.ts:1584` | (anon) | n | n | – | no | +| `packages/plugins/plugin-auth/src/last-admin-guard.ts:1594` | (anon) | n | n | – | no | +| `packages/plugins/plugin-auth/src/last-admin-guard.ts:1604` | (anon) | n | n | – | no | +| `packages/plugins/plugin-auth/src/last-admin-guard.ts:1609` | (anon) | n | n | – | no | +| `packages/plugins/plugin-auth/src/member-role-canonical.ts:268` | (anon) | Y | n | – | no | +| `packages/plugins/plugin-email/src/email-template-provenance.ts:54` | (anon) | Y | Y | **FIRES** | no | +| `packages/plugins/plugin-pinyin-search/src/companion-projection.ts:97` | (anon) | Y | n | – | no | +| `packages/plugins/plugin-sharing/src/rule-hooks.ts:257` | (anon) | n | n | – | no | +| `packages/plugins/plugin-sharing/src/rule-hooks.ts:355` | (anon) | n | n | – | no | +| `packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts:42` | (anon) | Y | Y | **FIRES** | no | +| `packages/plugins/plugin-webhooks/src/webhook-provenance.ts:45` | (anon) | Y | Y | **FIRES** | no | +| `packages/services/service-storage/src/attachment-access-hooks.ts:346` | (anon) | n | Y | – | no | +| `packages/services/service-storage/src/file-reference-lifecycle.ts:696` | (anon) | Y | Y | **FIRES** | no | +| `examples/app-crm/src/hooks/opportunity.hook.ts:9` | `opportunity_stage_probability` | n | n | – | no | +| `examples/app-todo/src/objects/task.hook.ts:50` | `task_logic` | Y | Y | **FIRES** | no | + +**The three rewrites #14099 measured are all here and all confirmed non-instances**, which +is the check that the instrument is pointed at the right population: the audit stamp writes +five keys and never reads the pre-image; the pinyin companion derives `__search` from the +**payload's own** values; service-storage's copy-on-claim reads the pre-image but writes no +per-row-derived value (§4.1 on why it cannot). + +### 2.2 The controls — `--self-test`, 10/10 + +A zero is not a result without a firing positive control, and a predicate that is trivially +false is not a predicate. Both directions are pinned, and the suite fails if any two shapes +collapse into one: + +``` +PASS POSITIVE — the card's own pinned residue (value from ctx.previous) +PASS POSITIVE — value derived from the per-row id +PASS POSITIVE — taint carried through a local binding +PASS POSITIVE — taint across a helper handed payload AND pre-image +PASS POSITIVE — Hook metadata literal door, not registerHook() +PASS NEGATIVE — audit-stamp shape: writes via a payload-passing helper, clock value, no pre-image read +PASS NEGATIVE — CONSTANT value gated on a pre-image read (the guard's over-fire shape) +PASS NEGATIVE — reads the pre-image but never writes the payload (a pure guard) +PASS NEGATIVE — writes a value derived only from the PAYLOAD (row-invariant) +PASS CONTROL — a beforeInsert registration must not be counted at all (sites=0, expected 0) + +SELF-TEST PASSED — 10/10 cases +``` + +--- + +## 3. Instrument B — runtime behavioural probe on the real engine + +`scripts/audits/14744-before-update-per-row-value-probe.mjs` +(`npx tsx … --out `). It boots the real `ObjectQL` against a stub driver, +dispatches handlers per row of a genuine `multi: true` update, and reads what actually +reaches `driver.updateMany` — the one `SET` clause D3 gives N rows. **Four of its eight +subjects are the shipped handlers themselves, imported and dispatched unmodified**; the +audit stamp and the pinyin projection are labelled replicas, following the pin suite's own +convention (`multi-update-hook-key-divergence.test.ts` §3 replicates the stamp with +`perRowClockStamp()` rather than booting the plugin). + +**Two scenarios per subject, and why two are needed.** A single batch whose rows disagree +cannot separate the residue from the clock: "same keys, different values" is equally +consistent with a value derived from the row and with a value that simply differs every time +it is computed. So each subject runs over rows that **disagree** on the pre-image field it +reads, and again over rows that **agree**. With the pre-images equal, any surviving value +difference is not attributable to the row. + +The guard is evaluated as an **observer, never as enforcement**: each dispatch receives a +read-recording `Proxy` over its context, and the payload is handed back behind a +write-recording proxy, so `guardWouldFire` is a measured property of the shipped handler's +execution rather than a reading of its source. Nothing in the probe registers on, or alters, +the engine's behaviour. + +### 3.1 Verdicts + +| subject | real? | verdict | guard fires | +|---|:--:|---|:--:| +| POSITIVE CONTROL — the card's pinned residue | replica | **INSTANCE** | yes | +| `sys_stamp_audit_update` (clock in the per-record stamp) | replica | NONDETERMINISTIC | **no** | +| `examples/app-todo` `task_logic` | **real** | CAUGHT_BY_14099 | yes | +| plugin-email template provenance stamp | **real** | CAUGHT_BY_14099 | yes | +| plugin-sharing rule provenance stamp | **real** | CAUGHT_BY_14099 | yes | +| plugin-webhooks provenance stamp | **real** | CAUGHT_BY_14099 | yes | +| pinyin companion projection (value from the payload) | replica | ROW_INVARIANT | no | +| NEGATIVE CONTROL — reads the pre-image, never writes | replica | NOT_A_PAYLOAD_WRITER | no | + +**Instances among real handlers: 0. Positive control: fires.** + +### 3.2 The residue itself, reproduced end-to-end on current `main` + +The positive control is not only a control — it is the card's defect, re-measured on the +real engine at this base: + +``` +dispatch a derived {priority: 'high'} ← row a's own pre-image says 'blocked' +dispatch b derived {priority: 'low'} +SET clause [{title: 'renamed', priority: 'low'}] ← ONE payload, D3 +stored a → priority 'low' b → priority 'low' ← row a's own derivation discarded +``` + +Nothing errored and nothing was refused. This also **confirms the card's correction to the +#14099 ruling's prose**: the value that survives is the **last** dispatch's, not the first. + +### 3.3 Where the two instruments disagree + +**Nowhere, on any subject measured both ways.** Instrument A's `guardWouldFire` and +instrument B's measured `guardFires` agree on all six shapes common to both, A's +`taintedWrite = 0` matches B's `INSTANCE = 0` over the real handlers, and both flag the +positive control. The one production site A flags that B does not exercise is +service-storage copy-on-claim (§4.1) — B would need the storage service; it is resolved by +source reading instead, and that is recorded as a reading rather than a measurement. + +⚠️ **Two instrument bugs were found and fixed while measuring, both of which had silently +changed a verdict.** They are recorded because each is available to any re-derivation: (1) +the observer originally recorded value *changes* rather than *assignments* — but the payload +is ONE object shared by every row's dispatch, so the second row assigning the same value it +found there produced no diff and was scored "wrote nothing", manufacturing a key-set +divergence out of a row-invariant handler; assignment is also exactly what #14099's own +recorder counts, so recording it keeps the instruments comparable. (2) An ESM-interop miss +on a default-exported Hook meant `task_logic` was never dispatched at all, and the subject +scored clean; the probe now resolves that import **by shape** and throws if no handler is +found, so the failure cannot present as a pass. + +--- + +## 4. Deliverable 2 — does the provenance guard over-fire? + +**Yes, on every in-repo handler it fires on: 5 of 5 flagged, 0 instances, precision 0/5.** +For the four measured at runtime the two scenarios say it precisely: + +| handler | rows **disagree** | rows **agree** | +|---|---|---| +| plugin-email / plugin-sharing / plugin-webhooks provenance | engine already refuses — `MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, `400`, `keys: ['customized']`, `rows: 2` | every row writes `customized: true`; `SET` clause is correct and honest — **the guard would refuse this** | +| `task_logic` | engine already refuses — same envelope, `keys: ['completed_date']`, `object: todo_task` | every row writes the same `completed_date`; batch honest — **the guard would refuse this** | + +So in the disagreeing case the guard is **redundant** (#14099 refuses first, before any +write), and in the agreeing case it is a **pure false positive**. On this tree the guard +would not refuse a single batch that is actually corrupted. + +### 4.1 The structural reason — the finding the decision should carry + +Every in-repo pre-image read is a read *to decide **whether** to write*, never *to compute +**what** to write*: + +- the three provenance stamps write the **constant** `true` (`customized`), gated on the + row's `managed_by`; +- `task_logic` writes a **clock** or the constant `null`, gated on `previous.status`; +- copy-on-claim reads `ctx.input.id` only to distinguish the by-id path, and on a per-row + dispatch it **refuses the batch itself** (`FileFieldBulkWriteError`, #7102) and then + no-ops for every row after the first — it is structurally incapable of being an instance, + and worth flagging as an existing in-tree precedent for handler-side refusal. + +A whether-decision that differs between rows **is** a key-set divergence, which is what +#14099 already refuses. So the guard's extra reach over #14099 consists entirely of batches +in which every row made the same whether-decision — i.e. the honest ones. + +⭐ **The corollary that matters for the write-shape decision:** the residue is not reachable +by *any* predicate over "which per-row state did the handler read", because the in-repo +population reads per-row state for a purpose that is already covered. Closing it needs a +statement about the *value's* provenance (which key the handler assigned *from* what), and +that is a different instrument from the one the card proposed — or it needs the write shape +to change, which is ADR-0058 Addendum II D3 and the human floor. + +### 4.2 One thing the guard gets right, worth keeping on the record + +The guard does **not** fire on `sys_stamp_audit_update` — the hook registered on `'*'` in +essentially every deployment, and the exact hook whose per-row clock reads killed the +value-comparison variant. Confirmed by both instruments (it never touches `ctx.previous` or +`ctx.input.id`). So the guard really is free of the measurement that sank value comparison; +its problem is precision on the population, not non-determinism. + +--- + +## 5. Blind spots — what these numbers cannot see + +⛔ Stated because "I counted N" without "here is what this count cannot see" is not a +measurement this card can use. The first three are enumerated by the instrument itself +(`blindSpots` in its JSON), not asserted here. + +1. **Registrations whose event argument is not a string literal — 7 in production.** Each + was read by hand: `engine.ts:13976` (the `on()` forwarding alias) and `plugin.ts:1225` + (the builtins' `events[]` loop, already covered by the literal door) are doors, not + handlers; `bu-tree-recompute.ts:301` and `sharing-plugin.ts:470` loop over + `after*` events only. Two are real: + - `webhook-headers-gate.ts:313` loops `['beforeInsert', 'beforeUpdate']` — **a + `beforeUpdate` registration instrument A does not count.** Its handler is + `assertWritableWebhookHeaders`, which throws or returns and never assigns to the + payload, so it is a non-instance; the population figure is 23 counted + this one read + by hand. + - `record-change-trigger.ts:299` binds whatever `triggerTypeToHookEvents` returns, which + **includes `beforeUpdate`** for the `record-before-update` and `record-before-write` + trigger types. This is the one open door in the tree: the handler is generic and the + behaviour belongs to **user-authored flow metadata**, which is not in this repo. On a + source reading, `buildContext` materialises a *new* record object by overlay rather + than handing the flow `ctx.input.data` by reference, so a flow bound this way does not + reach the batch payload through that path — ⚠️ a reading, not a measurement; I did not + exercise it at runtime, and #14758 (sandbox write-back) is the adjacent open question. +2. **Metadata-declared hooks are invisible to a syntax tree.** Hooks can arrive as stored + `sys_metadata` rows or JSON/YAML object definitions and be bound at boot by + `bindHooksToEngine`. The instrument swept every `.json`/`.yaml`/`.yml`/`.js`/`.mjs`/`.cjs` + file in the repo for `beforeUpdate` and found only spec JSON-Schema artifacts and two QA + checklist files — **no metadata-declared hook exists in-tree today** — but this says + nothing about a deployment's stored metadata. +3. **Roots.** `packages/`, `examples/`, `apps/` were walked; a repo-wide sweep found **0** + TypeScript files mentioning `beforeUpdate` outside them. +4. **One unresolved cross-file delegation**, `rule-hooks.ts:257` → + `stashAffectedRows` (`bulk-recompute.ts:291`), resolved by hand: that file contains + **zero** `input.data` writes, so it is not a payload writer. +5. **The taint analysis is intra-file.** A handler delegating to an *imported* helper is + followed only within its own module; instrument A reports every such case rather than + scoring it clean, and the only one that occurred is item 4. +6. **⛔ The largest blind spot is not in the tree at all.** #14099's confidence-gap note and + this card both say the population may be entirely downstream, and this census cannot + contradict that — it measures **this repository**. The `duly_task` corruption that + motivated #14099 was measured against a *downstream* deployment. **A zero in-repo is not + a zero in the field**, and §4.1's structural argument is about the handlers here, not + about handlers a customer may write. + +--- + +## 6. What the follow-on decision needs from this + +- **Scale, in-repo: zero.** No shipped or example handler in this repository would be caught + by closing this residue, so no in-repo migration cost attaches to any option, and the + urgency has to come from downstream evidence rather than from this tree. +- **The proposed provenance guard is measured and it does not work on this population** — + 0/5 precision, redundant with #14099 where it is right and false-positive where it is not. + ⛔ It is not implemented here, per the card's terminal scope; this is the finding, not a + licence. +- **The three known rewrites remain non-instances**, confirmed programmatically rather than + assumed from #14099. +- **An existing precedent for handler-side refusal is in the tree**: service-storage's + `FileFieldBulkWriteError` (#7102) refuses exactly this hazard for its own surface, from + inside the handler, without the engine splitting any write. Whatever the decision, that + shape is prior art worth reading — it is the only in-repo code that already closes the + residue for the surface it owns. + +--- + +_Generated by [Claude Code](https://claude.ai/code)_ diff --git a/scripts/audits/14744-before-update-per-row-value-census.mjs b/scripts/audits/14744-before-update-per-row-value-census.mjs new file mode 100644 index 0000000000..893ca45067 --- /dev/null +++ b/scripts/audits/14744-before-update-per-row-value-census.mjs @@ -0,0 +1,655 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14744] Census instrument A — STATIC enumeration + classification of every + * in-repo `beforeUpdate` handler, for the residue #14099's key-set refusal + * deliberately leaves open: a handler that writes the SAME key on every row + * with a PER-ROW VALUE. + * + * ## Why an AST walk rather than grep + * + * The registration spelling varies far more than a line-oriented pattern can + * follow. Measured on `origin/main` 4dd5041bd: a single-line + * `git grep "registerHook('beforeUpdate'"` finds 14 call sites, while 46 + * further `registerHook(` sites are multi-line, take the event through a + * variable, or are interface declarations. A grep census would therefore have + * under-counted the population by construction and reported a confident number. + * This walks the syntax tree, so the argument's POSITION is what is read, not + * its position on a line. + * + * ## The predicate this implements, stated so it can be argued with + * + * An INSTANCE is a `beforeUpdate` handler for which, on one `multi: true` + * update matching two or more rows: + * + * (a) the handler writes the payload (`ctx.input.data`), and + * (b) the SET of keys it writes is the same for every row — otherwise + * #14099's refusal already catches it and it is not this residue, and + * (c) the VALUE it writes for at least one of those keys is derived from + * PER-ROW state — `ctx.previous`, `ctx.input.id`, or anything carried + * from them. + * + * (c) is the discriminating clause and it is a DATAFLOW property, not a + * syntactic one. This pass computes it as taint over a per-function alias + * lattice: the payload and the pre-image each seed an alias set, the sets grow + * through local bindings, destructuring and `for…of`, and they PROPAGATE ACROSS + * CALLS — a helper handed the payload (rather than the context) is analysed + * with the payload seeded on that parameter. That last part is not a + * refinement: `sys_stamp_audit_update`, the hook registered on `'*'` in every + * deployment, writes the payload only through `stampData(hookCtx.input.data,…)` + * → `applyToRecord(record,…)`, so a classifier that followed only the context + * scores the single most important handler in the population as "writes + * nothing". The first revision of this script did exactly that. + * + * ⚠️ Deliberately NOT part of (c): a value that varies per row because the + * handler read a CLOCK inside the dispatch (`sys_stamp_audit_update`'s + * `updated_at`). Such a value differs per row but is honest whichever row's + * copy wins, and refusing it is precisely the non-deterministic failure that + * killed the value-comparison variant twice on #14099. Instrument B separates + * the two behaviourally by re-running each handler against an IDENTICAL + * pre-image; this pass separates them by provenance. + * + * ## The candidate guard this also scores + * + * `guardWouldFire` = writes the payload AND reads the pre-image — the + * provenance instrument #14744 asks to be measured for over-fire. It is + * deliberately a WIDER predicate than `taintedWrite`: the gap between the two + * counts is the guard's false-positive population, which is the answer to + * deliverable (2). + * + * ## Output + * + * JSON on stdout: every registration site, its resolved handler, the flags + * above, and an explicit `resolution` / `delegatesUnresolved` field naming the + * cases the walk could NOT follow — those are the blind spots the census + * reports rather than silently scoring as clean. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +// ⛔ Never `ts.createSourceFile` directly. It does not throw on a source it +// cannot read — the errors are parked on `parseDiagnostics` and the recovered +// tree walks like any other, so a file this census could not parse would be +// scored as a file with NO `beforeUpdate` handlers and quietly lower the +// population. `parseSourceFile` reads those diagnostics and refuses loudly. +// `pnpm check:parse-guard` enforces this; `scripts/ts-parse.mjs` is the +// authority on why. +import { parseSourceFile } from '../ts-parse.mjs'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const REPO = join(HERE, '..', '..'); + +const ROOTS = ['packages', 'examples', 'apps']; +const SKIP_DIR = new Set(['node_modules', 'dist', '.turbo', 'build', 'coverage', '.next', '.cache']); +const EVENT = 'beforeUpdate'; + +/** Property names that, read off the hook context, are PER-ROW state. */ +const PREIMAGE_PROPS = new Set(['previous', 'previousRecord', 'record', 'existing', 'oldRecord']); +/** `ctx.input.` reads that are per-row under per-row dispatch. */ +const PREIMAGE_INPUT_PROPS = new Set(['id', 'ids']); + +function walkFiles(dir, out) { + let entries; + try { entries = readdirSync(dir); } catch { return out; } + for (const name of entries) { + if (SKIP_DIR.has(name)) continue; + const full = join(dir, name); + let st; + try { st = statSync(full); } catch { continue; } + if (st.isDirectory()) walkFiles(full, out); + else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) out.push(full); + } + return out; +} + +const lineOf = (sf, node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; + +/** Strip `!`, `as X`, parens, `await` so pattern matching sees the core expression. */ +function unwrap(node) { + let n = node; + for (;;) { + if (ts.isParenthesizedExpression(n) || ts.isAsExpression(n) || ts.isNonNullExpression(n) + || ts.isAwaitExpression(n) || (ts.isSatisfiesExpression && ts.isSatisfiesExpression(n))) { + n = n.expression; + } else break; + } + return n; +} + +/** Text of a property-access chain, `a.b.c`, ignoring optional-chaining tokens. */ +function chain(node) { + const n = unwrap(node); + if (ts.isIdentifier(n)) return n.text; + if (ts.isPropertyAccessExpression(n)) { + const base = chain(n.expression); + return base === null ? null : `${base}.${n.name.text}`; + } + return null; +} + +/** Every function-ish node in a file, keyed by the name it is reachable under. */ +function indexFunctions(sf) { + const byName = new Map(); + const visit = (node) => { + if (ts.isFunctionDeclaration(node) && node.name) byName.set(node.name.text, node); + if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name) && node.initializer) { + byName.set(node.name.text, unwrap(node.initializer)); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return byName; +} + +function paramsOf(node) { + if (!node) return null; + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) + || ts.isMethodDeclaration(node)) return node.parameters; + return null; +} + +function bodyOf(node) { + if (!node) return null; + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isMethodDeclaration(node)) return node.body ?? null; + if (ts.isArrowFunction(node)) return node.body ?? null; + return null; +} + +const emptyResult = () => ({ + writesPayload: false, readsPreImage: false, taintedWrite: false, replacesPayload: false, + writtenKeys: new Set(), preImageReads: new Set(), payloadWriteSites: [], delegatesUnresolved: [], +}); + +function merge(into, sub, viaName) { + into.writesPayload ||= sub.writesPayload; + into.readsPreImage ||= sub.readsPreImage; + into.taintedWrite ||= sub.taintedWrite; + into.replacesPayload ||= sub.replacesPayload; + for (const k of sub.writtenKeys) into.writtenKeys.add(k); + for (const k of sub.preImageReads) into.preImageReads.add(k); + into.payloadWriteSites.push(...sub.payloadWriteSites.map((s) => (viaName ? { ...s, via: viaName } : s))); + into.delegatesUnresolved.push(...sub.delegatesUnresolved); +} + +/** + * Classify one function against a SEED descriptor saying which of its + * parameters already carry the payload, the pre-image, or the whole context. + * `{ ctx: 0 }` is the entry shape for a hook handler. + */ +function classify(fn, sf, byName, seeds, depth = 0, seen = new Set()) { + const res = emptyResult(); + const params = paramsOf(fn); + const body = bodyOf(fn); + if (!params || !body) return res; + + const payloadAliases = new Set(); + const preImageAliases = new Set(); + let ctxName = null; + + const nameAt = (i) => { + const p = params[i]; + return p && ts.isIdentifier(p.name) ? p.name.text : null; + }; + if (seeds.ctx != null) { + ctxName = nameAt(seeds.ctx); + if (ctxName) { + payloadAliases.add(`${ctxName}.input.data`); + payloadAliases.add(`${ctxName}.data`); + for (const p of PREIMAGE_PROPS) preImageAliases.add(`${ctxName}.${p}`); + for (const p of PREIMAGE_INPUT_PROPS) preImageAliases.add(`${ctxName}.input.${p}`); + } + } + for (const i of seeds.payload ?? []) { const n = nameAt(i); if (n) payloadAliases.add(n); } + for (const i of seeds.preImage ?? []) { const n = nameAt(i); if (n) preImageAliases.add(n); } + if (payloadAliases.size === 0 && preImageAliases.size === 0) return res; + + const underAny = (c, set) => { + if (c === null) return false; + if (set.has(c)) return true; + for (const root of set) if (c === root || c.startsWith(`${root}.`)) return true; + return false; + }; + const isPayloadExpr = (e) => { const c = chain(e); return c !== null && payloadAliases.has(c); }; + const isPreImageExpr = (e) => underAny(chain(e), preImageAliases); + const mentionsPreImage = (e) => { + let hit = false; + const v = (n) => { + if (hit) return; + if (isPreImageExpr(n)) { hit = true; return; } + if (ts.isIdentifier(n) && preImageAliases.has(n.text)) { hit = true; return; } + ts.forEachChild(n, v); + }; + if (e) v(e); + return hit; + }; + + // Grow the alias sets to a fixed point (bounded), so a declaration that + // follows its use — or a chain of them — still lands. + for (let round = 0; round < 3; round++) { + const growth = (node) => { + if (ts.isVariableDeclaration(node) && node.initializer) { + const init = unwrap(node.initializer); + if (ts.isIdentifier(node.name)) { + if (isPayloadExpr(init)) payloadAliases.add(node.name.text); + if (isPreImageExpr(init) || mentionsPreImage(init)) preImageAliases.add(node.name.text); + } else if (ts.isObjectBindingPattern(node.name)) { + const base = chain(init); + for (const el of node.name.elements) { + if (!ts.isIdentifier(el.name)) continue; + const prop = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text; + if (ctxName && base === `${ctxName}.input` && prop === 'data') payloadAliases.add(el.name.text); + if (ctxName && base === ctxName && PREIMAGE_PROPS.has(prop)) preImageAliases.add(el.name.text); + if (ctxName && base === `${ctxName}.input` && PREIMAGE_INPUT_PROPS.has(prop)) preImageAliases.add(el.name.text); + if (underAny(base, preImageAliases)) preImageAliases.add(el.name.text); + } + } + } + // `for (const row of data)` — iterating a payload yields payload rows. + if (ts.isForOfStatement(node) && node.initializer && ts.isVariableDeclarationList(node.initializer)) { + const d = node.initializer.declarations[0]; + if (d && ts.isIdentifier(d.name)) { + if (isPayloadExpr(node.expression)) payloadAliases.add(d.name.text); + if (isPreImageExpr(node.expression)) preImageAliases.add(d.name.text); + } + } + ts.forEachChild(node, growth); + }; + growth(body); + } + + const record = (node, keyText, valueExpr) => { + res.writesPayload = true; + if (keyText) res.writtenKeys.add(keyText); + const tainted = mentionsPreImage(valueExpr); + if (tainted) res.taintedWrite = true; + res.payloadWriteSites.push({ + line: lineOf(sf, node), key: keyText ?? '(computed)', tainted, + text: node.getText(sf).replace(/\s+/g, ' ').slice(0, 180), + }); + }; + + const visit = (node) => { + if (ts.isBinaryExpression(node) + && (node.operatorToken.kind === ts.SyntaxKind.EqualsToken + || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken + || node.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken)) { + const lhs = unwrap(node.left); + if (ts.isPropertyAccessExpression(lhs) && isPayloadExpr(lhs.expression)) { + record(node, lhs.name.text, node.right); + } else if (ts.isElementAccessExpression(lhs) && isPayloadExpr(lhs.expression)) { + const arg = unwrap(lhs.argumentExpression); + record(node, ts.isStringLiteral(arg) ? arg.text : null, node.right); + } else if (isPayloadExpr(lhs)) { + res.replacesPayload = true; + record(node, '(replaced)', node.right); + } + } + if (ts.isDeleteExpression(node)) { + const t = unwrap(node.expression); + if (ts.isPropertyAccessExpression(t) && isPayloadExpr(t.expression)) record(node, t.name.text, null); + } + if (ts.isCallExpression(node)) { + const callee = chain(node.expression); + if (callee === 'Object.assign' && node.arguments.length > 1 && isPayloadExpr(node.arguments[0])) { + for (let i = 1; i < node.arguments.length; i++) { + const src = unwrap(node.arguments[i]); + if (ts.isObjectLiteralExpression(src)) { + for (const p of src.properties) { + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name)) record(node, p.name.text, p.initializer); + else record(node, null, src); + } + } else record(node, null, src); + } + } + // Delegation: propagate whichever of payload / pre-image / ctx we hand on. + if (depth < 5) { + const fname = ts.isIdentifier(node.expression) ? node.expression.text : null; + if (fname && !['String', 'Number', 'Boolean', 'Array'].includes(fname)) { + const sub = { payload: [], preImage: [], ctx: null }; + node.arguments.forEach((a, i) => { + const u = unwrap(a); + const c = chain(u); + if (ctxName && c === ctxName) sub.ctx = i; + else if (isPayloadExpr(u)) sub.payload.push(i); + else if (isPreImageExpr(u) || (c !== null && preImageAliases.has(c))) sub.preImage.push(i); + }); + const hands = sub.ctx != null || sub.payload.length || sub.preImage.length; + if (hands) { + const target = byName.get(fname); + const key = `${fname}|${sub.ctx}|${sub.payload}|${sub.preImage}`; + if (target && paramsOf(target) && !seen.has(key)) { + seen.add(key); + merge(res, classify(target, sf, byName, sub, depth + 1, seen), fname); + } else if (!target) { + res.delegatesUnresolved.push({ + callee: fname, line: lineOf(sf, node), + hands: { ctx: sub.ctx != null, payload: sub.payload.length > 0, preImage: sub.preImage.length > 0 }, + }); + } + } + } + } + } + if (isPreImageExpr(node)) { res.readsPreImage = true; const c = chain(node); if (c) res.preImageReads.add(c); } + if (ts.isIdentifier(node) && preImageAliases.has(node.text)) { res.readsPreImage = true; res.preImageReads.add(node.text); } + ts.forEachChild(node, visit); + }; + visit(body); + return res; +} + +/** Resolve a handler ARGUMENT expression to a function node we can classify. */ +function resolveHandler(arg, sf, byName) { + const a = unwrap(arg); + if (ts.isArrowFunction(a) || ts.isFunctionExpression(a)) return { fn: a, how: 'inline' }; + if (ts.isIdentifier(a)) { + const t = byName.get(a.text); + if (t && paramsOf(t)) return { fn: t, how: `identifier:${a.text}` }; + return { fn: null, how: `unresolved-identifier:${a.text}` }; + } + if (ts.isCallExpression(a)) { + const name = ts.isIdentifier(a.expression) ? a.expression.text : chain(a.expression); + const factory = name ? byName.get(String(name).split('.').pop()) : null; + if (factory) { + const b = bodyOf(factory); + if (b) { + let found = null; + const v = (n) => { + if (found) return; + if (ts.isArrowFunction(n) || ts.isFunctionExpression(n)) { found = n; return; } + ts.forEachChild(n, v); + }; + v(b); + if (found) return { fn: found, how: `factory:${name}` }; + } + } + return { fn: null, how: `unresolved-factory:${name ?? '?'}` }; + } + if (ts.isPropertyAccessExpression(a)) return { fn: null, how: `unresolved-member:${chain(a) ?? '?'}` }; + return { fn: null, how: `unresolved:${ts.SyntaxKind[a.kind]}` }; +} + +/** Analyse one already-parsed source file, appending every registration site found. */ +function analyzeSource(sf, rel, isTest, sites) { + const byName = indexFunctions(sf); + + const emit = (node, door, fnNode, how, hookName) => { + const cls = fnNode ? classify(fnNode, sf, byName, { ctx: 0 }) : null; + sites.push({ + file: rel, line: lineOf(sf, node), door, isTest, hookName, resolution: how, + ...(cls ? { + writesPayload: cls.writesPayload, readsPreImage: cls.readsPreImage, + taintedWrite: cls.taintedWrite, replacesPayload: cls.replacesPayload, + guardWouldFire: cls.writesPayload && cls.readsPreImage, + writtenKeys: [...cls.writtenKeys], preImageReads: [...cls.preImageReads], + payloadWriteSites: cls.payloadWriteSites, delegatesUnresolved: cls.delegatesUnresolved, + } : { unclassified: true }), + }); + }; + + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text + : ts.isIdentifier(node.expression) ? node.expression.text : null; + if (callee === 'registerHook' || callee === 'on') { + const evIdx = node.arguments.findIndex((a) => { + const u = unwrap(a); + return ts.isStringLiteral(u) && u.text === EVENT; + }); + if (evIdx >= 0 && node.arguments.length > evIdx + 1) { + const r = resolveHandler(node.arguments[evIdx + 1], sf, byName); + emit(node, `${callee}()`, r.fn, r.how, undefined); + } + } + } + if (ts.isObjectLiteralExpression(node)) { + const props = new Map(); + for (const p of node.properties) { + if ((ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) || ts.isMethodDeclaration(p)) + && p.name && ts.isIdentifier(p.name)) props.set(p.name.text, p); + } + const ev = props.get('events'); + if (ev && ts.isPropertyAssignment(ev)) { + const arr = unwrap(ev.initializer); + const hasEvent = ts.isArrayLiteralExpression(arr) + && arr.elements.some((e) => { const u = unwrap(e); return ts.isStringLiteral(u) && u.text === EVENT; }); + const h = props.get('handler'); + if (hasEvent && h) { + let fnNode = null, how = 'inline'; + if (ts.isPropertyAssignment(h)) { const r = resolveHandler(h.initializer, sf, byName); fnNode = r.fn; how = r.how; } + else if (ts.isMethodDeclaration(h)) { fnNode = h; how = 'method'; } + else if (ts.isShorthandPropertyAssignment(h)) { + const t = byName.get(h.name.text); fnNode = t && paramsOf(t) ? t : null; + how = fnNode ? `identifier:${h.name.text}` : `unresolved-identifier:${h.name.text}`; + } + const nameProp = props.get('name'); + const hookName = nameProp && ts.isPropertyAssignment(nameProp) && ts.isStringLiteral(unwrap(nameProp.initializer)) + ? unwrap(nameProp.initializer).text : undefined; + emit(node, 'events[] literal', fnNode, how, hookName); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return sites; +} + +function analyzeText(text, name = 'fixture.ts') { + const sf = parseSourceFile(name, text); + return analyzeSource(sf, name, false, []); +} + +/** + * ⭐ The firing positive control this census's headline ZERO is worthless + * without, plus the negative controls that keep the predicate from being + * trivially true. Every fixture below is a shape the census claims to + * DISTINGUISH, so a change that collapses two of them reddens here rather than + * silently re-scoring the tree. + */ +const SELF_TEST_CASES = [ + { + name: 'POSITIVE — the card\'s own pinned residue (value from ctx.previous)', + expect: { writesPayload: true, readsPreImage: true, taintedWrite: true }, + src: `engine.registerHook('beforeUpdate', (ctx) => { + const prev = ctx.previous; + ctx.input.data.priority = prev.status === 'blocked' ? 'high' : 'low'; + });`, + }, + { + name: 'POSITIVE — value derived from the per-row id', + expect: { writesPayload: true, readsPreImage: true, taintedWrite: true }, + src: `engine.registerHook('beforeUpdate', (ctx) => { + ctx.input.data.slug = String(ctx.input.id) + '-x'; + });`, + }, + { + name: 'POSITIVE — taint carried through a local binding', + expect: { writesPayload: true, readsPreImage: true, taintedWrite: true }, + src: `engine.registerHook('beforeUpdate', (ctx) => { + const prev = ctx.previous; + const bumped = (prev.count ?? 0) + 1; + const data = ctx.input.data; + data.count = bumped; + });`, + }, + { + name: 'POSITIVE — taint across a helper handed payload AND pre-image', + expect: { writesPayload: true, readsPreImage: true, taintedWrite: true }, + src: `function apply(record, prior) { record.rank = prior.rank + 1; } + engine.registerHook('beforeUpdate', (ctx) => { apply(ctx.input.data, ctx.previous); });`, + }, + { + name: 'POSITIVE — Hook metadata literal door, not registerHook()', + expect: { writesPayload: true, readsPreImage: true, taintedWrite: true }, + src: `const h = { name: 'x', object: 'task', events: ['beforeUpdate'], + handler: (ctx) => { ctx.input.data.tier = ctx.previous.tier; } };`, + }, + { + name: 'NEGATIVE — audit-stamp shape: writes via a payload-passing helper, clock value, no pre-image read', + expect: { writesPayload: true, readsPreImage: false, taintedWrite: false }, + src: `function applyToRecord(record) { record.updated_at = new Date().toISOString(); } + function stampData(data) { applyToRecord(data); } + engine.registerHook('beforeUpdate', (ctx) => { stampData(ctx.input.data); });`, + }, + { + name: 'NEGATIVE — CONSTANT value gated on a pre-image read (the guard\'s over-fire shape)', + expect: { writesPayload: true, readsPreImage: true, taintedWrite: false }, + src: `engine.registerHook('beforeUpdate', (ctx) => { + if (ctx.previous.managed_by === 'package') ctx.input.data.customized = true; + });`, + }, + { + name: 'NEGATIVE — reads the pre-image but never writes the payload (a pure guard)', + expect: { writesPayload: false, readsPreImage: true, taintedWrite: false }, + src: `engine.registerHook('beforeUpdate', (ctx) => { + if (ctx.previous.locked) throw new Error('locked'); + });`, + }, + { + name: 'NEGATIVE — writes a value derived only from the PAYLOAD (row-invariant)', + expect: { writesPayload: true, readsPreImage: false, taintedWrite: false }, + src: `engine.registerHook('beforeUpdate', (ctx) => { + ctx.input.data.name_lower = String(ctx.input.data.name).toLowerCase(); + });`, + }, + { + name: 'CONTROL — a beforeInsert registration must not be counted at all', + expect: null, + src: `engine.registerHook('beforeInsert', (ctx) => { ctx.input.data.x = ctx.previous.y; });`, + }, +]; + +function runSelfTest() { + let failures = 0; + for (const c of SELF_TEST_CASES) { + const found = analyzeText(c.src, `${c.name}.ts`); + if (c.expect === null) { + const ok = found.length === 0; + if (!ok) failures++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${c.name} (sites=${found.length}, expected 0)`); + continue; + } + if (found.length !== 1) { + failures++; + console.log(`FAIL ${c.name} (expected exactly 1 site, got ${found.length})`); + continue; + } + const s = found[0]; + const bad = Object.entries(c.expect).filter(([k, v]) => s[k] !== v); + if (bad.length) failures++; + console.log(`${bad.length ? 'FAIL' : 'PASS'} ${c.name}` + + (bad.length ? ` → ${bad.map(([k, v]) => `${k}: expected ${v}, got ${s[k]}`).join('; ')}` : '')); + } + console.log(`\n${failures === 0 ? 'SELF-TEST PASSED' : 'SELF-TEST FAILED'} — ${SELF_TEST_CASES.length - failures}/${SELF_TEST_CASES.length} cases`); + return failures; +} + +if (process.argv.includes('--self-test')) { + process.exit(runSelfTest() === 0 ? 0 : 1); +} + +const sites = []; +for (const root of ROOTS) { + for (const file of walkFiles(join(REPO, root), [])) { + const rel = relative(REPO, file); + let text; + try { text = readFileSync(file, 'utf8'); } catch { continue; } + if (!text.includes(EVENT)) continue; + const sf = parseSourceFile(file, text); + analyzeSource(sf, rel, /\.(test|spec)\.ts$/.test(file), sites); + } +} + +/** + * What this instrument CANNOT see, enumerated rather than assumed away. A + * census that reports a population without bounding its own blind spots is a + * hypothesis wearing a number. + * + * - `nonLiteralEventArg` — a `registerHook(, …)` whose event argument is + * not a plain string literal. The walker keys on the literal, so any such + * site is invisible to the population count and has to be read by hand. + * - `nonTsFilesMentioningBeforeUpdate` — hooks can also arrive as METADATA + * (a stored `sys_metadata` row, a JSON/YAML object definition) and be bound + * by `bindHooksToEngine` at boot. Nothing in a TS syntax tree sees those. + * - `tsFilesOutsideScannedRoots` — anything under a root this walk never + * entered. + */ +function collectBlindSpots() { + const nonLiteral = []; + for (const root of ROOTS) { + for (const file of walkFiles(join(REPO, root), [])) { + let text; + try { text = readFileSync(file, 'utf8'); } catch { continue; } + if (!text.includes('registerHook')) continue; + const sf = parseSourceFile(file, text); + const v = (n) => { + if (ts.isCallExpression(n)) { + const c = ts.isPropertyAccessExpression(n.expression) ? n.expression.name.text + : ts.isIdentifier(n.expression) ? n.expression.text : null; + if (c === 'registerHook' && n.arguments.length > 0 && !ts.isStringLiteral(unwrap(n.arguments[0]))) { + nonLiteral.push({ + file: relative(REPO, file), line: lineOf(sf, n), + arg: n.arguments[0].getText(sf).replace(/\s+/g, ' ').slice(0, 60), + isTest: /\.(test|spec)\.ts$/.test(file), + }); + } + } + ts.forEachChild(n, v); + }; + v(sf); + } + } + const nonTs = []; + const outside = []; + const scanAll = (dir) => { + let entries; + try { entries = readdirSync(dir); } catch { return; } + for (const name of entries) { + if (SKIP_DIR.has(name) || name === '.git') continue; + const full = join(dir, name); + let st; + try { st = statSync(full); } catch { continue; } + if (st.isDirectory()) { scanAll(full); continue; } + const rel = relative(REPO, full); + const isData = /\.(json|ya?ml|[cm]?js)$/.test(name); + const isTs = name.endsWith('.ts') && !name.endsWith('.d.ts'); + if (!isData && !isTs) continue; + let text; + try { text = readFileSync(full, 'utf8'); } catch { continue; } + if (!text.includes(EVENT)) continue; + if (isData) nonTs.push(rel); + else if (!ROOTS.some((r) => rel.startsWith(`${r}/`))) outside.push(rel); + } + }; + scanAll(REPO); + return { + nonLiteralEventArg: { + production: nonLiteral.filter((x) => !x.isTest), + testCount: nonLiteral.filter((x) => x.isTest).length, + }, + nonTsFilesMentioningBeforeUpdate: nonTs, + tsFilesOutsideScannedRoots: outside, + }; +} + +const prod = sites.filter((s) => !s.isTest); +const blindSpots = collectBlindSpots(); +const summary = { + base: process.env.CENSUS_BASE ?? null, + totalSites: sites.length, + productionSites: prod.length, + testSites: sites.length - prod.length, + production: { + writesPayload: prod.filter((s) => s.writesPayload).length, + readsPreImage: prod.filter((s) => s.readsPreImage).length, + guardWouldFire: prod.filter((s) => s.guardWouldFire).length, + taintedWrite_INSTANCE_CANDIDATE: prod.filter((s) => s.taintedWrite).length, + unclassified: prod.filter((s) => s.unclassified).length, + replacesPayload: prod.filter((s) => s.replacesPayload).length, + withUnresolvedDelegation: prod.filter((s) => (s.delegatesUnresolved ?? []).length > 0).length, + }, +}; +process.stdout.write(JSON.stringify({ summary, blindSpots, sites }, null, 2)); diff --git a/scripts/audits/14744-before-update-per-row-value-probe.mjs b/scripts/audits/14744-before-update-per-row-value-probe.mjs new file mode 100644 index 0000000000..0a3a9a594a --- /dev/null +++ b/scripts/audits/14744-before-update-per-row-value-probe.mjs @@ -0,0 +1,394 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14744] Census instrument B — the RUNTIME behavioural probe, and the + * cross-check on instrument A's static predicate. + * + * Instrument A reasons about handler source. This one runs the REAL engine + * (`packages/objectql/src/engine.ts`) against a stub driver, dispatches REAL + * production handlers per row of a genuine `multi: true` update, and reads what + * actually reaches `driver.updateMany` — the one `SET` clause ADR-0058 + * Addendum II D3 gives N rows. Where a subject is a replica rather than the + * shipped handler it says so in its own `real` flag; the replica convention + * (and the audit stamp as the thing replicated) is the pin suite's own — + * `multi-update-hook-key-divergence.test.ts` §3 uses `perRowClockStamp()` + * rather than booting the plugin. + * + * ## How a subject is classified, and why it takes TWO scenarios + * + * Each subject runs twice over two rows: + * + * - **divergent** — the rows DISAGREE on the pre-image field the handler + * reads; + * - **uniform** — the rows AGREE on it. + * + * Two scenarios are needed because one cannot separate the residue from the + * clock. A single divergent run showing "same keys, different values" is + * equally consistent with (a) a value derived from the row's pre-image — the + * residue — and (b) a value that simply differs every time it is computed, like + * `sys_stamp_audit_update`'s `updated_at`. The uniform run settles it: with the + * pre-images equal, any remaining value difference is NOT attributable to the + * row, and the subject is nondeterministic rather than row-derived. That is the + * same discrimination #14099 made when it rejected a value comparison twice, + * arrived at from the other side. + * + * INSTANCE divergent: same key set, values differ + * AND uniform: values agree ⇒ the residue + * NONDETERMINISTIC uniform: values differ ⇒ the clock class + * CAUGHT_BY_14099 divergent: key sets differ ⇒ already refused + * ROW_INVARIANT divergent: same keys, same values + * NOT_A_PAYLOAD_WRITER + * + * ## The candidate guard, measured rather than modelled + * + * The instrument #14744 asks about — "refuse when a per-row dispatch READS the + * pre-image and WRITES the payload" — is evaluated here as an OBSERVER, never + * as an enforcement: each dispatch gets a read-recording `Proxy` over its + * context, so a `ctx.previous` / `ctx.input.id` read is recorded as it happens, + * and the payload is diffed around the call. `guardWouldFire` is therefore a + * measured property of the shipped handler's execution, not a reading of its + * source. ⛔ Nothing here is registered on, or changes, the engine's behaviour: + * the guard is not implemented, per the card's terminal scope. + */ + +import { writeFileSync } from 'node:fs'; +import { ObjectQL } from '../../packages/objectql/src/engine.ts'; +import { MultiUpdateHookKeyDivergenceError } from '../../packages/objectql/src/multi-update-hook-key-divergence.ts'; +import { bindEmailTemplateProvenanceStamp } from '../../packages/plugins/plugin-email/src/email-template-provenance.ts'; +import { bindRuleProvenanceStamp } from '../../packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts'; +import { bindWebhookProvenanceStamp } from '../../packages/plugins/plugin-webhooks/src/webhook-provenance.ts'; +import taskHookImported from '../../examples/app-todo/src/objects/task.hook.ts'; +// tsx's interop can hand back the module namespace rather than the default +// binding; resolve it by SHAPE so the probe fails loudly if neither carries a +// handler, instead of dispatching nothing and scoring the hook as clean. +const taskHook = typeof taskHookImported?.handler === 'function' + ? taskHookImported + : typeof taskHookImported?.default?.handler === 'function' ? taskHookImported.default : null; +if (!taskHook) throw new Error('probe: could not resolve examples/app-todo task hook handler'); + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }; +const field = (name) => ({ name, label: name, type: 'text' }); + +function makeStubDriver() { + const store = new Map(); + const matches = (row, where) => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + if (v && typeof v === 'object' && Array.isArray(v.$in)) { + if (!v.$in.some((x) => x === row[k])) return false; + continue; + } + const expected = v && typeof v === 'object' && '$eq' in v ? v.$eq : v; + if ((row[k] ?? null) !== (expected ?? null)) return false; + } + return true; + }; + const d = { + name: 'memory', version: '0.0.0', supports: {}, store, + updateCalls: 0, updateManyPayloads: [], + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, async syncSchema() {}, + async find(_o, ast, opts) { + const rows = [...store.values()].filter((r) => matches(r, ast?.where)); + const limit = typeof ast?.limit === 'number' ? ast.limit + : typeof opts?.limit === 'number' ? opts.limit : undefined; + return typeof limit === 'number' ? rows.slice(0, limit) : rows; + }, + async findOne(_o, ast) { for (const r of store.values()) if (matches(r, ast?.where)) return r; return null; }, + async create(_o, data) { const id = data.id ?? `r_${store.size + 1}`; const row = { ...data, id }; store.set(id, row); return row; }, + async update(_o, id, data) { d.updateCalls += 1; const cur = store.get(id); if (!cur) return null; const u = { ...cur, ...data, id }; store.set(id, u); return u; }, + async updateMany(_o, ast, data) { + d.updateManyPayloads.push({ ...data }); + const rows = [...store.values()].filter((r) => matches(r, ast?.where)); + for (const r of rows) store.set(r.id, { ...r, ...data, id: r.id }); + return rows.length; + }, + async delete(_o, id) { return store.delete(id); }, + async deleteMany(_o, ast) { const rows = [...store.values()].filter((r) => matches(r, ast?.where)); for (const r of rows) store.delete(r.id); return rows.length; }, + async count() { return store.size; }, + async bulkCreate(_o, rows) { return Promise.all(rows.map((r) => d.create(_o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async upsert(_o, data) { return d.create(_o, data); }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return d; +} + +/** + * Wrap a handler so each per-row dispatch reports what it READ (pre-image) and + * what it WROTE (payload keys and values), without altering either. + */ +function observed(handler, log) { + return async (ctx) => { + const rec = { preImageRead: new Set(), assigned: {} }; + const data = ctx?.input?.data; + const proxy = new Proxy(ctx, { + get(t, p) { + if (p === 'previous' || p === 'previousRecord' || p === 'record') { + if (t[p] !== undefined) rec.preImageRead.add(`ctx.${String(p)}`); + return t[p]; + } + if (p === 'input') { + const input = t[p]; + if (!input || typeof input !== 'object') return input; + return new Proxy(input, { + get(it, ip) { + if (ip === 'id' || ip === 'ids') { if (it[ip] !== undefined) rec.preImageRead.add(`ctx.input.${String(ip)}`); } + // ⚠️ The payload is handed back behind a WRITE-RECORDING proxy, and + // what it records is ASSIGNMENT, not value change. Recording a + // change instead is a measurement artifact that silently rewrites + // the verdict: the payload is ONE object shared by every row's + // dispatch (D3), so the second row assigning the SAME value it + // found there produces no diff, and a change-based observer scores + // that row as "wrote nothing" — turning a row-invariant handler + // into a fabricated key-set divergence. Measured here on the + // pinyin replica before the fix. Assignment is also exactly what + // #14099's own recorder counts ("the set of payload keys the hook + // chain assigned"), so this keeps the two instruments comparable. + if (ip === 'data' && it[ip] && typeof it[ip] === 'object') { + const real = it[ip]; + return new Proxy(real, { + set(dt, dp, dv) { rec.assigned[String(dp)] = dv; dt[dp] = dv; return true; }, + deleteProperty(dt, dp) { rec.assigned[String(dp)] = '(deleted)'; delete dt[dp]; return true; }, + defineProperty(dt, dp, desc) { + if ('value' in desc) rec.assigned[String(dp)] = desc.value; + Object.defineProperty(dt, dp, desc); return true; + }, + }); + } + return it[ip]; + }, + set(it, ip, v) { it[ip] = v; return true; }, + }); + } + return t[p]; + }, + }); + try { + await handler(proxy); + } finally { + log.push({ + rowId: ctx?.input?.id ?? null, + preImageRead: [...rec.preImageRead], + writtenKeys: Object.keys(rec.assigned).sort(), + writtenValues: rec.assigned, + payloadAfter: data && typeof data === 'object' ? { ...data } : null, + }); + } + }; +} + +async function runScenario(subject, scenario) { + const engine = new ObjectQL(); + const driver = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject({ + name: subject.object, label: subject.object, + fields: Object.fromEntries([['id', { ...field('id'), primaryKey: true }], + ...subject.fields.map((f) => [f, field(f)])]), + }); + + const log = []; + // A stub of the MinimalEngine face the provenance binders expect, forwarding + // registration to the real engine with the observer in between. + const bindTarget = { + registerHook: (event, handler, options) => engine.registerHook(event, observed(handler, log), options), + unregisterHooksByPackage: (pkg) => engine.unregisterHooksByPackage?.(pkg) ?? 0, + find: async (object, opts) => driver.find(object, opts, opts), + update: async () => ({}), + registry: engine.registry, + }; + subject.bind(bindTarget, engine, log); + + await engine.insert(subject.object, scenario.rows); + driver.updateManyPayloads.length = 0; + log.length = 0; + + let refusedWith = null; + try { + await engine.update(subject.object, { ...subject.payload }, { + multi: true, where: { id: { $in: scenario.rows.map((r) => r.id) } }, + }); + } catch (err) { + // ⚠️ Read the envelope by FIELD, not only through `instanceof`. Under tsx + // this probe's import of the divergence module and the engine's own can be + // two module instances, so `instanceof` is false against a genuine refusal; + // the ADR-0112 `code` is the identity that survives that, which is exactly + // the reason the code is a registered one (see the module's own note). + refusedWith = { + code: err?.code ?? null, status: err?.status ?? null, + keys: err?.keys ?? null, object: err?.object ?? null, rows: err?.rows ?? null, + name: err?.name, instanceofModule: err instanceof MultiUpdateHookKeyDivergenceError, + message: String(err?.message ?? err).slice(0, 200), + }; + } + return { + refusedWith, + dispatches: log, + updateManyPayloads: driver.updateManyPayloads.map((p) => ({ ...p })), + stored: [...driver.store.values()].sort((a, b) => String(a.id).localeCompare(String(b.id))), + }; +} + +const sameKeys = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const valuesAgree = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +function classify(divergent, uniform) { + const writers = (r) => r.dispatches.filter((d) => d.writtenKeys.length > 0); + const guardFires = [...divergent.dispatches, ...uniform.dispatches] + .some((d) => d.preImageRead.length > 0 && d.writtenKeys.length > 0); + + if (divergent.refusedWith?.code === 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE') { + return { verdict: 'CAUGHT_BY_14099', guardFires }; + } + const dWriters = writers(divergent); + if (dWriters.length === 0) return { verdict: 'NOT_A_PAYLOAD_WRITER', guardFires }; + + const uWriters = writers(uniform); + if (uWriters.length >= 2 && sameKeys(uWriters[0].writtenKeys, uWriters[1].writtenKeys) + && !valuesAgree(uWriters[0].writtenValues, uWriters[1].writtenValues)) { + return { verdict: 'NONDETERMINISTIC', guardFires }; + } + if (dWriters.length >= 2) { + if (!sameKeys(dWriters[0].writtenKeys, dWriters[1].writtenKeys)) { + return { verdict: 'KEY_SET_DIVERGENCE_UNREFUSED', guardFires }; + } + if (!valuesAgree(dWriters[0].writtenValues, dWriters[1].writtenValues)) { + return { verdict: 'INSTANCE', guardFires }; + } + } + if (dWriters.length === 1 && divergent.dispatches.length > 1) { + return { verdict: 'CAUGHT_BY_14099', guardFires }; + } + return { verdict: 'ROW_INVARIANT', guardFires }; +} + +/* ── Subjects ───────────────────────────────────────────────────────────── */ + +const SUBJECTS = [ + { + id: 'POSITIVE-CONTROL: the card\'s pinned residue', + real: false, note: 'The exact handler pinned in multi-update-hook-key-divergence.test.ts §4.', + object: 'task', fields: ['status', 'priority', 'title'], + payload: { title: 'renamed' }, + bind: (t) => t.registerHook('beforeUpdate', (ctx) => { + const prev = ctx.previous; + ctx.input.data.priority = prev?.status === 'blocked' ? 'high' : 'low'; + }, {}), + divergent: [{ id: 'a', status: 'blocked', priority: 'x', title: 't' }, { id: 'b', status: 'todo', priority: 'x', title: 't' }], + uniform: [{ id: 'a', status: 'todo', priority: 'x', title: 't' }, { id: 'b', status: 'todo', priority: 'x', title: 't' }], + }, + { + id: 'REPLICA: sys_stamp_audit_update (clock inside the per-record stamp)', + real: false, note: 'Shape of objectql plugin.ts:1137, replicated as the pin suite §3 does.', + object: 'task', fields: ['status', 'updated_at', 'title'], + payload: { title: 'renamed' }, + bind: (t) => { let tick = 0; t.registerHook('beforeUpdate', (ctx) => { + tick += 1; + ctx.input.data.updated_at = `2026-09-04T10:00:00.00${tick}Z`; + }, {}); }, + divergent: [{ id: 'a', status: 'blocked', updated_at: 'old', title: 't' }, { id: 'b', status: 'todo', updated_at: 'old', title: 't' }], + uniform: [{ id: 'a', status: 'todo', updated_at: 'old', title: 't' }, { id: 'b', status: 'todo', updated_at: 'old', title: 't' }], + }, + { + id: 'REAL: examples/app-todo task_logic', + real: true, note: 'The shipped example hook, imported and dispatched unmodified.', + object: 'todo_task', fields: ['status', 'completed_date', 'subject', 'priority'], + payload: { status: 'completed' }, + bind: (t) => t.registerHook('beforeUpdate', (ctx) => taskHook.handler(ctx), {}), + divergent: [{ id: 'a', status: 'in_progress', completed_date: null, subject: 's', priority: 'normal' }, + { id: 'b', status: 'completed', completed_date: '2026-01-01', subject: 's', priority: 'normal' }], + uniform: [{ id: 'a', status: 'in_progress', completed_date: null, subject: 's', priority: 'normal' }, + { id: 'b', status: 'in_progress', completed_date: null, subject: 's', priority: 'normal' }], + }, + { + id: 'REAL: plugin-email template provenance stamp', + real: true, note: 'bindEmailTemplateProvenanceStamp, unmodified.', + object: 'sys_email_template', fields: ['managed_by', 'customized', 'subject'], + payload: { subject: 'edited' }, + bind: (t) => bindEmailTemplateProvenanceStamp(t, silentLogger, 'sys_email_template'), + divergent: [{ id: 'a', managed_by: 'package', customized: false, subject: 's' }, + { id: 'b', managed_by: 'user', customized: false, subject: 's' }], + uniform: [{ id: 'a', managed_by: 'package', customized: false, subject: 's' }, + { id: 'b', managed_by: 'package', customized: false, subject: 's' }], + }, + { + id: 'REAL: plugin-sharing rule provenance stamp', + real: true, note: 'bindRuleProvenanceStamp, unmodified.', + object: 'sys_sharing_rule', fields: ['managed_by', 'customized', 'label'], + payload: { label: 'edited' }, + bind: (t) => bindRuleProvenanceStamp(t, silentLogger), + divergent: [{ id: 'a', managed_by: 'package', customized: false, label: 'l' }, + { id: 'b', managed_by: 'user', customized: false, label: 'l' }], + uniform: [{ id: 'a', managed_by: 'package', customized: false, label: 'l' }, + { id: 'b', managed_by: 'package', customized: false, label: 'l' }], + }, + { + id: 'REAL: plugin-webhooks provenance stamp', + real: true, note: 'bindWebhookProvenanceStamp, unmodified.', + object: 'sys_webhook', fields: ['managed_by', 'customized', 'label'], + payload: { label: 'edited' }, + bind: (t) => bindWebhookProvenanceStamp(t, silentLogger), + divergent: [{ id: 'a', managed_by: 'package', customized: false, label: 'l' }, + { id: 'b', managed_by: 'user', customized: false, label: 'l' }], + uniform: [{ id: 'a', managed_by: 'package', customized: false, label: 'l' }, + { id: 'b', managed_by: 'package', customized: false, label: 'l' }], + }, + { + id: 'REPLICA: pinyin companion projection (value from the PAYLOAD)', + real: false, note: 'Shape of plugin-pinyin-search companion-projection.ts:97.', + object: 'task', fields: ['title', '__search'], + payload: { title: 'Zhang San' }, + bind: (t) => t.registerHook('beforeUpdate', (ctx) => { + const data = ctx.input.data; + if ('title' in data) data.__search = String(data.title).toLowerCase(); + }, {}), + divergent: [{ id: 'a', title: 'A', __search: 'a' }, { id: 'b', title: 'B', __search: 'b' }], + uniform: [{ id: 'a', title: 'A', __search: 'a' }, { id: 'b', title: 'A', __search: 'a' }], + }, + { + id: 'NEGATIVE-CONTROL: reads the pre-image, never writes', + real: false, note: 'A pure guard — the guard predicate must NOT fire on it.', + object: 'task', fields: ['status', 'title'], + payload: { title: 'renamed' }, + bind: (t) => t.registerHook('beforeUpdate', (ctx) => { void ctx.previous?.status; }, {}), + divergent: [{ id: 'a', status: 'blocked', title: 't' }, { id: 'b', status: 'todo', title: 't' }], + uniform: [{ id: 'a', status: 'todo', title: 't' }, { id: 'b', status: 'todo', title: 't' }], + }, +]; + +const results = []; +for (const s of SUBJECTS) { + let divergent, uniform, error = null; + try { + divergent = await runScenario(s, { rows: s.divergent }); + uniform = await runScenario(s, { rows: s.uniform }); + } catch (err) { + error = String(err?.stack ?? err).slice(0, 400); + } + const cls = error ? { verdict: 'NOT_EVALUABLE', guardFires: null } : classify(divergent, uniform); + results.push({ subject: s.id, real: s.real, note: s.note, ...cls, error, divergent, uniform }); +} + +const out = { + generatedAt: new Date().toISOString(), + base: process.env.CENSUS_BASE ?? null, + verdicts: Object.fromEntries(results.map((r) => [r.subject, { verdict: r.verdict, guardFires: r.guardFires }])), + instanceCount: results.filter((r) => r.verdict === 'INSTANCE').length, + guardFiresCount: results.filter((r) => r.guardFires).length, + results, +}; +// ⚠️ The engine and the registry both log to stdout on boot, so the report is +// written to a file rather than piped: a JSON document interleaved with engine +// INFO lines parses as nothing, and a caller reading exit codes would never +// notice. `--out` is required for that reason. +const outIdx = process.argv.indexOf('--out'); +if (outIdx === -1 || !process.argv[outIdx + 1]) { + console.error('usage: tsx 14744-before-update-per-row-value-probe.mjs --out '); + process.exit(2); +} +writeFileSync(process.argv[outIdx + 1], JSON.stringify(out, null, 2)); +console.error(`probe: ${results.length} subjects · INSTANCE=${out.instanceCount} · guardFires=${out.guardFiresCount} · written to ${process.argv[outIdx + 1]}`); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 41508a171a..837fc7e027 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3101,6 +3101,7 @@ const BARE_ENTRY_POINT_NAME = 'selfTest'; const COMPOUND_ANCHOR_LEDGER = [ ['packages/lint/scripts/check-doc-formula-expressions.mjs', 'specSelfTest', false], ['packages/lint/scripts/check-doc-formula-expressions.mjs', 'fieldRuleSelfTest', false], + ['scripts/audits/14744-before-update-per-row-value-census.mjs', 'runSelfTest', false], ['scripts/check-comment-mask-corpus.mjs', 'runSelfTestCases', false], ['scripts/check-doc-authoring.mjs', 'selfTestRule3', false], ['scripts/check-doc-authoring.mjs', 'selfTestPackagesProse', false],