Uh oh!
There was an error while loading. Please reload this page.
feat(service-messaging): bind the claim credential in ack()'s compare-and-set - #12282
Conversation
…-and-set (#11859) INotificationOutbox.ack() takes back the claimed record; claim()/claimDigest() declare ClaimedDeliveryRecord[] (the (claimedBy, claimedAt) pair the store stamps, guaranteed present). The ack predicate now includes ownership, so a late ack from a node whose claim was reaped and re-claimed matches nothing — refused with the existing NotificationAckError DELIVERY_NOT_ELIGIBLE, writing nothing. The caller never supplies an identity: ownership is proven by round-tripping what claim() returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
📓 Docs Drift CheckThis PR changes 1 package(s): 2 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 79f9ded95d7a579ab29f379256c66558274e4c77 && git checkout 79f9ded95d7a579ab29f379256c66558274e4c77
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6274a1aea55305cb85549aeb9dabef8e0e831565 8deb984b295b073eacf35b3fbbb5a31759110e4a && git checkout -B drift-repro 6274a1aea55305cb85549aeb9dabef8e0e831565 && git merge --no-ff 8deb984b295b073eacf35b3fbbb5a31759110e4a
node scripts/docs-audit/affected-docs.mjs --json 6274a1aea55305cb85549aeb9dabef8e0e831565
|
os-trump
commented
Aug 25, 2026
Contract-review verdict: PASS (#11859)Reviewed by the contract-review chain at Reviewed: the full diff (all 10 files), the changeset, and the ruling record on #11859. Conformance to ruling C (maintainer 2026-08-25, 「其他同意」 — the claim token rides the record; ack returns it; the cancellation surface stays deferred):
Landing completed by the chain in the same stroke per the maintainer's standing instruction (2026-08-25, verbatim: 「审核通过你应该直接负责合并吧,还要等原始的项目经理吗」): clearing Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11859
Implements the maintainer ruling on the card (2026-08-25, option C — "the claim token rides the record; ack returns it; the cancellation surface stays deferred"):
claim()/claimDigest()now declare their true return type,ClaimedDeliveryRecord[]— the same rows as before with the (claimedBy,claimedAt) pair the store stamps guaranteed present — andINotificationOutbox.ack()takes that record back instead of a bare id. Both implementations include the credential in the ack compare-and-set, so a late ack from a node whose claim was reaped and re-claimed matches nothing and is refused with the existingNotificationAckError(DELIVERY_NOT_ELIGIBLE, ADR-0112), writing nothing. The caller never supplies an identity: ownership is proven by round-tripping whatclaim()returned (option A's caller-supplied identity and option B's requirednodeIdparameter are deliberately not taken, per the ruling). The credential is the pair, not the node id alone:claimedAtdistinguishes two claims by the same node, so a stale ack cannot land on its own node's later re-claim. The deferred cancellation surface (#11454) is untouched; the "deliberately no ack() spelling for stopping a pending delivery" sentence inoutbox.tsstands.Call-site population (measured on the merged ref
cd2541780)Derived by scanning
packages/ examples/ apps/forINotificationOutbox/SqlNotificationOutbox/MemoryNotificationOutboxreferences plus every.ack(call on those types.Production
ackcall sites (1):packages/services/service-messaging/src/dispatcher.ts—ackAttempt(the only production caller; both the normal and the digest path funnel through it). Now passes the claimed record.Test
ackcall sites, updated in this PR (8 calls in 2 files):src/outbox-ack-precondition.integration.test.ts— 7 calls (all now round-trip claimed records; the ack-as-cancel pin hands back alist()row, which carries no credential and stays refused).src/sql-outbox-audit-columns.test.ts— 1 call (fake row now carries the credential the record round-trips).Consumers of the claim/record types, measured and compile-compatible with no edit (the narrowing is a supertype-to-subtype return change; none call
ackdirectly):service-messaging:dispatcher.test.ts,digest.test.ts,messaging-service.test.ts,delivery-claim-tenant-audit.integration.test.ts,delivery-update-tenant-audit.integration.test.ts(drives ack through the dispatcher; its CAS-shape filter still matches),messaging-service.ts(enqueue only),messaging-service-plugin.ts(constructs the outbox),index.ts(re-exports; now also exportsClaimedDeliveryRecord).service-automation:builtin/notify-delivery-outcome.integration.test.ts,builtin/notify-organization-stamp.integration.test.ts— reach the types viaMemoryNotificationOutbox+ dispatcher; re-run green against the rebuilt dist (2 files, 9 tests).packages/spec/src/api/error-code-ledger.zod.tsmentionsINotificationOutbox.ackin a prose comment only — not touched (this lane's red line); flagged for the spec seat in the dev report.The HTTP outbox family (
IHttpOutbox,SqlHttpOutbox,MemoryHttpOutbox) is a different interface and is untouched.The replay test
src/outbox-ack-claim-ownership.integration.test.ts, one table over both backends (memory + real engine: ObjectQL + SqlDriver on better-sqlite3:memory:), driving every step through the public contract with explicitnowvalues — no hand-setclaimed_by:claimTtlMs→ node B'sclaim()reaps and re-claims → A's lateack()is refused by identity (NotificationAckError+DELIVERY_NOT_ELIGIBLE), and the row still belongs to B's claim with B's attempt intact (the asserted fingerprint reads status in_flight, holder node-b at the re-claim instant t1, zero attempts, no error), and B's own ack then lands (status success, one attempt).[]— and A's ack must succeed. An implementation that refuses every ack fails here.claimedAtin the predicate.Dissolution verification (direction predicted in writing before running): dropping ownership from the predicate (CAS where-clause + both backends' ownership checks; mutation proven on disk by marker/absence greps before running) turned exactly the predicted legs red — 4 failed (
promise resolved "undefined" instead of rejecting, both refusal legs on both backends) while both negative controls and the entire #11453 precondition suite stayed green (12 passed). Restored viagit checkout HEAD --under an EXIT/INT/TERM trap;git status --porcelainclean (disk == index == HEAD), pins re-run green 6/6.Reverse verification of the narrowed signature against the rebuilt dist: a scratch consumer in
service-automationcalling the old spelling fails compilation —error TS2345: Argument of type 'string' is not assignable to parameter of type 'ClaimedDeliveryRecord'— while the round-trip spelling compiles (positive control, exit 0).Verification (union at
8deb984b2, the final commit)pnpm --filter @objectstack/service-messaging typecheck— exit 0.pnpm --filter @objectstack/service-messaging exec vitest run— 29 files, 295 tests, all passed.pnpm --filter @objectstack/service-automation exec vitest run src/builtin/notify-delivery-outcome.integration.test.ts src/builtin/notify-organization-stamp.integration.test.ts— 2 files, 9 tests, all passed.scripts/pm/dispatch-gates.mjs, no paths), all exit 0 locally:check:nul-bytes,check:changeset-gate-self-tests,check:objectui-changeset,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration(accepts theno-migration-prescriptiondisposition),check-changeset-no-major,check-empty-changeset,check-plugin-teardown-shape,release-rehearsal-clone --self-test,docs-audit/check-affected-docs; convention-triggered:check:query-options-erasure,check:type-check-coverage,check:type-check-debt(32 ledger entries re-measured, none above its recorded number),check:engine-double-contract,check:cross-package-test-inputs,check:where-matcher,check:i18n,check:i18n-stale-fill.docs-audit/check-drift-commentis PR-context-only and runs in CI.Changeset
@objectstack/service-messagingminor with an explicit BREAKING note (launch-window convention; never major): the one breaking edit for a host is at ack call sites, which hand the whole record back where they previously handedrecord.id— the caller already holds it, sinceackcompletes a claim and the record is whatclaim()returned. Reads of claim results do not change. No stored row changes shape;sys_notification_deliveryis byte-identical before and after. ADR-0087 disposition:not-required (no-migration-prescription), stated in the changeset.Severity, honestly
The multi-node population is unmeasurable from inside this repo (#8501 — no membership view), and single-node deployments cannot hit the window at all. The contract tightening is cheap and correct regardless; that is the whole justification.
Review state: draft +
needs:contract-review(Clause-② yes — this changes a declared interface's accept behaviour). This PR stays gated; no ready flip, no auto-merge.Generated by Claude Code