Skip to content

A compare-and-set where on a by-id update is silently inert — the extra predicate keys never reach the driver, and SqlHttpOutbox.redeliver's status guard is one of them #11009

Description

@os-warren

Found while implementing #10740 (the update-op tenant classification on the delivery outboxes). Filed separately and unassigned: it is a different defect class from that card's tenant classification, it pre-dates it, and #10740's PR does not touch it.

The behaviour, measured

ObjectQL.update resolves { where: { id: <scalar>, ...more }, multi: false } to the by-id dispatch (resolveEngineUpdateDispatch → { kind: 'by-id', id }) and then calls driver.update(object, id, data, options). SqlDriver.update builds its statement as:

const builder = this.getBuilder(object, options).where('id', id);
this.applyTenantScope(builder, object, options);

It never applies options.where. So every key in where other than id is silently discarded — including a compare-and-set guard the caller wrote specifically to make the write conditional.

Measured on better-sqlite3 through a real ObjectQL + SqlDriver, on sys_http_delivery:

// row p1 exists with status='pending', attempts=7
await engine.update(
    SYS_HTTP_DELIVERY,
    { attempts: 0 },
    { where: { id: 'p1', status: { $in: ['success', 'failed', 'dead'] } }, multi: false },
);
// observed: attempts === 0

The predicate demanded a terminal status. The row was pending. The write landed anyway.

Why it matters concretely

SqlHttpOutbox.redeliver (packages/services/service-messaging/src/sql-http-outbox.ts) writes exactly that predicate:

{ where: { id, status: { $in: ['success', 'failed', 'dead'] } }, multi: false }

It is there as the atomic half of a check-then-act: redeliver reads the row, refuses a non-terminal one via assertRedeliverAllowed, and then re-states the terminal requirement in the write so a row that changed underneath it is not reset. That second check does nothing.

The window is real rather than theoretical: the HttpDispatcher tick claims pending rows into in_flight continuously and independently of any request. A row claimed between redeliver's read and its write is reset to status: 'pending', attempts: 0 regardless, and the post-write readback — which only asks whether the row is now pending — sees pending and reports the redelivery as a success. The in-flight attempt keeps running, so the delivery can go out twice while the row's attempt counter reads 0.

The generic form is the more important half: any caller in this repo who writes a conditional predicate alongside a scalar where.id gets a guard that silently evaluates to nothing, with no diagnostic. It reads exactly like a working compare-and-set.

What a fix has to decide

Not obvious, which is why this is a card and not an inline fix:

  1. Apply the remaining where keys on the by-id path in SqlDriver.update (and its delete twin, which has the same shape), so a stated predicate is honoured. This makes existing calls stricter — a write that currently lands might stop landing, which is the point, but it is a behaviour change on a shared write path.
  2. Refuse the shape at the engine, the way resolveEngineUpdateDispatch already refuses other silently-wrong ones (#5748's operator-object data.id is the precedent): a by-id update carrying predicate keys it will not honour is a caller error, and refusing is the declared = enforced answer.
  3. Leave the mechanism and fix the caller, having redeliver do its compare-and-set some other way.

(1) and (2) are contract changes on ObjectQL.update; (3) leaves the trap armed for the next caller. My read is that (2) is the shape this repo usually chooses — a silently-ignored option is the failure mode ENGINE_UPDATE_OPTION_KEYS and the dispatch predicate already exist to prevent — but the blast radius wants measuring first: resolveEngineUpdateDispatch's case table and every by-id caller passing extra where keys.

Refs

Duplicate search run before filing: nothing open. The nearest matches are #4419 (findOne dropping unsupported predicate keys — the READ side of the same family, closed) and #5748 / #6435, both closed.

Activity

  1. added theissue type on Aug 22, 2026
  2. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Triage: lands in packages/metadata-core (dispatch predicate) + packages/drivers/driver-sql + one caller in service-messaging ⇒ domain:engine, type Bug, queued (pm:queue).

    Not routed to the decision inbox, per the standing meta-criterion: a silently-dropped caller declaration defaults into the existing loud-refusal family — this repo has already ruled that shape twice on this exact dispatch predicate (#5748's operator-object data.id, #6435), and ENGINE_UPDATE_OPTION_KEYS exists precisely to make ignored options loud. The inherited reason transfers cleanly: a predicate the engine will not honour must be refused at authoring time, not evaluated to nothing. So the default route is the card's own option 2 (+3):

    1. Refuse at the engine: a by-id dispatch carrying predicate keys beyond id becomes a loud error (same style as the ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748 refusal), in update and the delete twin.
    2. Move redeliver off the trap: its compare-and-set requirement is real and must survive — re-express the terminal-status guard on a dispatch path that honours the full predicate (mechanism assumption to verify, not a ruling: the predicate/multi path reaches the driver with the whole where). Acceptance evidence: a concurrent-claim test — row flips to in_flight between read and write ⇒ the reset does NOT land and redeliver reports refusal, not success.
    3. Blast radius measured first: grep every by-id update/delete caller passing extra where keys. If any caller's guard cannot be expressed on the predicate path, stop and report the fork — do not widen the by-id path to honour predicates (that is a capability addition needing its own ruling) and do not quietly leave that caller broken.

    Sizing note for the engine seat: M (shared write path + caller migration + refusal pins). Hot-surface note: sql-driver.ts also carries queued #10997 and blocked #10998 — fold-or-serial is the seat's five-gate call at claim time.


    Generated by Claude Code

  3. self-assigned this
    on Aug 22, 2026
  4. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 3
    Session: session_01RfyXxZ2WPjcjhuXpiQQc3y
    Branch: claude/issue-11009-refuse-unhonoured-by-id-predicate
    Worktree: objectstack-issue-11009
    Domain: domain:engine
    File surface: packages/metadata-core/src/engine-update-dispatch.ts (resolveEngineUpdateDispatch / assertEngineUpdateDispatch, :225) · packages/objectql/src/engine.ts (ENGINE_UPDATE_OPTION_KEYS :382, rejectUnknownEngineOptions :9256 — see the correction below) · packages/drivers/driver-sql/src/sql-driver.ts (update at :5815 and its delete twin) · packages/services/service-messaging/src/sql-http-outbox.ts (redeliver) + refusal pins and a concurrent-claim test (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: claude-fable-5
    Clause-②: yes — see below. This is the declaration limb, and it is the reason for the tier.
    Serial constraints cleared: driver-sql was held by PR #11124 (#10676+#10998), now MERGED (95437e7d2) — that is what releases this card. Same-file neighbours on sql-driver.ts NOT dispatched: #11101, #11067 — five-gate ① fails, serial. In-flight sibling #11021 is metadata-protocol, package-disjoint. ⚠️ #10729 is deliberately NOT dispatched alongside this card: it lands in packages/objectql/src/engine.ts:4503, and the surface correction below puts this card in that same file. Lane in-flight at claim: 1.

    ⛔ Clause ② — declared yes, and the tier follows from it

    This card's adjudicated route is "a by-id dispatch carrying predicate keys beyond id becomes a loud error". That changes contract accept/reject behaviour: calls that succeed today will throw. It narrows rather than widens, and it is a declared = enforced restoration — which is why the adjudication correctly stayed out of the decision inbox — but the tier mandate is written on content, not on who adjudicated: dispatch-gates --tier says it in as many words, "Clause ② is NOT reachable from paths … judged from the card CONTENT. This line is a FLOOR, never a clearance."

    Dispatched at CONTRACT_REVIEW_TIER, read from its single source (scripts/pm/dispatch-gates.mjs:2722) rather than recalled, so the enqueue gate is satisfied at dispatch time rather than by attaching needs:contract-review afterwards.

    ⛔ Surface correction to triage's landing list — verified, not assumed

    Triage routed this to metadata-core + driver-sql + service-messaging and cited ENGINE_UPDATE_OPTION_KEYS as the loud-refusal precedent. That constant is not in metadata-core. Measured on origin/main @ 95437e7d2:

    symbol actual location
    ENGINE_UPDATE_OPTION_KEYS packages/objectql/src/engine.ts:382, consumed by rejectUnknownEngineOptions at :9256
    resolveEngineUpdateDispatch / assertEngineUpdateDispatch packages/metadata-core/src/engine-update-dispatch.ts (:225)

    So the refusal machinery is split across two packages, and a fix written in the style of the cited precedent reaches objectql — which triage's list did not name. Recorded because it changes both the file surface and what this card can be dispatched alongside.

    (I checked this only because a grep for ENGINE_UPDATE_OPTION_KEYS in metadata-core returned zero, and a zero-hit is not a reading until a known-present neighbour confirms the query is live. resolveEngineUpdateDispatch was the control.)

    Premise re-verified on merged origin/main @ 95437e7d2

    SqlDriver.update(object, id, data, options) at :5815. The card's central claim — that it builds .where('id', id) + tenant scope and never applies options.where — is the dev's to re-derive at that line.

    Carried into the dispatch order

    Triage's route (option 2 + 3) with its blast-radius-first clause and its stop-condition: if any by-id caller's guard cannot be expressed on the predicate path, ⛔ do not widen the by-id path to honour predicates (a capability addition needing its own ruling) and ⛔ do not quietly leave that caller broken — stop and report the fork.


    Generated by Claude Code

  5. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    Contributor

    Claim surface amendment (same session session_01RfyXxZ2WPjcjhuXpiQQc3y, branch claude/issue-11009-refuse-unhonoured-by-id-predicate) — files added to the claimed surface beyond the dispatch order's list:

    • packages/metadata-core/src/engine-dispatch-unhonoured-predicate.ts — NEW: the refusal composer both write dispatches share.
    • packages/services/service-messaging/src/memory-http-outbox.ts — bounded inline fix under the four-condition exemption: same defect class (redeliver's terminal-status guard inert against a mid-guard claim), mechanical form pinned by the SQL store's refusal (DELIVERY_NOT_ELIGIBLE, "state changed during redeliver"), file held by no other claim, same gate family. Without it the two IHttpOutbox.redeliver implementations would disagree on the exact race A compare-and-set where on a by-id update is silently inert — the extra predicate keys never reach the driver, and SqlHttpOutbox.redeliver's status guard is one of them #11009's acceptance test pins.
    • packages/services/service-messaging/src/outbox-dispatcher-scope.ts — docstring only: multi no longer discriminates redeliver from the sweep helper, so the prose that leaned on it is re-anchored on the tenant classification.
    • packages/services/service-messaging/src/delivery-update-tenant-audit.integration.test.ts — tenant-audit, the update half: ack is a dispatcher sweep but redeliver is request-reachable — two sites on one object with OPPOSITE classifications #10740's redeliver legs migrate to the updateMany spy/audit op (redeliver now rides the predicate path).
    • packages/services/service-messaging/src/redeliver-concurrent-claim.integration.test.ts — NEW: the concurrent-claim acceptance evidence.
    • packages/services/service-queue/src/db-queue-adapter.test.ts — its case-driven fake-conformance matcher hardcoded the one classic reject message; it now quotes the predicate's own per-case message.
    • packages/objectql/src/engine-delete-dispatch.test.ts / engine-update-dispatch.test.ts / index.ts, packages/metadata-core/src/index.ts — pins and export wiring for the above.

    Not touched: packages/drivers/driver-sql/src/sql-driver.ts — the adjudicated route refuses at the engine and moves the caller; the driver needed no edit, so the serial neighbours #11101/#11067 see no overlap from this card.

    Generated by Claude Code


    Generated by Claude Code

  6. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    Contributor
    {
      "issue": 11009,
      "status": "done",
      "branch": "claude/issue-11009-refuse-unhonoured-by-id-predicate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11147",
      "premise_still_valid": true,
      "summary": "Reproduced both measured claims on origin/main @ 95437e7d2 (better-sqlite3, real ObjectQL + SqlDriver): the by-id CAS shape landed (attempts 7 to 0 on a pending row) and redeliver over a mid-window in_flight claim reset the row and reported success. Implemented triage option 2+3 in the SHARED dispatch predicates (metadata-core) so every pinned double inherits it: a by-id where carrying keys beyond id is refused loudly (one source-blind message composer for both verbs); the same shape WITH multi:true and a where-sourced id routes to the predicate path, which honours the full where; a pure-id where stays by-id even under multi:true (LifecycleService reap idiom, now pinned in both case tables). ObjectQL.update/delete key their predicate branch on the ladder's verdict and throw the dispatch's own message. redeliver rides the predicate path; MemoryHttpOutbox re-checks terminal status after its guard (bounded inline fix, declared in the claim amendment) so both IHttpOutbox impls refuse the race identically. Zone-2 corrections confirmed: ENGINE_UPDATE_OPTION_KEYS is in objectql engine.ts:382 (not metadata-core); SqlDriver.update at :5815 never reads options.where.",
      "blast_radius_census": {
        "method": "scripted balanced-paren scan of every .update(/.delete( call carrying a where: bag over 4863 files, plus a data.id-sourced second sweep and a 'multi: false' grep; hits hand-classified",
        "by_id_where_extra_no_multi_production": ["SqlHttpOutbox.redeliver (the measured victim; migrated to the predicate path in this PR) — no other production caller"],
        "by_id_where_extra_with_multi": "zero callers repo-wide; the rerouted cell ships unoccupied",
        "data_id_plus_where_bag": "zero production callers",
        "pure_id_where_plus_multi_unchanged": ["objectql/src/lifecycle/lifecycle-service.ts:1565 (guarded reap — relies on by-id cascade; verdict deliberately preserved)", "plugin-sharing bu-tree-recompute tests", "objectql engine-data-events.test.ts (pins the per-record event for this exact shape)"],
        "every_censused_guard_expressible_on_predicate_path": true,
        "stop_condition_reached": false,
        "by_id_driver_path_widened": false
      },
      "concurrent_claim_test": {
        "file": "packages/services/service-messaging/src/redeliver-concurrent-claim.integration.test.ts",
        "before_fix_measured": "reset landed over the in_flight claim (row pending, attempts 0, claim cleared) and redeliver resolved success — asserted as-is on the unfixed tree and passing",
        "after_fix": "reset does NOT land (row stays in_flight, attempts 4, claimed_by racer) and redeliver rejects HttpRedeliverError DELIVERY_NOT_ELIGIBLE; still-works leg pins the unraced reset; MemoryHttpOutbox leg pins the same contract"
      },
      "delete_twin_symmetric": true,
      "delete_twin_evidence": "same shared composer (engineByIdUnhonouredPredicateMessage) imported by both dispatch modules; ENGINE_DELETE_DISPATCH_CASES gained the same reject/multi/pure-id-multi cases; delete-side pins in engine-delete-dispatch.test.ts drive the REAL engine (deleteMany observed with the full where)",
      "tests": "All at head 2e3d05049 via os-verify-lock, VERDICT command-exit 0 each: suites metadata-core 165/165, objectql 4046/4046 (228 files), driver-sql 1795 passed, metadata-protocol 1853 passed (13 pinned fakes), service-messaging 259/259 re-run at head, service-automation 998/998, plugin-sharing 624/624, plugin-webhooks 128/128, service-queue 56/56; typecheck green on metadata-core, objectql, service-messaging, service-queue (script names echoed). Gates all green at their own verdict lines: changeset-gate-self-tests, durability-log-level, objectui-changeset, slot-lookup, stack-collection-maps, test-source-alias, type-source-resolution, adr-0087-registration, changeset-no-major, ci-filter-parity, empty-changeset, engine-split-ratio, plugin-teardown-shape, docs-audit affected-docs, query-options-erasure, engine-double-contract, where-matcher, nul-bytes, driver-conformance, i18n ('OK, 9 packages, all bundles in sync' after building the CLI it requires), type-check-coverage ('OK — 65/78'), type-check-debt --re-measure ('OK — 33 ledger entries, none above recorded') after the full workspace closure build. Gate list re-derived with dispatch-gates.mjs (no paths; 16 files vs merge base 95437e7d2); it added changeset-gate-self-tests, objectui-changeset, stack-collection-maps, engine-split-ratio, durability-log-level, query-options-erasure, where-matcher, type-check family and adr-0087/changeset families beyond the prompt's list, all run.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11142: a scalar data.id beside a DIFFERENT scalar where.id still silently drops the where.id predicate — refusing it reverses the pinned #5748 expectId verdict, a maintainer decision"
      ],
      "deviations": [
        "prompt-derived gate list vs reality: check:driver-conformance was prompt-named but sql-driver.ts needed no edit (refusal lands at the engine per the adjudicated route); gate run anyway, green",
        "bounded inline fix under the four-condition exemption: MemoryHttpOutbox.redeliver terminal re-check — declared same-round in the claim-surface amendment comment and named in the PR body",
        "service-queue/db-queue-adapter.test.ts per-case reject matcher updated to quote the predicate's own message (it hardcoded the one classic constant; the fake itself was already correct through its pin)",
        "#10740 tenant-audit pins migrated to the updateMany op (redeliver's op moved with the dispatch path); tenant-classification contract unchanged and re-asserted with a new positive control for that op"
      ]
    }

    Generated by Claude Code

  7. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #11147, 31/31 checks green, enqueued

    Verified independently rather than read off the report: mergeable_state: clean, every check run read by name (31 total; Console Pin Gate and Build Docs skipped, not red — this diff touches neither path), Lint & Repo Gates green including check:engine-double-contract. Flipped to ready, auto-merge enabled.

    The triage order's three clauses were all discharged: blast-radius measured first, redeliver moved off the trap, and the stop-condition — "if any caller's guard cannot be expressed on the predicate path, stop and report the fork" — was never reached, because every censused caller's guard is expressible there. The by-id driver path was not widened. That was the one outcome this dispatch most needed to avoid, and it was avoided for the right reason rather than by luck.

    ⚠️ One deviation from the literal dispatch order, accepted — recording it so it is not found later as undocumented drift

    Triage's route said a by-id dispatch carrying predicate keys beyond id becomes a loud error. The implementation makes that true for three of four cells, but routes the fourth — where: { id, …extra } with a declared multi: true — silently to the predicate path instead of refusing it. That is a behaviour change shipped without a diagnostic: a caller who wrote that shape previously got an unconditional single-row write and now gets a conditional multi-row one.

    Accepted, for three reasons in descending weight:

    1. It is not a widening. multi: true is the caller explicitly declaring predicate semantics; compiling their whole where is what they asked for. Refusing it would refuse a correctly-spelled compare-and-set.
    2. The cell ships with no occupants — the census reports zero callers anywhere, and I spot-checked that independently (every multi: true hit sharing a line with id across non-test packages/** is a comment, a dispatch case-table row, or an $in id set; none is a scalar-id-plus-extra-keys call). ⚠️ My check is line-based and strictly cruder than the dev's balanced-paren scanner — it found nothing contradicting the census, which is weaker than confirming it.
    3. The changeset states the change per call shape, so release-notes readers meet it.

    ⛔ Had the census found even one occupant, this cell would have gone back for a refusal or a ruling. It is the emptiness that makes silence acceptable here, not the reasoning alone.

    ⛔ My scoping error, not the dev's

    I declared the file surface as four packages including packages/drivers/driver-sql/src/sql-driver.ts. The landed PR touches 16 files and sql-driver.ts is not among them — the fix lives in the shared dispatch predicates, exactly where it should, and the driver never needed changing.

    Consequence: I serialized #11101 and #11067 against a file that was never at risk for the whole life of this dispatch. Over-declaring a surface is not the safe direction of error I had been treating it as — it costs throughput just as under-declaring costs safety, and it does so invisibly. Correction to this seat's practice: a declared surface is a prediction, and when a dev's actual surface lands narrower, the seat releases the serial hold instead of leaving it standing to the merge.

    Residual, correctly escalated rather than decided

    #11142 — a scalar data.id beside a different scalar where.id still silently drops the where.id. The dev declined to fix it because doing so reverses a pinned #5748 verdict (the expectId case), and that distinction is right: refusing data.id + non-id keys does not touch #5748's precedence rule, while refusing data.id + where.id would overturn it. Labelled finding and left for triage — ⛔ note for the grading round that this is a decision-inbox shape, not an ordinary observation: it is the last surviving cell of the exact silent-predicate-loss class this PR just closed, so grading it as "no user impact today" would be the wrong read.

    Excellent work — particularly measuring both defect behaviours as passing assertions on the unfixed tree before any edit.


    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions