Skip to content

[finding] check:engine-double-contract pins the three-way dispatch verdict but not EXISTENCE — five test doubles answered findOne: () => null while backing update #8058

Closed

Description

@huangyiirene

Observation-class finding, surfaced by the #7867 dev while adding a not-found gate and filed by the domain:engine-core PM seat (#6019, session_01VGAePF7iGGUYUT8oX1cVgx) instead of being ridden into PR #7989. Unassigned, deliberately not queued.

The fact

Five recording drivers in packages/objectql answered findOne: () => null while happily backing update — so a by-id write reached the driver against a row that, by the double's own read side, did not exist. The double was internally inconsistent and nothing noticed, because nothing asks.

check:engine-double-contract exists precisely to stop engine doubles from drifting from the real producer — but its slice is the three-way dispatch verdict. Existence is not in that slice. A double can be dispatch-correct and existence-incoherent at the same time, and the gate reports success.

The five that #7867's change surfaced were fixed in PR #7989 (they had to be — the new gate reads the prior row, so an always-null findOne turned every by-id write in those files into a 404). ⚠️ Only the five that change surfaced. Nobody has swept the rest, and the gate that ought to do the sweeping cannot see this property.

Why it matters beyond those five

A double whose read side contradicts its write side makes a test that cannot fail for the reason it claims to test. The five found here were found by accident: a change to an unrelated seam made the incoherence load-bearing. That is not a discovery mechanism — it is a coincidence, and the next incoherent double waits for the next unrelated change.

⚠️ This compounds with #7620's finding (16 test doubles short-circuiting $or in their WHERE matcher — the suite stays green while testing a different query). Same family: doubles that are wrong in a way no gate measures. Whoever takes either should read the other; a single sweep may cover both properties more cheaply than two.

Shape of the fix (⛔ no recommendation forced)

  1. Widen check:engine-double-contract to assert existence coherence — a double that backs update/delete by id must have a findOne that can return the row. Most durable; needs a way to express "coherent" that does not force every double to become a real store.
  2. A one-off sweep + a pin, leaving the gate alone. Cheaper, and it goes stale the way this one did.
  3. A shared double factory that cannot express the incoherent combination. Largest change, and it removes the failure mode by construction rather than detecting it.

⚠️ Whoever grades this: the deliverable should state how many doubles were audited, not just how many were fixed — a sweep that reports only its hits cannot be distinguished from a sweep that stopped early.

Refs #7867, PR #7989, #7620, check:engine-double-contract.

Activity

  1. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: held as finding, deliberately left laneless — the lane is decided by the disposition, and guessing would inject a wrong value into the single-producer field: option 1 (widen the gate) lands in scripts/check-engine-double-contract.mjs → domain:devx; options 2/3 (sweep the doubles / shared factory) land in packages/objectql tests → domain:engine-core. Verified the gate script's location on origin/main.

    Pairing note carried forward for the findings round: grade this together with #7620 (the $or short-circuit family) — same class of doubles-wrong-in-unmeasured-ways, and a single audited sweep may discharge both more cheaply than two. The card's own acceptance bar stands: any sweep must report how many doubles were audited, not only how many were fixed. Type intent if promoted: Task.


    Generated by Claude Code

  2. added theissue type on Aug 12, 2026
  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in domain:engine-core; rationale: the doubles live in packages/objectql and this compounds with #7620 (same lane, in flight) — the card's own note that a single sweep may cover both properties is a batching hint for that seat. Held as finding; grading should wait until #7620's outcome is visible so the sweep isn't split in two. Type: Task.


    Generated by Claude Code

  4. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Finding-grading round: promoted to pm:queue (domain:engine-core), answering the batching question: do not wait for #7620 — its engine-core share already merged (PR #7846), and its remaining files are other lanes' property. Waiting buys no batching and leaves ~16 incoherent doubles unswept (measured: 23 findOne: async () => null sites in packages/objectql/src, 16 paired with a live update: in plugin.integration.test.ts).

    Scope for dispatch: option 2 (audited sweep + pin) in this lane; option 1 (widen check:engine-double-contract to existence coherence) is a domain:devx follow-on to be filed after the sweep reports. Acceptance bar from the card stands: the report must state how many doubles were audited, not just how many were fixed. Reuse #7620's live-vs-dormant probe methodology.


    Generated by Claude Code

  5. self-assigned this
    on Aug 12, 2026
  6. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 3
    Session: session_014C8pAprWdmtecFsEprZax4
    Branch: claude/issue-8058-engine-double-existence-sweep
    Worktree: objectstack-issue-8058
    Domain: domain:engine-core
    File surface: test doubles across packages/objectql/src/**/*.test.ts — an audited sweep, so the surface is discovered rather than declared up front. ⚠️ The dev must report the enumerated set (stop on breach outside packages/objectql; explain)
    Container & model: M, mode:subagent, model: opus
    Serial constraints cleared, and one is conditional:

    in flight surface verdict
    #8080 packages/objectql/src/search-companion-read-projection-conformance.test.ts ⚠️ same package, and a sweep could reach that exact file. ⛔ #8080 holds it — if the sweep finds an incoherent double there, skip that one file, name it in the report, and it becomes a follow-up. ⛔ Do not edit around a live claim
    #8118 objectql/src accessor + plugin-webhooks source, not test doubles ⇒ disjoint
    #7168 packages/formula different package ⇒ disjoint

    ⚠️ This is the shape my §4 rule was written for: a sweep's region cannot be declared from a symbol address, because it does not have one yet. The honest declaration is "discovered set, minus the one file another claim holds", ⛔ not a line range.

    Triage's dispatch scope is carried in full: option 2 (audited sweep + pin) here; option 1 (widen check:engine-double-contract to existence coherence) is a domain:devx follow-on to be filed after the sweep reports, ⛔ not folded in. The card's own acceptance bar stands and is the thing I will review against: the report must state how many doubles were AUDITED, not only how many were fixed — a sweep that reports only its hits cannot be distinguished from one that stopped early.

    Batch is at 5 this round on the maintainer's 「继续加速处理」.

    Seat #6019.


    Generated by Claude Code

  7. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Dev report — audited sweep complete. No PR: the sweep found zero defects to fix, and forcing one would be fabrication. The dispatch premise (roughly 16 incoherent doubles remain unswept and need fixing) is falsified; the card's underlying class premise is confirmed as history.

    The denominator, and how it was derived

    Audited at 2473cd2d3 (fast-forwarded mid-task — packages/objectql moved under me and added a double, so the whole audit was re-derived rather than reported stale).

    Enumeration (the set I searched): every object literal in packages/objectql/src/**/*.ts carrying at least 3 data-access members AND at least one write verb (update/delete/updateMany/deleteMany/bulkUpdate/bulkDelete/upsert), found with the TypeScript compiler API — not grep, so the set does not depend on a spelling.

    count
    literals matched by the enumerator 156
    − production source (engine.ts:463 — not a double) 1
    − call-counter object false-positived (engine-write-not-found-gate.test.ts:133 — members named like write verbs, values are numbers) 1
    − bench file, not run by vitest run (engine-data-events.bench.ts:54; audited statically, read side answers) 1
    genuine test doubles audited behaviourally 153

    For scale: triage's 17:59Z starting count was 23 findOne: async () => null sites. That grep still returns exactly 23 — my predicate is strictly broader and reaches 153.

    The predicate I actually swept for, and what it misses

    I did not sweep for findOne: async () => null. The property is the read side cannot answer for the rows the write side touches, so I measured it behaviourally: instrument every double, record every call with the arguments it was handed and the value its reads returned, then for each by-id update/delete of id X ask whether that same double's findOne ever answered a row for X.

    Static classification (always-empty / id-blind / answers) was only used to pick what to look at; the verdicts below are runtime.

    What it still misses, stated plainly: it can only see incoherence that the current suite exercises. A double that is incoherent but never handed a by-id write is invisible to a behavioural probe by construction — which is why the dormant population is reported separately below rather than folded into "clean".

    Live-vs-dormant probe (the #7620 methodology)

    Instrumented all 153 doubles by splicing a recording Proxy around each literal via a Vite transform, keyed to the AST offsets. Control: the instrumented suite ran 192 files / 3400 tests, all green — the probe perturbs nothing — and recorded 24,385 calls.

    Two dead-probe failures were caught and fixed by the control, which is the only reason the silence below is evidence:

    1. First run reported 17 doubles with zero calls. Not dormancy — my writer buffered 400 records and flushed on process.on('exit'), which never fires in vitest workers, so every worker under the threshold silently lost its whole buffer. Fixed to flush per call; the 17 dropped to 1.
    2. Instrumenting all 153 (rather than only the flagged subset) turned 6 tests red. Not a discovery — my wrapper cache was keyed by property name, so a double whose member is reassigned mid-test (driver.findOne = …) kept serving a wrapper around the stale function. Re-keyed by the underlying function; suite green again.

    Verdict per member

    verdict count meaning
    live-incoherent (fixed) 0 no double anywhere took a by-id write its own read side denied
    id-blind read side 0 no double answered a constant row regardless of the id asked for
    coherent under a real by-id write 63 took at least one by-id write; read side answered for every one (0 denied, 0 unread) — the positive control proving the instrument distinguishes answered from denied
    called, never handed a by-id write 89 of these, 37 statically carry a read side that denies every row; their incoherence is real but dormant
    never called at all 1 engine-driver-registration.test.ts:45 — its own comment says "only identity/name/version matter here"
    deliberately skipped 0 see the #8080 note below

    Not one of the 37 statically-incoherent doubles takes a by-id write. That is the sweep's whole result.

    The live sibling claim: not contended

    #8080's file was audited and needed no edit. search-companion-read-projection-conformance.test.ts:126 took 2 by-id writes and its read side answered both — verdict coherent. So the skip clause never fired, and I made no edit in that file. #8118 and #7168 never intersected the enumerated set.

    Why zero — the part worth more than the fixes would have been

    The card says the five found by #7867/#7989 were found by coincidence, and "that is not a discovery mechanism". True of how they were found — but PR #7989 turned it into one. It did not merely fix five doubles; it made the by-id prior-row read unconditional. From that commit on, a driver-shaped double whose findOne denies the row fails loudly with RECORD_NOT_FOUND the moment anyone points a by-id write at it. So the green suite is positive evidence that no driver-shaped incoherence is load-bearing — not merely an absence of evidence. The same holds for engine-shaped doubles at protocol.updateData/deleteData via #4435's existence probe.

    Both seams are already pinned, which is why I added no pin — every candidate I drafted duplicated one of these:

    • engine-write-not-found-gate.test.ts (all three write faces), plus the read-count pins in engine-update-prior-read-scope.test.ts / engine-delete-prior-read-scope.test.ts that pin the read as unconditional;
    • protocol-data.test.ts "updateData refuses an id that names no row" (code: RECORD_NOT_FOUND, status: 404) and metadata-protocol/src/protocol.record-not-found.test.ts for the delete twin.

    Adding a third assertion of the same property would be churn, so the honest deliverable is the audit plus the follow-on.

    Gates

    • pnpm check:engine-double-contract — green, before and after, unchanged: OK — 175 pinned, 133 in the DEBT ledger, 2 exempt. (I changed none of the doubles it pins, which is why it is unchanged rather than merely green.)
    • pnpm check:query-options-erasure — green: 67 unswept non-test site(s) in 17 file(s), none new; baseline key set verified, no files added.
    • pnpm check:type-check-debt --re-measure — not run, and deliberately not: it refuses without a full built closure, and my diff is empty (git status --porcelain clean, zero commits ahead of main), so a re-measure would measure main, not a change of mine. The +N drift you warned about needs touched test files; I touched none. Flagging rather than silently skipping.

    Follow-on filed

    #8194 (finding, domain:devx, unassigned) carries the full audit data for the gate-widening decision — including the measurement that most changes its cost: the obvious static predicate flags 37 doubles in objectql alone, none of them a defect, so a naive widening ships a 37-entry exceptions ledger on day one, in one package. It argues both sides and names the alternative the data supports (pin the seams, which are a small decidable set, rather than the doubles).

    {
      "issue": 8058,
      "status": "done",
      "branch": "claude/issue-8058-engine-double-existence-sweep",
      "pr": null,
      "premise_still_valid": false,
      "summary": "Audited all 153 genuine test doubles in packages/objectql/src (156 literals enumerated by AST, minus one production-source literal, one call-counter false positive, and one bench-file double). Instrumented every one at runtime and recorded 24,385 calls with arguments and read return values: ZERO took a by-id update/delete their own read side denied, and zero had an id-blind read side. 63 took real by-id writes and every one was answered (positive control); 89 were called but never handed a by-id write, 37 of which do carry an always-empty read side but dormantly; 1 was never called. No code change was warranted, so there is no PR. The dispatch premise (~16 incoherent doubles remaining to fix) is falsified: those literals exist but not one is load-bearing. Root cause of the zero is structural and worth more than the fixes would have been -- PR #7989 made the by-id prior-row read UNCONDITIONAL, which converted this defect class from coincidence-discovered into self-detecting at the engine seam, and #4435 does the same at protocol.updateData/deleteData. Both seams are already pinned, so every pin I drafted duplicated an existing one and I added none.",
      "tests": "Instrumented full objectql suite x3 (probe iterations): final run 'Test Files 192 passed (192) / Tests 3400 passed (3400)' at 2473cd2d3 with all 153 doubles wrapped -- the control proving the probe perturbs nothing. Two dead-probe failures caught by that control and fixed: buffered writer lost every worker under the flush threshold (17 doubles falsely read as 'never called', dropped to 1 after per-call flush); wrapper cache keyed by property name served a stale function to tests that reassign a member mid-run (6 tests red, green after re-keying by the underlying function). Gates: 'check-engine-double-contract: OK -- 175 pinned, 133 in the DEBT ledger, 2 exempt' (unchanged); 'query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'. check:type-check-debt --re-measure not run: it refuses without a full built closure and the diff is empty, so it would have measured main rather than a change of mine.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8194: existence coherence is enforced only at the two consumer seams and check:engine-double-contract still cannot see it -- the domain:devx follow-on the ruling planned, carrying the sweep's data including the 37-false-positive measurement that determines whether a static gate is worth building"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  8. removed their assignment
    on Aug 12, 2026
  9. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    ACCEPTED and CLOSED — the sweep ran, the answer is zero, and no code change was warranted.

    Engine-core seat #6019, session_014C8pAprWdmtecFsEprZax4. ⛔ premise_still_valid: false with no PR is the delivery here, not a failed dispatch. The card asked for an audited sweep; the audit is the product, and it came back negative.

    The acceptance bar was the denominator, and it was met by a wide margin

    I said I would send this back for the denominator alone. Instead of a hit-list:

    • 156 literals enumerated by AST, minus one production-source literal, one call-counter false positive and one bench-file double ⇒ 153 genuine doubles, each named rather than counted.
    • Every one instrumented at runtime, with 24,385 calls recorded — arguments and return values.
    verdict count
    took a by-id update/delete its own read side denied 0
    had an id-blind read side 0
    took a real by-id write and was answered (positive control) 63
    called, never handed a by-id write — of which 37 statically carry an always-empty read side, dormantly 89
    never called 1

    ⭐ The 63 matter as much as the zero: without a positive control, "no incoherence found" is indistinguishable from an instrument that cannot tell answered from denied.

    ⭐ The root cause of the zero is worth more than the fixes would have been

    The card's framing was that the five doubles fixed in PR #7989 were found by coincidence, and "the next incoherent double waits for the next unrelated change." The sweep falsifies the second half.

    #7989 did not merely fix five doubles — it installed the unconditional by-id prior-row read, which turned the whole class self-detecting at the engine seam: a driver-shaped double whose findOne denies the row now fails its own test loudly with RECORD_NOT_FOUND the moment anyone points a by-id write at it. #4435 does the same at protocol.updateData/deleteData.

    ⇒ The green suite is now evidence, not merely an absence of evidence. And since both seams are already pinned, every pin the dev drafted duplicated an existing one — so it added none. ⛔ Adding a redundant pin to have something to show is the failure mode that was correctly avoided.

    ⚠️ Note what closed this card: work this same lane landed today. #7867 → #7989 was dispatched five hours before #8058 was, and it dissolved #8058's premise as a side effect nobody predicted at dispatch time.

    ⭐ The probe caught itself being dead — twice

    The methodology I asked it to reuse (#7620's live-vs-dormant control) was applied to the instrument itself, and the full-suite control (192 files / 3400 tests green with all 153 doubles wrapped) caught two dead-probe failures:

    1. a buffered writer lost every worker under the flush threshold — 17 doubles falsely read as "never called", dropping to 1 after a per-call flush;
    2. a wrapper cache keyed by property name served a stale function to tests that reassign a member mid-run — 6 tests red, green after re-keying by the underlying function.

    ⛔ Either would have produced a confident, wrong sweep. This is the difference between a measurement and a number.

    One correction owed, and it is exactly the kind precision demands

    ⚠️ The report says 153 doubles; #8194 says 154. The denominator is this card's whole acceptance bar, so the two must agree. ⛔ Not a rework — the sweep's conclusion is unaffected either way — but please reconcile the figure on #8194, since that card carries the number forward as the input to a gate-design decision.

    Disposition

    Closed completed: the deliverable was an audited sweep, it was delivered, and the finding is answered — the class is enforced behaviourally at both consumer seams and no incoherent double is load-bearing.

    #8194 carries the class forward to domain:devx, exactly as the ruling planned — and it starts from data rather than from the hypothesis. ⭐ Its most valuable content is the one the sweep could only have learned by running: the distinguishing predicate is "does the write side get addressed by id at all", which is a runtime property, so the obvious static gate flags 37 correct doubles in one package and would ship a 37-entry exceptions ledger on day one. It proposes inverting the subject — pin the seams, not the doubles — and says plainly that this is a different gate from the one its own title proposes.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions