Skip to content

fix(gate): read-seam recognizer matched a callee NAME with no shape check, so Array.prototype.find pulled non-read catches into the census - #12137

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-11921-read-seam-receiver-check
Aug 25, 2026
Merged

fix(gate): read-seam recognizer matched a callee NAME with no shape check, so Array.prototype.find pulled non-read catches into the census#12137
yinlianghui merged 2 commits into
mainfrom
claude/issue-11921-read-seam-receiver-check

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#11921

isReadCall() decided "is this call a storage read?" by matching the callee name against DRIVER_READ_CALLEES and nothing else. find is IDataDriver.find and it is also Array.prototype.find, so a pure in-memory helper matched — and the same-file wrapper recursion then attributed that "read" to the helper's caller, pulling a try/catch that guards no storage read at all into the read-seam census.

The fix, and why this shape

A declared fact about the contract the vocabulary is already anchored to, not a new list of names:

every read on IDataDriver takes the object name first — find(objectName, query), findOne(objectName, …), count(objectName, query?). A predicate is never its first argument. Array.prototype.find's first argument is always one.

So a call whose first argument is a function literal is not a call to that contract, however it is spelled. The predicate can only decline to exclude: it fires on a shape the declared contract cannot produce, so it cannot drop a real driver read.

Chosen on measurement, not on taste — what each alternative would have excluded

The card offered three shapes and recommended none. All three were measured across the whole census (90 matched reads across 66 seams), not against the one known site.

shapereads excludedseams removedcensus
taken — first argument is a function literal5266 → 64
card (2) — any function-literal argument5266 → 64
card (1) — require a receiver that is not a plain array/local binding (implemented as "receiver is this or a this. chain", the closest syntactic form)201466 → 52
card (3) — leave it, record the imprecision0066

Card shape (1) is the dangerous one, and only measurement says so. Of the 14 seams it removes, 12 are real driver readshistory-cleanup (×4), build-probes (×3), seed-tenancy-backfill, diffMetaItem, the dangling-reference audit, loadGovernance, checkGovernance. The genuine driver reads arrive through receivers spelled this, this.engine, this.ql, driver, engine, engineAny, ledger, port; the array reads through list, allResults and a .map(...) chain. Nothing in the shape of a receiver separates those two lists — only what the name is bound to does, and that is a type-checker. Declaring the receiver names instead only postpones it: the next driver binding gets a name the list does not have, and its seam leaves the census silently. That is the unsafe direction — a population that shrinks without a word.

The taken shape and card (2) are indistinguishable on today's data; the taken one is preferred because it is strictly narrower in the safe direction (a function in a query option cannot make a real driver read disappear).

⭐ Not tuned to the known instance. The card measured 1 of 66. The correct number is 2 of 66, and the second was not known when the card was filed:

  • getMetaItems (metadata-protocol/src/protocol.ts) — the filed instance. Its try guards mergePackageAwareOverlay, a pure helper whose list.find((c) => …) runs on a plain local array. The try holds exactly one await and no find/findOne/count of its own.
  • resolveDeferredUpdates (metadata-protocol/src/seed-loader.ts) — new, and the worse of the two: its try guards writeDeferredReference, a WRITE. It was in the read-seam population only because allResults.find(r => …) updates an in-memory stats row.

Five spurious matches across four call sites, in two files. Two of the four sit at depth 0 (a direct find in the try block), not at a wrapper hop — so "exclude a wrapper hop", as shape (2) was phrased, would have been too narrow by half.

The census, restated rather than silently invalidated

Every figure on #9165, #8901 and #8845 is quoted against a 66-seam population. Two of those 66 were never read seams. Re-measured with the corrected recognizer over the same anchor tree, so the correction is a census and not a subtraction:

treeold recognizercorrected
origin/main @ 8664a2c (#8845 anchor)6664
origin/main @ 8619f9513 (this branch's base)6664

The old recognizer reproducing 66 at 8664a2c is the calibration: that is the number the script header already tells the reader to re-run the gate and check, so the instrument is verified against the record before it is trusted. The same two functions are the non-members on both trees.

Directly measured and unaffected: the #9165 assignment ladder (3 / 1 / 1 / 0) and every parenthetical in the verdict line — 8 type-discriminated, 1 pass-through, 1 answer-by-assignment, 1 baselined — are byte-identical before and after. Only the seam count moves.

⛔ The #8845 and #9165 ladder rungs are deliberately not re-derived here. Re-running those narrowings is the census re-run #8901's restart conjunct (b) reserves, and #8901's own ⛔ forbids re-opening the #8845 decision; #11921 forbids this fix riding along with any other change. What is restated is the denominator every one of those figures is quoted against, plus enough of each departing seam's catch shape (recorded in the header) that any rung's owner can place it exactly.

No baseline entry was added. The baseline's own header says an entry means "a human read it, not a rule that guesses", and booking a recognizer imprecision there is the failure this card exists to prevent.

#8897 — decided here, as its Restart-when: requires

#8897's restart line is "any PR touches scripts/check-durability-degradation-log-level.mjs (decide options 1/2/3 in that change)". This PR touches it, so the three-way is settled rather than passed by.

Taken: option 1 — leave LOGGER_RECEIVERS as it is, and record the narrowness in the header beside the other honest limitations. The decision, its reasoning and its cost are written at the constant itself; the top-of-file limitations list grew from two to three.

The filer's weak preference was also (1), but the reason recorded is not the filer's — it is the measurement this PR ran on the mirror-image question, which none of the three options had:

  • This card's whole subject is that matching a name with no provenance manufactures false members. Asking the same question of the receiver side was measured here, and receiver provenance is not syntactically available either — see the 14-seams-of-66 result above.
  • Options 2 and 3 are that same instrument, pointed at loggerLevel. And the direction of their error is the unsafe one, which the filer's analysis did not price: for the read-seam rule collectLoggedLevels powers an exemption ("the catch said something, so this is the other rule's question"). Widening what counts as a logger widens an exemption — a seam that invents an answer gets excused because something that is not a logger happens to own a .warn.
  • Option 1 keeps the error where this file keeps it everywhere else: a genuinely loud catch may be over-counted as silent, and no invention is ever excused for a reason nobody declared.

⚠️ What option 1 costs, stated rather than hidden: the misclassification is real and still latent — the read-seam rule consults the log exemption only after finding an invented answer, and the live injected-receiver seam (dangling-reference-audit.ts, receiver port) invents none. #8897's second restart condition is unchanged and is the promote-immediately one: a seam reporting through an injected receiver going red with a "silent" message. If that fires, this decision was wrong and the evidence will say so out loud.

⛔ Not decided here, deliberately: the call-shape half of the same function's narrowness (#9657, closed) — a different defect, already answered by #9609.

⚠️#8897 is pm:on-hold and its stated restart has now fired and been answered. Its label needs updating when this lands.

#8901 — named, per its trigger-file clause

#8901's clause is real, and it is worth saying where, because the card's body attributed it to #8901's Restart-when: line and it is not there. It is in the maintainer hold record of 2026-08-16 (comment 5306092540), verbatim:

Trigger file (opportunistic-restart clause): any PR touching scripts/check-durability-degradation-log-level.mjs — dispatches whose file surface intersects it must name this card; note #8897 (held) must be decided in the same change whenever this file is edited.

Reaffirmed on 2026-08-17 ("trigger files unchanged"). Named here, therefore, and #8901 remains open and on hold. ⛔ The #8845 decision is not re-opened. #8901 is not addressed here beyond this naming — out of scope: the declared failure-propagation vocabulary it exists for.

⚠️ One thing #8901's owner needs from this PR: the census figure its Restart-when: conjunct (b) is measured against moves from 66 to 64. Its ladder readings taken at 945ffbea8 and earlier were all taken with the pre-fix recognizer.

Self-test, both directions, and one fixture that was not

Five fixtures, and expectSeams is the load-bearing assertion in the passing ones — expectViolation: false would hold vacuously if the seam were merely graded green, and what is asserted is that it is not in the population at all.

Reverse-verified by ablation, in both directions, with the mutation confirmed on disk each time and restored through a trap:

  • reverting the shape test → 2 cases red, census back to 66;
  • forcing the predicate to over-fire (return true) → the 3 flags cases red (31 in total), i.e. the guards against subtracting a real read are not vacuous either.

⚠️ The first ablation caught a fixture of mine that asserted nothing: the wrapper-hop case spelled its array find inside a records.map(...) callback, and isReadCall's wrapper recursion walks the same tick only, so the call was never reached in either direction and the case passed pre-fix and post-fix alike. Respelled as a same-tick for loop — which is also the shape of the live instance — in its own commit.

Verification

Gate union re-run at the final commit 9154242e8, all exit 0: check:agent-test-spelling, check:cross-package-test-inputs, check:durability-log-level, check:entry-guard, check:parse-guard, check:pnpm-filter-targets, check-ci-filter-parity.mjs, check-cross-package-test-inputs.mjs, plus check:ratchet-remedy-authority, bare-root-worklist.mjs --self-test and check-nul-bytes.mjs. Gate list derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack in this worktree.

Repo-wide eslint . --no-inline-config: 5099 files, 0 errors, 0 warnings (not narrowed).

The two landing obligations that a gate edit can owe were checked rather than assumed, and neither fires: bare-root-worklist.mjs reports 0 untriaged rows, and check-ratchet-remedy-authority --list still observes this file as [anchor: shrink], with the sweep's control corpus set-equality audited both ways (the OR : offer text is untouched).

skip-changeset: the diff is one file under root scripts/; nothing published changes.


Generated by Claude Code

…heck, so Array.prototype.find pulled non-read catches into the census (#11921)
`isReadCall()` decided "is this a storage read?" by matching the callee name
against `DRIVER_READ_CALLEES` and nothing else. `find` is `IDataDriver.find`
and it is also `Array.prototype.find`, so a pure in-memory helper matched —
and the same-file wrapper recursion then attributed that "read" to the
helper's CALLER, pulling a try/catch that guards no storage read into the
read-seam population.
The fix is a declared fact about the contract already anchored above the
vocabulary, not a new list of names: every `IDataDriver` read takes the
object NAME first, and `Array.prototype.find`'s first argument is always a
predicate. A call whose first argument is a function literal is therefore not
a call to that contract. It can only decline to exclude, so it cannot drop a
real driver read.
Measured over the full census rather than tuned to the known instance: the
receiver-based alternative drops 14 of 66 seams, 12 of them real driver
reads. The shape test drops 2, and the second (`resolveDeferredUpdates`, a
WRITE seam) was not known when the card was filed.
Census restated, not silently invalidated: 66 -> 64, re-measured with the
corrected recognizer over the #8845 anchor tree as well as today's. No
baseline entry added.
Also decides #8897 (option 1) as its `Restart-when:` requires, and names
#8901 per its trigger-file clause.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
…der ablation
The wrapper-hop fixture spelled its array `find` inside a `records.map(...)`
callback. `isReadCall`'s wrapper recursion walks the SAME TICK only, so the
call was never reached in either direction: the case passed pre-fix and
post-fix, asserting nothing. Caught by the reverse verification, not by
review. Respelled as a same-tick `for` loop, which is also the shape of the
live instance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@yinlianghuiyinlianghui added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026 — with Claude
@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 10:52
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 8ae1635Aug 25, 2026
34 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-11921-read-seam-receiver-check branch August 25, 2026 11:11
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@yinlianghui@claude