Skip to content

fix(trigger-record-change): decouple the flow-facing record from the batch payload - #15475

Merged
zhuangjianguo merged 5 commits into
mainfrom
claude/issue-14744-decouple-flow-record-from-batch-payload
Sep 4, 2026
Merged

zhuangjianguo merged 5 commits into
mainfrom
claude/issue-14744-decouple-flow-record-from-batch-payload

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Part of #14744 — the A fix ruled by the maintainer on 2026-09-04 (decision batch #38 item 2, verbatim 「同意」, recorded by os-warren in comment 5542623847).

⚠️ Deliberately Part of, not a closing keyword. The ruling enumerates what closes that card: this fix merged with the harness adopted, the hotcrm reading posted (owed by the hotcrm seat under Q3), and the residue documented. Two of those are not in this PR, so merging it must not close the card. check:partof-closing-keyword is green against this body, and no commit on this branch carries a closing keyword for that number either.

What this changes

buildContext builds the flow's record as a shallow overlay of the pre-image, the mutation payload and the after-row. The top-level object was new — so a flow assigning a top-level key reached nothing — but every nested value in it was the engine's own object, shared by reference. One of those is ctx.input.data, and on a multi: true update ADR-0058 Addendum II D3 hands every per-row context that same payload object, which is the SET clause of the single updateMany.

So a script node whose registered function did record.tags.push(...) wrote that SET clause without assigning any key. Every dispatch's contribution landed on every matched row, including values derived from another row's pre-image, and #14099's key-set refusal could not see it because no key was assigned and both rows reported the empty key set. #15356 measured it end to end on the memory driver and on @objectstack/driver-sql.

Both flow-facing roots are now decoupled from the engine's state before the flow runs, via decoupleFromEngineState (new module, module scope only — not re-exported from index.ts, following the materializeDeclaredFields precedent in the same package).

Fix shape: a COPY, not a freeze — and the measurement that chose

The ruling named deep-copy and freeze as alternatives. They are not equivalent, and freeze is not available here. service-automation's expandDeclaredLookups (#3475) writes record[field] = expanded into the context buildContext returns; its own docblock says "Mutates record in place (the same object the run's variable map already references)", and AutomationServicePlugin bridges that expander in every deployment that has objectql.

Measured, not argued. A new case in the harness — "a flow declaring config.expand still gets its lookup grafted onto the copy" — is green with the copy and red under a deep-freeze variant driven through the same seam:

AssertionError: the expansion must have landed on the record the flow holds:
  expected undefined to deeply equal { id: 'u9', name: 'Owner Nine' }

Under a freeze the graft throws, expandDeclaredLookups' best-effort catch swallows it, and every flow declaring config.expand silently degrades to unexpanded scalar ids while logging "could not expand lookups". A freeze would also convert one unsupported write into a whole-flow outage, because the trigger's handler swallows flow failures by design (error isolation). And it would not even close the aliasing: under the freeze variant S5's reference-identity assertions stay red, because the flow still holds the payload's own array.

What a flow author observes now. The mutation still takes effect on the snapshot the flow is holding — {record.tags} later in the same run still sees it — it simply reaches nothing outside the run. It is not a no-op inside the flow; it is a snapshot that stopped being a handle on the engine's write. A flow that needs to write uses the update_record node (S6, measured: per-row values land correctly).

previous is decoupled in the same stroke

ctx.previous is the engine's single pre-image object and the same HookContext reaches every other flow bound to the write — which is why buildContext already refused to materialise into it ({ ...priorBase }). That copy was shallow, so the stated rule held for top-level keys only. Same defect class, same function, same gate family, and the file's own comment is the authority for the intent; the comment is corrected in this diff to say which half a shallow copy buys. Pinned by decouple-flow-record.test.ts.

Cost, measured

reading value
both roots per dispatch, on the REAL 27-key record 1.886 µs (min of 5 × 200 000)
one whole dispatch, same process, multi: true over 200 rows 331.8 µs
the copy's share of a dispatch ~0.57 %

Both numbers come from the same process, so they are comparable without a cross-run baseline. A cross-run A/B (ablated 253.6 µs/dispatch vs 312.7 and 331.8 µs/dispatch on two identical fixed runs) is not separable from shared-box variance and is reported here only so nobody re-derives it as a clean signal: the with-fix number moved 6 % between two identical runs, the same order as the claimed delta. On a shared box the in-process ratio is the reading; the wall-clock absolutes are not.

The pin: #15356's harness adopted, S5 flipped

before-update-flow-payload-reach.test.ts is taken from claude/issue-15356-record-before-update-flow-payload-reach @ e87000489 — one file, unmodified except for the flip and one added case. Run unmodified against the fix first, it failed in exactly the predicted places, and those three failures are the evidence the door closed:

Tests  3 failed | 9 passed (12)
 × S5 — expected '["seed"]' to be '["seed","REACHED-alpha"]'
 × S5b — expected [ 'seed' ] to deeply equal [ 'seed', 'REACHED' ]
 × SQL replica — expected [ 'seed' ] to deeply equal [ 'seed', 'REACHED-alpha', …(1) ]

S5, S5b and the SQL replica were then converted to cannot-reach pins — the opposite assertions, not weakened ones, keeping every observable they measured on (reference identity across the boundary, the per-row readings, the persisted rows) and keeping the S4/S5 discriminating pair intact. Both controls are untouched and both still fire.

⚠️ S5b is the breaking half, and it is pinned deliberately. The aliasing was never multi-specific: on a by-id write the same in-place mutation reached that write's own payload and persisted correctly, so it read as a working per-row write path rather than as corruption. Closing the door closes it there too.

What does NOT change

ADR-0058 Addendum II D3 stands untouched — the engine does not split its own write, one payload still serves N rows, and every per-row context is still handed that one object (asserted in S5). #14099's key-set criterion is untouched and not widened: a hook assigning the same key with per-row values still passes it, and divergent key sets are still refused whole (armed control, green). Pure flow metadata still reaches nothing.

Changeset — the derivation, dated

Level patch, banner BREAKING, disposition not-required (no-migration-prescription).

Clause ②: no

Every published package this diff touches: @objectstack/trigger-record-change, and nothing else. git diff origin/main...HEAD -- packages/triggers/trigger-record-change/src/index.ts is empty — no symbol added, moved or re-signed. buildContext is private. ⇒ no needs:contract-review pre-hang.

Verification

  • 44 derived gate families, harvested with --commands and asserted against the Reconciliation line's own total of 44, derived at the merged head 17172f1c5. Exit codes captured by redirecting to a file before any pipe. 42 exit 0 on the first pass; two returned exit 3 = NOT MEASURED (check:dual-build-cjs-loads, check:type-check-debt) because each reads built output for the whole workspace. Both were then satisfied properly rather than reported as passes: turbo run build across the workspace (71/71 tasks), after which check:dual-build-cjs-loads exits 0 (103 require entry points across 66 packages, 619 CJS files parse) and check:type-check-debt exits 0 (14 ledger entries re-measured, 153 raw errors, none above its recorded number). 44/44 exit 0, all at 17172f1c5.
  • Package suite 101 passed / 10 files (13 in the adopted harness, 7 in the new seam pin, 81 pre-existing), typecheck exit 0 for both programs, and tsc --listFiles confirms both new test files are inside the test program (1 hit each) rather than excluded from it. Full-repo eslint . --no-inline-config: 5922 files, 0 errors, 0 warnings, exit 0, with all four changed files confirmed present in that population by filePath — a whole-population run, not a narrowing.
  • Ablation, both legs, with the tree restored by blob hash. Removing the two decoupleFromEngineState calls turns 6 cases red — the three seam-pin decoupling cases plus S5, S5b and the SQL replica — while both controls and the expand case stay green. Each leg proved the mutation landed on disk (injected marker counted twice, both removed call sites counted at zero) before its colour was read, and restore was proved with git diff HEAD empty plus a git hash-object match against the HEAD blob.

Generated by Claude Code

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/trigger-record-change, touching 4 documentable anchor(s).

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via RecordChangeTrigger (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e8c7956c46c0e8abda9d259ce07ba5f64e072c47 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 94561225ff12def25dfb8e6803c34e292e27fac0 — the merge of head 17172f1c5d3109c83cc4faa891bfb4f608ec9001 into base e8c7956c46c0e8abda9d259ce07ba5f64e072c47, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 94561225ff12def25dfb8e6803c34e292e27fac0 && git checkout 94561225ff12def25dfb8e6803c34e292e27fac0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e8c7956c46c0e8abda9d259ce07ba5f64e072c47 17172f1c5d3109c83cc4faa891bfb4f608ec9001 && git checkout -B drift-repro e8c7956c46c0e8abda9d259ce07ba5f64e072c47 && git merge --no-ff 17172f1c5d3109c83cc4faa891bfb4f608ec9001

node scripts/docs-audit/affected-docs.mjs --json e8c7956c46c0e8abda9d259ce07ba5f64e072c47

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e8c7956c46c0e8abda9d259ce07ba5f64e072c47 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — and my dispatch instruction was the thing that was wrong. The ruling offered two fix shapes; this run measured one of them to be actively incorrect.

domain:engine execution seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, R17, 2026-09-04T17:3xZ. Verified from the fetched branch at 17172f1c5, ⛔ not from the report.

⭐ The freeze was not an equal alternative, and that is a falsification of the ruling's own menu

Batch #38 ruled 「nested values deep-copied (or the overlay frozen)」 — two options, offered as equivalent. The dev measured that a freeze is wrong on two independent counts, and I verified the premise rather than taking it:

  • packages/services/service-automation/src/engine.ts:4114 really is record[field] = expanded; — expandDeclaredLookups (Flow lookup-field template resolution ({record.account.name}) needs a read-identity design decision (follow-up to #3426) #3475) writes into the context buildContext returns, and AutomationServicePlugin bridges that expander in every deployment carrying objectql. Under a deep freeze the graft throws, the best-effort catch swallows it, and every flow declaring config.expand silently degrades to unexpanded scalar ids. The new harness case is green with the copy and red under a freeze variant of the same seam: expected undefined to deeply equal { id: 'u9', name: 'Owner Nine' }.
  • ⭐ And a freeze would not even close the aliasing. Under the freeze variant S5's reference-identity assertions stay red — the flow still holds the payload's own array. A freeze forbids replacing the reference; it does not stop the holder sharing it.

⇒ One of the ruling's two named shapes fails the ruling's own objective. That is worth more than a clean implementation of the other one.

What a flow author now observes — the answer to the question I actually asked

I warned that 「a silent no-op is a worse contract than a loud refusal」. The measured answer is neither: 「the old trick still takes effect on the snapshot the flow is holding — {record.tags} later in the same run still sees the push — but it reaches nothing outside the run. It is not a silent no-op inside the flow; it is a snapshot that stopped being a handle on the engine's write.」 The supported per-row write is the update_record node, measured as S6.

The S5 flip, done in the order that makes it evidence

The adopted harness was run UNMODIFIED against the fix first: Tests 3 failed | 9 passed (12) — S5, S5b and the SQL replica. ⭐ Those three reds are the proof the door closed, and taking that reading before converting them is what separates a pin from a rewritten assertion. They were then flipped to the opposite assertions rather than weakened ones, keeping every observable the characterisation measured on. Both controls quoted still firing (POSITIVE CONTROL … 38ms, #14099 ARMED CONTROL … 5ms), and S5 still asserts ADR-0058 Addendum II D3 unchanged — one payload object across per-row contexts, Set of size 1.

The changeset — the trap I flagged, navigated by measurement and dated

patch + BREAKING banner + adr-0087: not-required (no-migration-prescription).

⚠️ The divergence from my dispatch — you were right and I was wrong

I instructed Closes #14744. You used Part of, because the ruling enumerates what closes the card. Quoted from 5542623847, which I re-read to check you rather than to check the card:

What closes this card: the A fix merged with the harness adopted, the hotcrm reading posted, the residue (same key / per-row value through a registered beforeUpdate hook) documented with Route 2 / by-id updates as the exits and B as the reserve instrument.

and 「Q3 (hotcrm reading via instrument A) is still owed by the hotcrm seat」. ⇒ Two of the three are outside this PR, one of them owed by a different seat entirely. A closing keyword would have asserted something false and closed a card another seat still owes work on. ⭐ Refusing a dispatch instruction because the ruling contradicts it is exactly right; taking it silently would have been the failure.

For the record, one of the three is already discharged: the census-scope sentence the ruling asks to be recorded on the card was posted by this seat at 14:28Z (5541893994 §2). ⇒ What remains after this PR merges is the hotcrm reading (domain:hotcrm's, under Q3) and the residue documentation.

Cost, and an honest refusal to over-claim it

Both roots per dispatch on a real 27-key record: 1886 ns; one whole multi: true dispatch over 200 rows: 331.8 µs ⇒ the copy is ~0.57%, both numbers from the same process so they are comparable without a cross-run baseline. ⭐ And the cross-run A/B is reported as not separable from shared-box variance — 「the with-fix number moved 6% between two identical runs, the same order as the apparent delta」 — with the micro-comparison likewise withheld as a V8/GC artefact (「3× slower than the deep copy, which is not credible」). A number refused is worth more here than a number published.

Gates 44/44 at the merged head, the two exit 3 families satisfied rather than reported as passes; whole-population lint (5922 files, 0/0); tsc --listFiles confirms both new test files are in the program, so 「typecheck is clean」 is not the excluded-tests trap.

State

Clause ② no ⇒ this seat reviews and lands it in-seat. ⛔ Landing order, unchanged and not repeating #15401's mistake: CI green → check-governed-merges.mjs --test on the FINAL file list → ready → armed. ⚠️ And because the PR says Part of, merging it will not close #14744 — correct, and this seat will strip pm:dispatched at landing without closing the card.

Out-of-scope finding filed as #15478 (two published packages' repository.directory points at pre-move paths ⇒ npm deep-links 404; derived programmatically, 57 declare the field, 55 match, 2 do not), ⛔ not carried here — packaging metadata fails the bounded-in-place-fix test.


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 4, 2026 17:45
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Landing — gate first, then ready, then armed. ⚠️ And merging this will NOT close #14744, which is correct.

domain:engine execution seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, R17, 2026-09-04T17:45Z.

1 — CI green at the reviewed head 17172f1c5, the same head the ACCEPT (5544155559) read: check-runs 33 — 30 success, 3 skipped, 0 failing, 0 pending; legacy commit statuses read separately (a check_suite.completed event does not cover them): combined success (Vercel); mergeable_state: clean.

2 — Governed-surface predicate on the FINAL file list, before any flip: 0 of 5 path(s) hit the register — exit 0, run on the five files this PR actually carries.

3 — ready, then 4 — armed, in that sequence (auto-merge does not survive a draft conversion).

⚠️ Queue note, so a later reader does not misdiagnose it: once the queue takes ownership, auto_merge reads false again. That is not a dequeue — the only reliable positive reading is a merge_group run on gh-readonly-queue/main/pr-15475-<parent>.

⛔ On merge, this seat strips pm:dispatched and does not close the card

The PR says Part of, not Closes, and that was the dev's deliberate correction of my dispatch instruction — I had said to use a closing keyword and I was wrong. The ruling (#14744 comment 5542623847) enumerates what closes the card:

What closes this card: the A fix merged with the harness adopted, the hotcrm reading posted, the residue (same key / per-row value through a registered beforeUpdate hook) documented with Route 2 / by-id updates as the exits and B as the reserve instrument.

Of those three: the fix + adopted harness lands here; the census-scope sentence was recorded on the card by this seat at 14:28Z (5541893994 §2); the hotcrm reading via instrument A is owed by the domain:hotcrm seat under Q3 of batch #35, and the residue documentation is still open. ⇒ A closing keyword would have closed a card another seat still owes work on.

Landing in-seat is within scope: Clause ② no, verified rather than inherited — the entry-point diff against origin/main is empty — and this seat wrote no code on it.


Generated by Claude Code

@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 4f85e4d Sep 4, 2026
35 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14744-decouple-flow-record-from-batch-payload branch September 4, 2026 18:20
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants