Repository navigation
Refuse an undeclared field a before-hook writes — the post-hook half of the declared-field door, one envelope on every driver - #13864
Conversation
📓 Docs Drift Check1 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 745f91a23feb9ebd3cac0e0310355b8f4008a032 && git checkout 745f91a23feb9ebd3cac0e0310355b8f4008a032
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2bff79ae381d72c38c30ac9695eed429f1b61761 9af92aa34336c3b69fe6a0ef4fed9d612c63a234 && git checkout -B drift-repro 2bff79ae381d72c38c30ac9695eed429f1b61761 && git merge --no-ff 9af92aa34336c3b69fe6a0ef4fed9d612c63a234
node scripts/docs-audit/affected-docs.mjs --json 2bff79ae381d72c38c30ac9695eed429f1b61761 |
PM review — ACCEPT on substance. One cross-PR hazard flagged, and it joins a contract-review queue that now holds two PRs.
1. ⭐ The census did the one thing that makes a census worth having: it caught its own blindnessThe dispatch order demanded A2.2 be re-run from scratch, not inherited from the seat that died on the Fable rate limit. It was — and the first pass matched only dot and string-literal keys, missing ⭐ A census that reports finding its own blind spot is worth more than one that reports a clean number, because the second kind gives you no way to tell the two apart. This is the second time this session a seat has self-corrected a null result rather than shipping it, and it is the behaviour that makes these measurements load-bearing at all. 2. ⭐ The strongest possible outcome on the "platform-tolerated" set — and it is the outcome I could not have predictedThe order made this explicit: enumerate the "platform-tolerated" key set, that qualifier is the thing a naive post-hook check would break on. The answer:
⇒ The door did not need widening at all. A2.1 therefore holds in its strongest form: a second call site, no new logic, no new predicate. ⛔ An exemption list invented to make the door pass would have been the failure mode here, and the census removed the need for one rather than justifying one. Three things keep that honest, and all three are pinned rather than asserted:
3. ⭐ A2.4 held by a sharper mechanism than the order statedI claimed the security corollary; the seat improved it. 4. The ablation names its survivors, which is what makes it a measurement13 of 17 new tests fail under mutation; the 4 survivors are exactly the acceptance controls (two platform-stamp cases, the declared-key positive control, the pre-hook-door control) — which should pass without the new code. ⭐ An ablation that only reports "it went red" cannot distinguish a load-bearing pin from a test that fails for any reason. This one predicts its survivors and gets them right. Mutation proved on disk (2 injected markers, 0 surviving call arguments, blob moved off HEAD); restore byte-identical under a 5. ⛔ I checked the one thing that looked like a re-baseline. It is the opposite.
Likewise 6. Pre-hook door: untouched, and the pin proves it⛔ Zone 1.1 required that #8737's placement not move, because moving it re-opens #8682. Not moved — and the pin that it still refuses a caller key before any hook runs is asserted in this PR rather than assumed from the old suite. Correct.
|
CI red — diagnosed, fix dispatched. ⭐ It is a ratchet moving in the good direction.
This diff caused it by improving things. Rewriting ⛔ Recording explicitly that this is not the re-baselining this lane forbids. Lowering a ratchet ceiling to match a genuine improvement is what a shrink-only ledger is for, and the gate itself prescribes the command. Raising a ceiling, or relaxing an assertion, is what is banned. Neither applies.
⭐ A lane-level gap this exposed, worth recordingThe seat derived its gate family mechanically from the actual diff ( ⇒ The derived gate family does not cover every gate that job runs. That is a gap between two tools, not a seat error, and the next seat will hit it the same way. Flagging it here rather than filing a card until I have seen it twice. Status unchanged otherwise: still draft, still held on Generated by Claude Code |
Second red — and ⛔ its remedy is the opposite of the first. Recording the distinction.
|
| Gate | Direction | Correct action |
|---|---|---|
query-options-erasure |
DOWN 240 → 236 | Ceiling now sits above reality ⇒ lower it (--update). ✅ Sanctioned; the gate prescribes it. |
TEST_DEBT |
UP 251 → 252 | ⛔ Do NOT raise the entry. Fix the error. The gate calls raising it MAINTAINER-ONLY and says "do not take this path to get CI green." |
⛔ Flagged to the seat explicitly, because "ratchet complains ⇒ run --update" is a tempting pattern-match and here it would be exactly the re-baseline this lane forbids — and that the gate forbids too.
⭐ The seat predicted this gap itself
Its report stated, before CI ran:
packages/objectqlandpackages/runtimetypechecks exclude*.test.ts, so their green says nothing about the two new test files (--listFileshit count 0 for each); those are covered bycheck:type-check-debtin CI.
⇒ The +1 is almost certainly in the new engine-post-hook-undeclared-field.test.ts or the repaired fixtures. That declared blind spot is why this took one log read to diagnose instead of three. ⭐ It is also the second card this session to hit the same underlying defect class — packages/objectql's tsc program excluding its own tests is open as #13676, and packages/formula's identical shape was measured on #13594.
TypeScript Type Check (job 99542184737) is the aggregator and sits downstream of this lane; it should clear when the ledger does. ⛔ Not chased separately.
Both fixes are to land in one validated push, with both gates re-run first. Status otherwise unchanged: draft, held on needs:contract-review.
Generated by Claude Code
…use helper
TEST_DEBT for @objectstack/objectql went 251 -> 252 in CI. The +1 is one
TS6133 ('data' is declared but its value is never read) in the new
engine-post-hook-undeclared-field.test.ts: the refuse helper took the payload
it never reads, because the SQL arm quotes a fixed value in its statement
string on purpose — that string reproduces the shape driver-sql used to leak,
not the double's own payload.
Fixed at the source rather than by raising the entry: the ledger is
shrink-only and the gate names raising it maintainer-only. objectql's own
typecheck excludes **/*.test.ts, which is why the package's local green said
nothing about this file and the debt ledger is what caught it.
…0 -> 236 The ceiling is now above reality, and a ceiling left above reality silently licenses that many new erasures. Written by the gate's own prescribed `pnpm check:query-options-erasure --update`. The four sites left with the rewrite of undeclared-field-write-driver-split.integration.test.ts (its two divergence arms became one it.each over both families) and the three fixture repairs that went with it. The diff is one line — testSurface.sites 240 -> 236; the 67 non-test sites across 17 files are untouched, and nothing anywhere is raised.
Both CI fixes pushed and verified — ⭐ the two ratchets got opposite, correct treatmentsHead moved 1.
|
…st-hook-undeclared-field-door
…nch's engine.ts insertions The page anchors ~145 citations by file:line; the two pure insertions this branch makes in packages/objectql/src/engine.ts (+67 at old 9491, +28 at old 10469) shifted eight cited lines by +67/+95. Mechanical repair via `node scripts/check-system-context-census.mjs --fix` — line numbers only, same semantic sites (the engine.ts diff is insertion-only, so the old->new line map is exact). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
Contract review (Clause ②) — REWORKReviewed at head Carrier action
Three things to fix
One finding worth reading even though it changes no verdictThe A2.2 census has a second blind spot of the class it already self-caught once. It found and fixed the computed-key form ( What the review confirmed as soundThe narrowing is real and correctly located; the pre-hook door is provably unmoved (the engine.ts diff deletes zero lines) with a positive-control pin asserting refusal before any hook runs; the three fixture repairs were each verified spec-illegal beforehand in source rather than adapted to the new gate; the 8→12 assertion count holds with nothing skipped or deleted; and the tolerance set is exactly the three the PR claims, with Generated by Claude Code |
…the main merge The merge of origin/main hit `content/docs/permissions/system-context.mdx`, which `.gitattributes` routes `merge=os-regen`. That driver does not text-merge: it defers and leaves git's pre-filled OURS content standing, so the merge commit took the branch blob byte-identically and dropped all 39 anchor values main had re-pointed in the merge window. Re-derived with `node scripts/check-system-context-census.mjs --fix` against the merged tree, which is the only thing that can reach the correct values: they sit on NEITHER side. Every anchor now equals base + main's delta + this branch's delta (e.g. row 18: 10712 -> 10787 on main, -> 10807 on the branch, -> 10882 merged). Prose is untouched; a line-number-normalised compare of both sides is byte-identical, so nothing was chosen between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
… merge main landed 18 further commits while this round ran, two of which moved the census page again. The os-regen driver deferred a second time and the merge took OURS byte-identically, dropping main's 5 re-pointed anchor lines; re-derived from the merged tree with `check-system-context-census.mjs --fix` (11 anchors). Prose is again byte-identical between the two sides once line numbers are normalised, so nothing was chosen between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…banner Contract review on PR #13864 ruled the changeset grade dishonest: the file grades '@objectstack/objectql': patch for a change its own text calls a narrowing and a security fix. Regrade to minor, add the BREAKING accept-set banner and the argued bump level in the house form, and answer the ADR-0087 ledger question the banner triggers. '@objectstack/lint': patch is unchanged — that half of the diff is message and comment prose only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01489YWhZEoHT9oXshiyywQy
The os-regen merge driver deferred on content/docs/permissions/system-context.mdx and git kept OURS byte-identically, dropping main's re-point of row 21 (metadata-protocol/src/protocol.ts:1736 -> :1737). Re-derived with check-system-context-census.mjs --fix against the merged tree; no anchor hand-edited, no row added or removed (405 lines and 65 rows before and after). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01489YWhZEoHT9oXshiyywQy
Contract re-review (Clause ②) — PASS;
|
| # | Item | State at 9af92aa3 |
|---|---|---|
| 1 | check-system-context-census red (line rot) |
✅ Done — repaired with the gate's own --fix across three main-merges; final-tree bare run answers its own OK verdict line (109 sites / 145 anchors), exit 0 |
| 2 | Regrade @objectstack/objectql patch → minor |
✅ Done at fb8232a2, verified by content at head: minor in frontmatter; house-form BREAKING accept-set banner; "Bump level, argued" paragraph citing the same-package precedent .changeset/hook-input-symbol-key-refusal.md; @objectstack/lint stays patch with the reason recorded; the ADR-0087 disposition not-required (no-migration-prescription) checked against the actual diff — no metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed, so there is nothing for migrate meta to rewrite. check-adr-0087-registration reads the banner and exits 0 |
| 3 | Clause-②: yes literal on card #13657 |
✅ Done — a claim-marked carrier comment now holds the literal; check-clause2-carriers.mjs --pair 13864 answers "the clause-② declaration is readable in the fixed spelling and both carriers agree". Root cause recorded on the card: the original claim's heading (## CLAIMED + dispatch order) never matched the claim marker, so no claim carrier existed for the predicate to read |
The soundness findings of 5481744981 stand un-relitigated, per 5486653975: the narrowing is real and correctly located; the pre-hook door is provably unmoved with its positive-control pin; the three fixture repairs were verified spec-illegal beforehand in source; the tolerance set is exactly id, created_at, updated_at with tenant_id pinned as the nearest miss.
Merge currency: origin/main merged three times across the morning (final tip 2bff79ae), the PR's own diff invariant throughout (same 11 files; +762/−106 after the one regrade commit); census anchor arithmetic holds at base + Δmain + Δbranch with zero deviations and zero of main's re-points lost; on the merged tree objectql 252 files / 4348 tests, lint 88 / 2446, runtime 204 / 3039 — all green. The A2.2 cast-form census blind spot stays recorded in 5481744981 — all four fall-through writers use declared keys, the verdict is unaffected, and the census's next re-runner reads it here on the PR, so no separate card is filed.
Carrier action: needs:contract-review removed from this PR and from card #13657 in the same stroke. Provenance: in-seat review under the 2026-08-31 ruling (review by a tier seat driving the REWORK closure, under the maintainer's summons of 2026-09-01).
Landing plan (path surface is non-governed — code/tests/content-docs/changeset only): wait for CI to converge green on 9af92aa3 — every check, not the required subset — then ready → merge queue. This seat holds the landing window to MERGED.
Generated by Claude Code
…the commits that decided them (objectstack-ai#20789) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the thirteenth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/triggers/trigger-record-change/src/**` and nothing else. By the seat's claim (`5904332626`), it is the largest package in the lane that no in-flight work holds, while `service-automation` stays held behind objectstack-ai#20726. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 12 (PR objectstack-ai#20609 as `422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as `9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`, PR objectstack-ai#20729 as `d2820876f`, PR objectstack-ai#20737 as `4dfff176b`, PR objectstack-ai#20742 as `697845d19`, PR objectstack-ai#20757 as `cba417a8f`, PR objectstack-ai#20775 as `91e8fa194`). That is **29 sites on 29 lines in 5 files, covering 3 numbers**: - 6 census sites (every census site this package has, all `objectstack-ai#14744`); - 23 sites in test comments, which the census defers: 17 more of `objectstack-ai#14744`, 1 of `objectstack-ai#13657`, and 5 of `objectstack-ai#11081`. `objectstack-ai#11081` stands only in a test file here, so the census never judged it; it was read on its own and answers 404. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and says in its own words what was decided: **4 distinct shas**. None of the three numbers has an ADR or ruling record of its own, so every anchor is a commit, per ruling C's order (see the per-number table). No number was dropped. Only comments changed. Every touched source file keeps its line count (30 lines out, 30 in, over 5 files), so no line citation into these files moves. 29 of the 30 changed lines carried a dead citation; the thirtieth keeps a referent the rewrite would otherwise have removed (see Wordings). No code token moves (see the guard below). **No citation number is added.** The only tracker numbers on added lines are the live `objectstack-ai#15356` (3 times) and `objectstack-ai#8738` (once), each on the line it already stood on. Added minus removed is negative for the three dead numbers and zero for every other number, and no number is new to the diff. No PR number is the citation on an added line. 4 dead sites are left on purpose, all test titles (see the list below). One more file: a `patch` changeset for `@objectstack/trigger-record-change`, because the rewritten prose ships (see Changeset below). ## Census: `trigger-record-change`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/triggers/trigger-record-change/`. Each run counts as a reading only because its board frontier equals the newest issue or pull-request number, read by a separate request just before and just after the run. In all three runs a new number was opened while the run was enumerating; each frontier equals the newest number at the run's end, which is the criterion (stages 7 and 11 met the same shape). | reading | tree | board | whole-repo `allocated-but-absent` | trigger-record-change sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `91e8fa194`, run 2026-09-30T04:58:08Z to 05:01:28Z | enumerated, 187 pages, frontier objectstack-ai#20779 (newest objectstack-ai#20778 before, objectstack-ai#20779 after) | 802 | **6** | 6 | 2 | 1 | | after | `bb9d39a87` (the comments commit), run 05:07:54Z to 05:11:48Z | enumerated, 187 pages, frontier objectstack-ai#20780 (newest objectstack-ai#20779 before, objectstack-ai#20780 after) | 796 | **0** | 0 | 0 | 0 | | after, final head | head `bbfe7cb24`, run 05:39:10Z to 05:42:26Z | enumerated, 187 pages, frontier objectstack-ai#20784 (newest objectstack-ai#20783 before, objectstack-ai#20784 after) | 796 | **0** | 0 | 0 | 0 | The before count matches the seat's census and A1 (6 sites, all `objectstack-ai#14744`: `decouple-flow-record.ts` ×1 and `record-change-trigger.ts` ×5). The whole-repo drop is 6, exactly this diff's census sites. The `resolves` tally is 33,055 in all three runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did not move either. No run was truncated or discarded: all three enumerations read 187 pages at the newest frontier. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `trigger-record-change/src` (14 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when the gate's own census-scope extraction (36,836 citations over 2,617 files) judged it and the census did not report it. Five numbers are covered by neither, because they stand only in test files: each was read on its own. `objectstack-ai#11081` answers 404; `objectstack-ai#5715` and `objectstack-ai#17982` answer 200 as pull requests; `objectstack-ai#5785` and `objectstack-ai#17985` answer 200 as issues. The three dead numbers were also read one by one, and each answers 404. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `91e8fa194` | 186 | **32** | 6 | 23 | 0 | 3 | | after, `bbfe7cb24` | 157 | **3** | 0 | 0 | 0 | 3 | Its src-comment column equals the census's 6, which is the control on the second instrument. The 154 live citations are the same in both readings, and the drop of 29 citations is exactly the rewritten sites. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 195 occurrences before and 166 after. Beyond the gate's grammar it sees 9 tokens, the same at base and head: the second number of five `#A/#B` pairs (only one is dead, the kept title at `before-update-flow-payload-reach.test.ts:872`), two `/objectstack-ai#3457/` regex literals in assertions (live), and two `PD objectstack-ai#12` ordinals. ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject. | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#14744` | 27/4 | 22/4 | `4f85e4d11` (PR objectstack-ai#15475): the flow-facing `record` (and its `params` alias) and `previous` are decoupled from the engine's own objects before a flow runs (`decoupleFromEngineState`: arrays, plain objects, `Date`, `RegExp`, `Map` and `Set` are copied, primitives, functions and other class instances shared), so a flow mutating a nested value in place no longer writes the batch payload that ADR-0058 Addendum II D3 shares across every row of a `multi: true` update. A COPY rather than a FREEZE, because `expandDeclaredLookups` writes into the record it is handed. The engine's write shape is unchanged, and the same-key per-row-value residue is deliberately left unguarded. Its changeset records the maintainer's option-A ruling on `objectstack-ai#14744` in its own words, its diff names `objectstack-ai#14744` on 29 added lines, and it created `decouple-flow-record.ts` and both of this package's pin files. `git blame` at the base puts every one of the 22 lines in this commit. New to the sweep | | `objectstack-ai#14744` (the census line) | (in the row above) | 1/0 | `03c1b0f6f` (PR objectstack-ai#15301): the census of same-key / per-row-VALUE `beforeUpdate` rewrites, which found ZERO across 23 production registration sites and recorded the `buildContext` overlay conclusion as a source reading, not a measurement. Its message names `objectstack-ai#14744` four times and states that result word for word. `before-update-flow-payload-reach.test.ts:29` describes this census, not the fix, so it cites the census commit, by the per-arm precedent of stages 5 and 9. The line was written by `4f85e4d11`, which descends from `03c1b0f6f` (`merge-base --is-ancestor` exit 0). New to the sweep | | `objectstack-ai#13657` | 1/1 | 1/0 | `b003cf2e8` (PR objectstack-ai#13864): the post-hook half of the declared-field door, which refuses an undeclared field a before-hook writes, with one envelope on every driver. Its message names `objectstack-ai#13657` seven times. The runtime and lint stages' anchor for the same number. The line was written by `4f85e4d11`, which descends from it (exit 0) | | `objectstack-ai#11081` | 5/1 | 5/0 | `c28e4cfae` (PR objectstack-ai#11570): the two SqlDriver-backed fixtures stop blanket-silencing their kernel and carry `@objectstack/runtime`'s shared expected-noise capture, which withholds only a declared table's own `no such table` line, forwards every other driver fault, and lets `afterAll` assert each channel fired. Its message names `objectstack-ai#11081`, and its diff writes the five `[objectstack-ai#11081]` tags in this very file; `git blame` at the base puts all five lines in it. Stage 7's anchor for the same number | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 4), and all 4 are ancestors of the base (`merge-base --is-ancestor`, exit 0 for each; reverse leg, base against each anchor, exit 1 for each; control legs exit 0: stage 1's landing `422db788a`, and the repository's root commit, which lies deeper than every anchor; the history is complete, `--is-shallow-repository` false, 15,167 commits; the anchors lie 2,516, 2,585, 3,082 and 4,207 commits behind the base). Each of the 3 numbers answers 404 on the issues endpoint, which serves pull requests too. No ADR, `scripts/adr-anchors/` file or other `docs/` page records any of the three as its decision. `docs/audits/2026-09-multi-update-per-row-value-census.md` names `objectstack-ai#14744`, but it states that it is "measurement only — ships nothing … implements no guard", the input to a decision rather than its record, so the census line cites the commit that landed it. ## Wordings to check - **Tag swaps in brackets or parentheses.** 「[objectstack-ai#14744]」 became 「[commit 4f85e4d]」 at `decouple-flow-record.test.ts:4` and `before-update-flow-payload-reach.test.ts:805`. 「[objectstack-ai#11081]」 became 「[commit c28e4cf]」 on 5 lines. 「(objectstack-ai#14744, measured by objectstack-ai#15356)」 became 「(commit 4f85e4d, measured by objectstack-ai#15356)」 at `decouple-flow-record.ts:5`. 「(objectstack-ai#14744)」 became 「(commit 4f85e4d)」 at `record-change-trigger.ts:340`. 「(objectstack-ai#8738 pre-hook / objectstack-ai#13657 post-hook)」 became 「(objectstack-ai#8738 pre-hook / commit b003cf2 post-hook)」. - **Headings `:4` and `:859`.** 「[objectstack-ai#15356 measured, objectstack-ai#14744 closed]」 and 「[objectstack-ai#15356 measured it, objectstack-ai#14744 closed it]」 keep the live `objectstack-ai#15356` and put the sha where the dead number stood. - **`before-update-flow-payload-reach.test.ts:10`.** 「objectstack-ai#14744 then ruled the door closed」 became 「The option-A ruling (commit 4f85e4d) then closed the door」: the ruling is named in words beside the commit that carried it, whose changeset records it, the form stages 2, 6 and 7 used for a ruling. - **`:22` and `:87`.** 「the objectstack-ai#14744 residue shape」 and 「the objectstack-ai#14744 pinned residue shape」 became 「the residue shape commit 4f85e4d pins」: the positive control that pins it is in that commit's diff. - **`:23`.** 「because objectstack-ai#14744's fix is about aliasing」 became 「because commit 4f85e4d fixes aliasing」: a commit fixes something, it does not have a fix. - **`:29` and `:34`, the census paragraph.** 「objectstack-ai#14744's census found」 became 「The census in commit 03c1b0f found」. That removed the referent of 「The conclusion recorded on that card」 five lines down, so `:34` became 「The conclusion recorded in that census」. This is the one changed line that carried no dead number. It is true as written: the census record `03c1b0f6f` landed carries that very conclusion, "On a source reading, `buildContext` materialises a *new* record object by overlay … a reading, not a measurement" (`docs/audits/2026-09-multi-update-per-row-value-census.md:308-311`). - **`:455`.** 「that is precisely the blind spot objectstack-ai#14744 is weighing」 became 「… the blind spot commit 4f85e4d left unguarded」. The present tense described a card still being weighed; that commit's changeset says the key-set refusal "is untouched and is not widened — a hook that assigns the same key with per-row values still passes it". - **「Before objectstack-ai#14744」 / 「before objectstack-ai#14744」** at `:686`, `:705`, `:738`, `:924` (the word 「Before」 sits at the end of the line above at `:685` and `:704`) became 「before commit 4f85e4d」: before that commit the flow-facing record shared its nested values with the payload, which is the reading each sentence quotes. - **「objectstack-ai#14744 made」, 「objectstack-ai#14744 carries the fix」, 「objectstack-ai#14744 closed the door」** at `:47`, `:95`, `:642`, 「Until objectstack-ai#14744」 at `record-change-trigger.ts:341`, 「and objectstack-ai#14744.」 at `:124`, 「objectstack-ai#14744 — DECOUPLE」 at `:453`, 「(unchanged by objectstack-ai#14744 —」 at `:496`: the number became the commit, and each sentence already states what the commit did. ## The 4 sites left - **Test strings, 4 sites on 4 lines**, all `describe` / `it` titles carrying `objectstack-ai#14744`, left as stages 1 to 12 left theirs: `before-update-flow-payload-reach.test.ts:825` and `:872` (the second number of `[objectstack-ai#15356/objectstack-ai#14744]`, a spelling the gate's grammar cannot see), `decouple-flow-record.test.ts:78` and `:136`. - No source string, operator log string, assertion message, quoted maintainer ruling or generated file in this package carries a dead number. - Outside `src`, the package's `CHANGELOG.md` names `objectstack-ai#14744` on 2 lines (467, 478). It is release-owned and deliberately not edited here (see Acceptance notes). The package `README.md`, which also ships, names none of the three. ## Mechanical guard: no code token moves The guard compares, base `91e8fa194` against head, over all 5 touched `.ts` files: - **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild` walk, so comments are trivia and JSDoc nodes are never visited). String and template literals are therefore read in full. - **Reading 2**, the full token stream in parser context (a `getChildren` walk, so punctuation and keywords are included; JSDoc nodes skipped). Results: - Real run at the final head `bbfe7cb24`: 6,110 base leaf tokens, **0 files with a token change** on either reading (exit 0). - Comment control in `record-change-trigger.ts` (「reach nothing outside its own run.」 to 「reach nothing beyond its own run.」): 0 files changed, as expected (exit 0). - Positive control, a code token added in `record-change-trigger.ts` (`params: isolatedRecord,` given `as typeof isolatedRecord`): DIFFER, 953 to 954 leaf tokens and 2,130 to 2,133 full tokens (exit 1). - Positive control, one digit changed inside a kept test title (`decouple-flow-record.test.ts:78`, `objectstack-ai#14744` to `objectstack-ai#14745`): DIFFER on the string literal (exit 1). Every mutation went through `scripts/ablation-replace.mjs` (wrap mode) under a shell trap that restores by absolute path, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`f3235a962fc5`, `9a8bf70abbcc`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/trigger-record-change` (`.changeset/20596-trigger-record-change-provenance-anchors.md`) is included. Its body is stage 12's, word for word, with the package name changed. Measured on the built package (A3), after a full workspace build in which this package was a cache miss: `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. - `4f85e4d11` appears 3 times in each of `dist/index.js` and `dist/index.mjs`: the `buildContext` docblock (`record-change-trigger.ts:340` and `:341`) and the inline comment at `:496`, which the bundle keeps. - It appears twice in each of `dist/index.d.ts` and `dist/index.d.mts`: the same `buildContext` docblock. - The other three anchors appear nowhere in `dist`: their lines are in test files. The rewrites at `record-change-trigger.ts:124` and `:453` and `decouple-flow-record.ts:5` are stripped by the bundle. - Positive controls, one unchanged line beside each rewrite, land exactly where their neighbours do: the line after `:341` once in all four files, the line before `:496` once in each JS file and 0 in the declaration files, and the neighbours of the three stripped rewrites 0 everywhere. - A never-written negative phrase appears nowhere in `dist`. - None of the three dead numbers is left in `dist`. ## Gates (final head `bbfe7cb24`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` exits 0 (self-test, 114 cases, 8 batteries). `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run judged 1 added citation across 2 files, the live `objectstack-ai#15356` at `decouple-flow-record.ts:5`, and it resolves. - **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth). - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `bbfe7cb24` (after a fresh fetch) derived 59 commands. They are all 53 derived at dispatch, plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - Each ran with its exit code captured before any pipe, and all 59 exit 0; none exited 3. - `--ran`, fed each command with its exit code, reports 59 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. - A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock, at `bbfe7cb24`:** - `pnpm --filter @objectstack/trigger-record-change test`: 10 files pass and 101 tests pass. `vitest list --filesOnly` names 10 files, all the tracked test files, the 3 touched ones included. - `pnpm --filter @objectstack/trigger-record-change typecheck` exits 0. `tsc --listFiles` on `tsconfig.test.json` holds all 14 files under `src/`, and on `tsconfig.json` the 4 non-test files, so all 5 touched files are compiled. - **Lint, as a proven narrowing:** eslint with inline config disabled, over the 5 touched `.ts` files, gives 5 files, 0 errors and 0 warnings (its `--format json` output). All 5 are in eslint's own population (`isPathIgnored` is false for each; a `dist` file, as the control, is ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 6 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked.** `CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`, `NON_CITATION_HEADS` excuses a number after the word 「option」, and a URL-spelled link carries no `#` at all (objectstack-ai#20636). In this package, at the base and at the head: `#N-word` none, `#A/#B` 5 lines, `option #N` none, URL-spelled none, which is the claim's 0 / 5 / 0 / 0. Of the five `#A/#B` second numbers (`objectstack-ai#4251` twice, `objectstack-ai#5038`, `objectstack-ai#4649`, `objectstack-ai#14744`), only `objectstack-ai#14744` is dead, and it stands in a kept test title. - **`CHANGELOG.md` is left.** `packages/triggers/trigger-record-change/CHANGELOG.md` names `objectstack-ai#14744` on 2 lines. It is release-owned (AGENTS.md, Documentation Guardrails), a deferred surface of the citation gate, and ⛔ not part of this stage. - **A live number in a runtime string, left for its lane.** `record-change-trigger.ts:239`'s operator `warn` for an array-form trigger event ends with the live `objectstack-ai#3457`, and two tests assert the message carries it. That is form D, not this card's comment-only form C, and the shrink-only `doc-authoring-prose-id` baseline already holds it (`record-change-trigger.ts`: `objectstack-ai#3457: 1`), so `check:doc-authoring` sees no growth. - **「The card」 phrases are left.** 3 other comment lines in 2 files of this package speak of 「the card」. They carry no number, neither instrument sees them, and none of them lost a referent in this diff. They are unchanged, as in stages 8 to 12. - **The census instrument did not truncate in this stage.** All three enumerations read 187 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#14744` → `4f85e4d11` (the decoupling) or `03c1b0f6f` (its census), both new to the sweep; `objectstack-ai#13657` → `b003cf2e8` and `objectstack-ai#11081` → `c28e4cfae` reuse the runtime and lint stages' anchor and stage 7's. - **Base.** The branch is on `main` at `91e8fa194`. `main` has since moved six commits (`cd6d8a5ff`, `1bcba27d2`, `a3d7588b5`, `9ad654487`, `274e16271`, `085ca6bc1`). Their 50 files touch nothing under `trigger-record-change`, nor `scripts/check-issue-citations.mjs`, `.changeset/config.json` or the `doc-authoring-prose-id` baseline, and none is a path in this diff. Three of them are gate inputs (`scripts/engine-double-contract.pinned.json`, `scripts/objectql-double-limit.baseline.json`, `scripts/sdui-manifest.record.json`), so those families ran here against the base's copies; this diff moves no code token, so nothing here can interact with them. No merge was taken; the merge queue rebuilds on the merged generation. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #13657
The declared-field door (#8682 insert, #8738 update) sits in front of the
before*hooks — deliberately: PR #8737 moved it there so no work, above all no autonumber, is consumed by a payload about to be refused. That left the payload the hooks themselves produce unjudged, and from there the three drivers did three different things.This adds the post-hook, pre-statement half of the same door. ⛔ The pre-hook door is not moved, merged, or weakened — moving it re-opens #8682, and the pin that it still refuses a caller key before any hook runs is asserted in this PR.
The measurement this card turns on — A2.2, re-run here
packages/,examples/andapps/, and it caught its own blindness once: the first pass matched only dot and string-literal keys, so it misseddata[SEARCH_COMPANION_FIELD] = …inplugin-pinyin-search. Computed-key support was added and the scan re-run. Positive controls fire (input.probability,data.priority,ctx.input.title— declared keys, all present in the output).sys_stamp_audit_insert/_update—objectql/src/plugin.tsobject: '*'— every objectcreated_at,updated_atobject: '*'created_by,updated_by,tenant_idhasField(objectName, …)bindSearchCompanionHooks—plugin-pinyin-search__searchschema.fields[__search]installCommentAccessHooks—plugin-auditsys_commentauthor_idsys-comment.object.ts)installAttachmentAccessHooks—service-storagesys_attachmentuploaded_bysys-attachment.object.ts)applyCopyOnClaim—service-storageif (!(field in data)) continue)examples/app-crmopportunity hookcrm_opportunityprobabilityexamples/app-todotask hooktodo_taskpriority,status,completed_dateexamples/app-showcaseL2 bodiesshowcase_task,showcase_inquirytitle;status,sourceExcluded, with reason:
plugin.ts_packageId(stamps a metadata definition read from a stored row, not a record payload) ·spec/conversions/registry.tsctx.input.fp/trace(conversion-migration fixture strings) ·seed-loader.tsorganization_idandimport-runner.tsdata.id(caller-side payloads, already governed by the pre-hook door) ·record-change-trigger.tsmaterializeDeclaredFields(iterates declared names only) ·objectql-adapter.tsnormaliseIdentifierWrite(rewrites present keys only).The "platform-tolerated" set, enumerated
Exactly
id,created_at,updated_at— and they are alreadyPLATFORM_PROVISIONED_COLUMNSinundeclaredWriteFieldErrors, tolerated since #8682 for the same reason the audit hook writes two of them unconditionally: SQL drivers create them as built-in columns on every table whether the object declares them or not. So the post-hook door needed no new tolerance and no exemption list — the set the census found is the set the function already had.Two facts keep that honest, both pinned:
registerObjectinjectscreated_at, created_by, organization_id, owner_id, owning_business_unit_id, updated_at, updated_by, so the audit family is declared. Measured, not assumed.systemFields: falseobject (the hard opt-out — seed / migration tables), where the registry injects nothing. That is the object the new tests use, so the tolerance is exercised rather than shadowed.tenant_idis the nearest miss and is refused: the platform writes it, but only behindhasField, and the registry injects it under no setting. Pinned, so that guard cannot quietly become optional.⇒ A2.2 HELD. STOP condition 1 does not fire. No shipped hook writes an undeclared, untolerated key.
Zone 2 verdicts
undeclaredWriteFieldErrorsreusable unchangeddriver-sql(better-sqlite3) anddriver-memory:memoryno longer persists the shadow column. Doors exercised are named below.FieldMasker.detectForbiddenWritesreports only fields explicitlyeditable: false; an absent entry is fail-open. An undeclared key can carry no entry, so field-level security could never gate it. Additionally the FLS write gate readsopCtx.datainside the middleware, whichexecuteWithMiddlewareruns strictly before the executor holding the hooks — so no hook-written key is presented to it at all.insert intoand no bound values. It is unreachable because no driver is reached — the leak lived in the driver's own error string. ⛔ Not widened: the pre-existingmapDataErrordriver-string branch is untouched.Verification — at final commit
b5fd717e17pnpm --filter @objectstack/objectql test— 251 files / 4339 tests passpnpm --filter @objectstack/runtime test— 202 files / 3015 tests passengine-post-hook-undeclared-field.test.ts— 17 tests, three driver flavoursundeclared-field-write-driver-split.integration.test.ts— 12 tests (was 8), real sandbox + real driversplugin-audit22 ·plugin-sharing30 ·plugin-pinyin-search2 ·service-storage33 ·trigger-record-change7 ·plugin-approvals34 ·example-crm45 tests ·example-todo106 tests ·example-showcase364 tests ·metadata-protocol2040 tests ·dogfoodhook suites 8 ·clihook suites 41 ·linttargeted 197node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the actual diff (not a hand-written list): 33 gates, all green.check:doc-authoringcaught a tracker id I had put in a runtime lint string — stripped to an adjacent comment, per the rule that a runtime string reaches readers who cannot resolve#NNNN.check:dual-build-cjs-loads— it exits 3PREREQUISITE NOT MET("⛔ This is NOT a pass: nothing was measured") because 26 packages have nodist/in this container. It needs a fullpnpm build; CI runs it. This diff changes no package exports or build config.Ablation
Both post-hook doors neutered (fed an empty row list), mutation proved on disk (2 injected markers, 0 surviving call arguments, blob hash moved off
HEAD): 13 of 17 new tests fail. The 4 survivors are exactly the acceptance controls — the two platform-stamp cases, the declared-key positive control, and the pre-hook-door control — which should pass without the new code. Restore verified byte-identical to theHEADblob withgit diff HEADempty, via atraponEXIT INT TERMwith absolute paths. No rebuild was needed and none is claimed: the new tests import./engine.jsrelatively from source, and the package's vitest config declares no alias, sodist/is not in that resolution path.What changed outside
packages/objectql, and whyThe behaviour change falsified prose and a pin that asserted the old split. Leaving them would ship a false statement and a red
main.packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts— this file existed to pin the divergence. Its two arms are now oneit.eachover both families asserting the convergence. Assertion count goes up (8 → 12); nothing is skipped, disabled or deleted. Itssys_organizationexpected-noiserequiredset is narrowed to none — the documented remedy for "a table read on only SOME of a file's paths", which is what the tenancy probe became once the refusal short-circuits ahead of it. The capture still withholds the noise where it fires and still fails when no capture is installed.packages/lint/src/validate-hook-body-writes.ts— thectx.inputmessage and the header described the split as current runtime behaviour. Corrected. The rule is not retired: it fires at author time and names the field, object and body, where the runtime refusal fires at write time on whichever record first reaches the branch.content/docs/automation/hook-bodies.mdx— same correction to "What still happens at runtime".Three fixtures repaired, each the same defect this card closes
Each had a hook writing a field its own object never declared — spec-illegal before this PR, merely invisible.
engine.test.ts(batch-hook) — mocked field map now declaresstamped, the field its own hook writes.engine-undeclared-update-field.test.ts—stubFieldsnow declaresdescription, which the harness's hook stamps.idis still the one name the registry does not inject, so the tolerance under test is unchanged.search-companion-read-projection-conformance.test.ts— itsbindCompanionStampdouble was looser than the hook it doubles: it lackedstampCompanion'sif (!schema?.fields?.[SEARCH_COMPANION_FIELD]) return;guard, so in thedeclared: falsearm it stamped a column the real hook returns early on. The guard is restored. (In that arm the real plugin binds no hooks at all — pinyin is off.) The arm's subject is untouched: the stored row still carries the blob from the fixture seed, and every read door must still project it away.Scope
out of scope: #13858— filed from this work, not addressed here. Three sibling lint messages (ctx.apiwrites in hook bodies, action bodies, flow nodes) still describe a driver-dependent outcome for caller-supplied payloads, which the #8682/#8738 pre-hook door already refuses. That staleness predates this card and wants its own reproduction.Sibling-seat surfaces: this diff touches
packages/objectql,packages/runtime(one test file),packages/lint(one rule's prose) andcontent/docs. It does not touchpackages/metadata-protocol/src/protocol.ts(#13576, #13445) orpackages/drivers/driver-memory(#13435) — the runtime test consumes driver-memory as its ruled test consumer, but the driver itself is unmodified. Inpackages/objectql/src/engine.tsthe edits are at the two write-path door sites (~9490 and ~10470); the driver-registry region (~5215, ~7850) that #13578 works in is untouched.Clause ② self-declaration — from the actual diff
YES, content limb. This narrows what a write accepts: a hook-written undeclared key is stored today on⚠️ The implementation seat ran at the default tier after the
memoryand is refused after. Path limb NO — nothing underpackages/spec/src/**.needs:contract-reviewattached at creation.CONTRACT_REVIEW_TIERexhaustion recorded on the card; the label requirement is unconditional and is met here.Generated by Claude Code