Uh oh!
There was an error while loading. Please reload this page.
fix(service-messaging): enforce ack()'s claimed-row precondition in both outbox implementations - #11858
Conversation
…oth outbox implementations (#11453) `ack()` is the dispatcher's completion callback for a row it CLAIMED, and neither implementation checked that, so `ack(id, { success: false, suppressed: true })` on an unclaimed `pending` row succeeded — flipping the row terminal and recording an attempt that never went on the wire. That made `ack` read like the cancellation primitive this interface deliberately does not have, and it raced `claim()` (atomic by contract; `ack` was never part of that atom). Both implementations now refuse a row that is not `in_flight`, with `NotificationAckError` / `DELIVERY_NOT_ELIGIBLE` — this package's already registered ADR-0112 code, the same refusal `SqlHttpOutbox.redeliver` raises when its own compare-and-set misses. A refused ack writes nothing. `SqlNotificationOutbox` does it as an atomic conditional update, not a read-then-write: the precondition is re-stated in the write, which per #11009 must ride the predicate path (the by-id path silently discards it). `attempts` increments inside that condition and nowhere else, so it can only move for a row that was genuinely claimed. The sibling HTTP outbox is untouched: `assertHttpRedeliverable` depends on `IHttpOutbox.ack` incrementing unconditionally, so `attempts === 0` on a terminal row still means "parked, never sent". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 64606795b93b072f540cc15336109bb5e7efff9e && git checkout 64606795b93b072f540cc15336109bb5e7efff9e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 589758d22ccacf9cc56b5bc8a9f9766cb7e2a93a 0b765a45295768cf589a081007129197caecef9c && git checkout -B drift-repro 589758d22ccacf9cc56b5bc8a9f9766cb7e2a93a && git merge --no-ff 0b765a45295768cf589a081007129197caecef9c
node scripts/docs-audit/affected-docs.mjs --json 589758d22ccacf9cc56b5bc8a9f9766cb7e2a93a
|
os-sam
commented
Aug 24, 2026
docs-drift answered — no prose goes false, and the one row it listed is a shared-vocabulary collision between the two outboxes
The one page named: |
| grep over the page | hits |
|---|---|
sys_http_delivery / "http outbox" / IHttpOutbox | 20 |
INotificationOutbox / "notification outbox" / sys_notification | 0 |
Every in_flight occurrence belongs to sys_http_delivery's lifecycle — the schema table (:205), the dispatch diagram (:244), the claim SQL (:402–:405), and the crash-recovery table (:614).
⇒ This PR deliberately leaves IHttpOutbox alone, and proves it by import graph rather than by intent: assertHttpRedeliverable is unreachable from this diff, and dispatcherAckOptions still serves SqlHttpOutbox.ack by id. Nothing on that page changes.
⭐ The row is a true match on its own rule and a false one on relevance: the two sibling outboxes share their vocabulary — in_flight, attempts, and even DELIVERY_NOT_ELIGIBLE. That is not a bot defect; it is the corpus being genuinely ambiguous at the literal level. attempts change would have crossed over and destroyed assertHttpRedeliverable's attempts === 0 discriminator.
⚠️ The bot's declared blind spot — checked, and it is where the interesting thing was
It flagged packages/services/service-messaging/src/index.ts as yielding no anchor. That file carries the new exports, so I searched the docs for them directly:
NotificationAckError→ zero hits incontent/docs.DELIVERY_NOT_ELIGIBLE→ three hits, and none of them goes false:references/api/error-code-ledger.mdx:239andreferences/api/contract.mdx:135— bare bullets in a code list, no gloss, and both files carry⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate.automation/webhooks.mdx:552–556— the HTTP redeliver endpoint's409refusal list, i.e. theSqlHttpOutbox.redeliverinstance of the concept, which this PR does not touch.
⇒ The narrow-gloss concern this PR raises for the contract reviewer — that the ledger's inline gloss ("delivery row is in a non-terminal state") is now narrower than its uses, since this ack refuses pending rows and already-terminal ones — is confined to the in-repo ledger comment. It has not propagated into any hand-written published page. Correctly flagged rather than done: widening it is a packages/spec edit and this lane has zero ownership there.
⇒ No docs work outstanding on this PR. Recorded so the contract reviewer does not re-derive it and a later reader does not read the bot's row as unaddressed.
Generated by Claude Code
os-sam
commented
Aug 24, 2026
Landing — contract review CLEARED (checked which, not assumed), CI green
The label-absence test, run rather than skipped
Checked: contract review PASS at 22:39:33Z, PR #11858 at head CI: 31 checks — 29 success, 2 skipped, 0 non-green, including ⭐ The open question is ruled — and it lands on this seat, not on this PRThe reviewer ruled B on the ⇒ "a one-line spec-lane docs catch-up, for the dispatching seat to card" — that is mine, and I am carding it rather than carrying it. Card disposition — |
Part of #11453 — the repair half only. The card's other half (there is no cancellation expression on
INotificationOutbox) is deliberately not addressed here: the only named pull for cancellation, the hardrecall()on #10753, was deferred by the #11454 ruling, so widening the interface now would be capability expansion with a deferred consumer. Triage ruled the cancellation surface gets decided when recall is revived, with this card's compare-and-set analysis as input. Closing theackworkaround is deliberate and not a loss — the card itself names it a trap. Left open rather than auto-closed so a human decides whether the cancellation section still wants a card.⛔ Draft on purpose.
needs:contract-reviewwas hung at dispatch, not after the fact: this is accept/reject movement on a declared interface member. Not to be flipped ready, auto-merged, or have that label cleared by anyone but the contract-review tier.The defect
ack()is the dispatcher's completion callback for a row it CLAIMED, and neither implementation checked that.MemoryNotificationOutbox.acklooked the row up by id and mutated it;SqlNotificationOutbox.ackread only['attempts']by id. Soack(id, { success: false, suppressed: true })on an unclaimedpendingrow succeeded, flipped the row to terminalsuppressed, and incrementedattempts— which madeackread like the missingcancel, wrong in two directions:list()and itsack(),claim()can take the row —claimis atomic by contract andackwas never part of that atom — so a suppression could land on a delivery already on the wire, or a dispatcher's real outcome could be overwritten by a caller that believed it was cancelling.attempts. The counter feeds the retry schedule (classifyDeliveryAttempt(result, errorClass, row.attempts, …)), so a row "cancelled" this way reached its next real attempt with the backoff already advanced by an attempt that never went out.The shape chosen: loud rejection, not loud no-op
Both implementations refuse a row that is not
in_flight, throwingNotificationAckErrorcarryingDELIVERY_NOT_ELIGIBLE. Why rejection over a no-op: a no-op leaves the caller believing the row is suppressed while it is still queued and will still be delivered — replacing "silently succeeds" with "silently does nothing", which for the ack-as-cancel caller is strictly worse than the defect. Neither outbox holds a logger, so a "loud" no-op would have needed either a new constructor dependency or a return-value widening that avoid-ignoring caller drops anyway. A throw is unmissable at the call site on the first run.Why that code, and no new one:
DELIVERY_NOT_ELIGIBLEis already registered to@objectstack/service-messaginginERROR_CODE_LEDGER, and it is already this package's refusal for "this delivery row's state does not permit the operation" —SqlHttpOutbox.redeliverraises exactly it when its own compare-and-set misses. Two spellings for one concept would be a second thing for a caller to match on. It also keeps this PR out ofpackages/spec, which has zero ownership in this lane: minting a code would have required a ledger entry there, and an unregistered code would have needed a row inpackages/runtime's dispatcher-error vocabulary. A registered code needs neither.DELIVERY_NOT_ELIGIBLEreads "delivery row is in a non-terminal state", which is theredeliverinstance of the concept. This ack refusespendingrows and already-terminal ones, so the gloss is now narrower than its uses. Widening that one comment is apackages/specdocs edit this lane may not make — flagged rather than done.A refused ack writes nothing
Status,
attemptsanderrorare left exactly as they were, so the row stays claimable and its backoff position stays honest. An id matching no row remains a silent no-op, unchanged and now declared on the interface: an absent row has no state to corrupt and no claim to lose.SqlNotificationOutbox: an atomic conditional update, not a read-then-writeA read-then-write is the defect wearing a different hat, so the precondition is re-stated in the write:
where: { id, status: 'in_flight' }. Per #11009 that must ride the predicate path (multi: true) — on the by-id path the driver binds only the primary key and the extra predicate is silently discarded, which is the identical trapredeliverwas carrying before that card. The write therefore moves from theupdateop toupdateMany, exactly asredeliver's did, so its options now come from a newdispatcherAckCasOptionsbeside the existing helper. The tenant classification is unchanged — declared global, warrant re-derived inoutbox-dispatcher-scope.ts; the op moved, the warrant did not. Of the three sites on these objects, onlySqlHttpOutbox.ackstill writes by id.attemptsis incremented inside that condition and nowhere else, so the counter can only move for a row that was genuinely claimed — i.e. for a real dispatch attempt.Because
IDataEngine.updatedeclares its return asany, a miss is detected by reading the row back — the same techniqueredeliveruses. The detector is the pair(status, attempts), not status alone: a retry ack's post-state ispending, the same status a refused row already had, so only the recorded attempt tells them apart. Without the read-back a lost claim would write nothing and still report success — the silent-success family this card exists to close.The dispatcher absorbs exactly one refusal
A send slower than
claimTtlMslegitimately loses its claim to the visibility-timeout reap, so the dispatcher can provoke this refusal while behaving correctly.NotificationDispatcherlogsDELIVERY_NOT_ELIGIBLEand continues with the rest of the batch; any other error still propagates. Without this, one lost race would unwind the partition loop and strand every still-valid row in that batchin_flightuntil its own timeout expired — turning a lost race into a batch-wide delay. Named here rather than left as an incidental: it is a caller change, and it is the only one.Measured, not assumed: the sibling HTTP outbox
assertHttpRedeliverable(http-outbox.ts:315+) usesattempts === 0on a terminal row to tell "parked, never sent" from "sent and failed", and its docstring says that pair is reachable only becauseIHttpOutbox.ackincrements unconditionally. That predicate cannot be reached from this diff: it readsHttpDeliveryonIHttpOutbox, whose implementations (memory-http-outbox.ts,sql-http-outbox.ts) import fromhttp-outbox.js,http-sender.js,backoff.js,audit-timestamp.jsandoutbox-dispatcher-scope.js— and from none ofoutbox.ts,memory-outbox.tsorsql-outbox.ts. The HTTP outbox is deliberately left alone; applying the sameattemptschange there would have madeattempts === 0reachable throughackand destroyed the discriminator.dispatcherAckOptionsis likewise untouched and still servesSqlHttpOutbox.ackby id.Premise check
The card asked whether any live caller depends on
ack-as-cancel today. None exists. Repo-wide,.ack(has zero callers outsidepackages/services/service-messaging/src, and inside it every call site is a dispatcher completion. The only producer ofsuppressed: trueanywhere isbackoff.ts:56—classifyDeliveryAttemptclassifying aninvalid_recipientsend outcome, i.e. a genuine attempt on a claimed row, which the contract test pins as still reachingsuppressed. No compatibility shim was added, because nothing needs one.Verification
Test-first: the contract test was written and run on an unmodified tree with the failure signature predicted in writing beforehand. Predicted and observed identically —
AssertionError: promise resolved "undefined" instead of rejecting, 6 failed | 4 passed (10), the two still-works legs passing on both backends as the positive control that the harness really drives an ack.outbox-ack-precondition.integration.test.tsis one table over both backends, because the precondition is a property of the interface and the two drifting apart is the failure it prevents. The SQL leg runs on a realObjectQL+SqlDriver(better-sqlite3:memory:, the #5704 ruled test backend) rather than a fake, because a fake engine cannot refuse a write. Assertions are identities, not counts: each names the row, its terminal state and itsattemptsvalue, and each refusal asserts the error identity (name+code) rather than that something was thrown.Ablation, per implementation, restoring source from
HEADand verifying withgit hash-objectagainst the HEAD blob (both non-empty and matching):3 failed | 7 passed— the 3 memory refusal cases, SQL untouched3 failed | 7 passed— the 3 SQL refusal cases, memory untouchedEach leg reds only its own backend's three cases, so neither implementation's pass is borrowed from the other. The mutation was confirmed on disk each way (anchored greps for both the injected marker and the removed text) before the run, and the script carried a restore
trapso a mid-mutation kill could not leave the tree poisoned. No rebuild is needed for these legs and that is measured, not assumed: the contract test imports its subjects by same-package relative specifier, so vitest runssrc/— visible in this branch's own history, where behaviour changed between runs with no build in between.Gates, all at
0b765a45, each read from the gate's own verdict line with the exit code captured before any pipe:check:driver-memory-census—OK — every declaration is ledgered … Nothing here invests in the driver (#5499 freeze). Run explicitly: the derivation reports it unreachable by construction, so it is in no path-derived union.check:type-check-debt— narrowing measured, not declared:@objectstack/service-messagingis absent from bothDEBTandTEST_DEBT(grep exit 1, 0 hits, against a positive control of@objectstack/coreand@objectstack/plugin-approvals), and the package'stsconfig.jsonincludessrc, where its tests live — so the ledger's "tests hidden from tsc" trap does not apply and the package typecheck really does cover the new test file.check:type-check-coverage—OK — 65/78 workspace packages type-checked.check:engine-double-contract—OK — 401 pinned, 133 in the DEBT ledger, 2 exempt(this diff edits a fake engine double).check:cross-package-test-inputs—OK: 16 package(s) read outside themselves, all declared(the new test reads across packages).check:where-matcher—296 matcher(s) discovered … 0 silently-wrong … none new.check:test-source-alias,check:type-source-resolution,check:published-files,check:slot-lookup,check:query-options-erasure,check:nul-bytes,check:changeset-gate-self-tests,check:objectui-changeset, plugin-teardown-shape, empty-changeset, changeset-no-major, adr-0087-registration, release-rehearsal-clone self-test — all exit 0.check:i18n— first run returnedPREREQUISITE NOT MET — the workspace CLI is not built, which is not measured, never a pass; after building the CLI it returns a realOK (9 package(s) — all bundles in sync, no undeclared authoring keys).Package suite:
28 passed (28)files,286 passed (286)tests, andpnpm --filter @objectstack/service-messaging typecheckclean (script name echoed in the output, so it is not a zero-match silent pass).The gate list was re-derived from the actual changeset with
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackrather than taken from the dispatch, which is how the test-kind gates above (engine-double-contract, cross-package-test-inputs, where-matcher, query-options-erasure, i18n) were found — none were named at dispatch.Follow-ups filed, not smuggled in
ack(id, result)carries nonodeId, so the compare-and-set can verify that a claim exists but not whose. A row reaped and re-claimed by another node in the read→write window still matchesstatus = 'in_flight'. Closing that needs a signature change — a contract decision, not a repair.IHttpOutbox.recordUndeliverableexists for. Nothing on this side reads that value the wayassertHttpRedeliverabledoes, so closing it today would mean declaring a discriminator with no consumer.Generated by Claude Code