Skip to content

docs(gate): record the read-seam wrapper recursion's callback refusal, measured per seam - #12356

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-12138-wrapper-recursion-census
Aug 25, 2026
Merged

docs(gate): record the read-seam wrapper recursion's callback refusal, measured per seam#12356
yinlianghui merged 1 commit into
mainfrom
claude/issue-12138-wrapper-recursion-census

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of #12138.

Part of, not Fixes: this card was dispatched measure-first, and the measurement says the sanctioned fix does not apply. Triage should decide what happens next — see "What this does not do".

Comment-only. 86 insertions, 0 deletions, all inside the header of scripts/check-durability-degradation-log-level.mjs. The read-seam census is unchanged at 64 and the self-test's 45 read-seam cases and 63 log-level cases are unchanged.

Why there is a record to write at all

isReadCall's wrapper recursion walks a same-file wrapper's body with walkSameTickInclusive, so a driver read inside a nested function body is not seen. The top of the file states the reason the try side refuses the same descent — "a callback registered inside a try runs later and is not guarded by that catch" — and states it nowhere for the wrapper side, where the same answer is not obviously right. #12138's own dedupe section identified that missing record. This is it.

Both recognizers re-derived on one named tree

The filing's table (64 → 72) was attributed to origin/main @ 8619f9513, where the gate actually reports 66; 64 was the post-#12137 figure. #12137 has since merged, so the table was re-derived rather than carried forward:

recognizerread seams
today — walkSameTickInclusive, depth 264
probe — walkAll, depth 272

Measured at origin/main @ 3ddad51b5c, then reproduced byte-identically — both counts and all 8 delta seams — after merging origin/main @ c312a562e3. The delta is still 8.

The wrapper recursion explains only 3 of the 8

Raising MAX_READ_WRAPPER_DEPTH from 2 to 6 while leaving walkSameTickInclusivein place admits 5 of the same 8 (70 seams: +8 / −2, where the 2 are the same try lines re-attributed to a different first-matching callee, engine.ts:9407 and :10572). Saturation checked at depth 50 — 70 and 75, unchanged.

So for those 5 the miss is the depth bound, not the callback boundary. walkAll merely masks the bound by descending lexically through nested declarations instead of counting call hops, which reaches the read at depth 1 no matter how many awaits sit between.

#seamwhy today misses itinvoked now?
1protocol.ts:10243getMetaItemCachedgetMetaItemdepth boundyes — real
2protocol.ts:13559saveMetaItemgetMetaItemdepth boundyes — real
3protocol.ts:14535migrateStoredMetadatasaveMetaItemdepth boundyes — real
4protocol.ts:17213duplicatePackagesaveMetaItemdepth boundyes — real
5sys-metadata-repository.ts:883promoteDraftdropPromotedDraftRowcallbackyes — real
6sys-metadata-repository.ts:1353closeterminatecallbackno — FAKE
7engine.ts:9237insertapplyAutonumberscallbackyes — real
8lifecycle-service.ts:625sweepreapObjectdepth boundyes — real

All 8 were decidable from the call site; none needed provenance. Every hop in the seven real ones is an await on the caller's own tick.

Seam 6 is a fake seam, and it is why walkAll is not the fix

close()'s try calls w.terminate(). terminate resolves by name to the local const arrow at sys-metadata-repository.ts:1246 — a synchronous, void, in-memory routine whose only call is self.watchers.delete(subscription) on private readonly watchers = new Set<WatchSubscription>(). calleeName reads that as delete, and the wrapper recursion resolves delete to this file'sasync delete(ref, opts) method, whose findOne lives inside a withTxn callback.

Ablation: refusing the delete wrapper hop drops the probe from 72 to exactly 70, removing seams 5 and 6 and nothing else.

That is #11921's defect — a callee name matched with no shape check — one level up, on the wrapper name instead of the vocabulary name. contradictsDriverReadShape guards only the DRIVER_READ_CALLEES hit; the wrapper hop has no equivalent. Today it is harmless because the callback refusal stops the walk before the fake read is reached. Widening the walk is what arms it, and a fake seam is the unsafe direction: an invented member of the denominator #5186 / #6451 / #9165 / #8845 / #8901 are all quoted against.

What this does not do, and why

The sanctioned cheap fix for this card was an Array.prototype.map / Promise.all allowlist. Not one of the 8 goes through map or Promise.all. The real shapes are withTxn(cb), keysetWalk(cb) (imported from @objectstack/types, so its body is not even in this file's index) and locally-bound const arrows. That allowlist would admit zero of them while still not excluding seam 6. Telling the four real callback seams from the fake one needs to know what the receiving method does with its argument — #11921's provenance problem, deliberately out of this card's scope.

The try-side refusal is untouched and stays separable: the wrapper recursion is at isReadCall's walkSameTickInclusive(body, …), a different call site from the two catch-side helpers (findPropagationCall, collectLoggedLevels).

The census does not move. It is 64 before and after, so #8901's restart conjunct (b) is not triggered by this reading.

Changeset

None, deliberately. scripts/** is not published by any workspace package, and this change is comment-only inside a CI gate script — there is no user-visible behaviour and nothing for release notes to describe. skip-changeset applied.

Verification

At the final commit 39613ffb7c, union re-derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no STALE TREE). All run under scripts/pm/os-verify-lock.sh, exit codes captured before any pipe — VERDICT command-exit 0 · held the lock 34s · waited 408s:

check:agent-test-spelling · check:bash32-floor · check:cli-command-ids · 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 · check-nul-bytes.mjs — all EXIT=0.

The gate's own verdict lines at that commit:

✓ self-test (log-level rule): 63 case(s) passed
✓ self-test (read-seam invention rule): 45 case(s) passed, and the baseline offer stays marked maintainer-only (#8435)
✓ read-seam invention (#5186 + #6451 + #9165, 3 package roots, vocabulary find/findOne/count): 64 read seam(s), none invents an unreported answer …

Generated by Claude Code

…, measured per seam (#12138)
Comment-only. The read-seam census is unchanged at 64 and the self-test's 45
cases are unchanged.
`isReadCall`'s wrapper recursion walks a wrapper body with
`walkSameTickInclusive`. The top of this file documents that refusal for the
`try` side and nowhere for the wrapper side, where the same answer is not
obviously right. This records the missing measurement.
Re-derived on one named tree (`origin/main` @ 3ddad51, reproduced after
merging c312a56): 64 today vs 72 for a `walkAll` probe. The delta is still
8, but reading the 8 one by one shows the callback refusal explains only 3 of
them: raising `MAX_READ_WRAPPER_DEPTH` from 2 to 6 with the same-tick walk
intact admits the other 5, and saturates.
One of the 3 is a FAKE seam — `Set.prototype.delete` resolving by name to this
file's own `delete()` method, which is #11921's defect on the wrapper name
instead of the vocabulary name. The sanctioned `Array.prototype.map` /
`Promise.all` allowlist matches none of the 8, so no code change is made here.
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
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — and this is the strongest measure-first result of the shift. The dispatch said a per-seam enumeration with no code change would be a complete outcome; you delivered that and falsified the sanctioned fix, the card's own thesis, and the direction of the remedy.

The fake seam — verified at the source, because it is what flips the answer

If seam 6 is real, "widen the walk" is defensible. If it is fake, widening arms a defect. Checked on origin/main:

packages/metadata-protocol/src/sys-metadata-repository.ts:1246constterminate=(): void=>{stopped=true;self.watchers.delete(subscription);packages/metadata-protocol/src/sys-metadata-repository.ts:653asyncdelete(

A local const arrow, synchronous, : void, whose only call is self.watchers.delete(...) on an in-memory Set — and the same file carries an async delete(ref, opts) for calleeName's bare delete to land on. The mis-resolution is exactly as you describe, and your ablation pins it: refusing the delete wrapper hop takes the probe 72 → 70, removing seams 5 and 6 and nothing else.

⭐⭐ And the framing is the valuable part: this is #11921's defect one level up — a callee name matched with no shape check, on the wrapper name rather than the vocabulary name, where contradictsDriverReadShape has no equivalent. It is harmless today only because the callback refusal stops the walk before the fake read is reached. So the card's proposed remedy would have converted a dormant defect into a live one, in the unsafe direction — an invented member of a denominator that #5186, #6451, #9165, #8845 and #8901 are all quoted against.

That is a much better answer than "0 to 8 of the 8 are real".

The card's own thesis survives only for 3 of 8

Raising MAX_READ_WRAPPER_DEPTH from 2 to 6 while leaving walkSameTickInclusivein place admits 5 of the same 8.

So for five of them the miss is the depth bound, not the callback boundary the card is named after — and walkAll was never fixing the bound, it was masking it by descending lexically through nested declarations instead of counting call hops. Saturation checked at depth 50 (70 and 75, unchanged) so the 6 is not an arbitrary stopping point.

⚠️ Note what this does to the filing: #12138 describes one mechanism and the evidence shows two, with the majority belonging to the one it does not name. Triage will want that when it decides what comes next.

The sanctioned cheap fix admits zero — spot-checked here

Triage's dispatch named an Array.prototype.map / Promise.all allowlist as the cheap sound subset. You report not one of the 8 goes through either. Spot-checked three:

sys-metadata-repository.ts:883 await this.dropPromotedDraftRow(ref, draft.hash, opts, draftPackageId);
engine.ts:9237 issuedPerRow[i] = await this.applyAutonumbers(object, rows[i], …);
protocol.ts:10243 } catch (error: any) { ← a try-line, as the census attributes

Plain awaits in try bodies and an indexed loop — no .map, no Promise.all. The real shapes you name (withTxn(cb), keysetWalk(cb) imported from @objectstack/types so its body is not even in this file's index, and locally-bound const arrows) are consistent with that. ⇒ the allowlist would admit zero of the eight and still not exclude seam 6.

Zone 1d honoured exactly

The census does not move — 64 before and after — so #8901's restart conjunct (b) is not triggered. You did not clear the hold, did not grade it, and did not need to record a new number because there is no new number. That is the right disposition rather than a technicality.

Also correct: the try-side refusal is untouched and stays separable (isReadCall's walkSameTickInclusive(body, …) is a different call site from findPropagationCall and collectLoggedLevels) — Zone 2a's warning about two call sites was live and you handled it.

Part of, not Fixes

Right. The card asked for a measurement, the measurement says the sanctioned remedy does not apply, and choosing among what is left — raise the depth bound (and arm seam 6 unless the wrapper hop gets a shape check first), build the wrapper-name shape check, or accept the under-count and keep the record — is a re-scoping call for triage, not a dev's. ⛔ This seat does not make it either.

Landing conditions

  1. Lint & Repo Gates reports completed + success, read by name.
  2. Nothing else. Comment-only, 86 insertions, census and both self-test suites unchanged.

Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yinlianghui@claude