fix(service-messaging): sys_notification_delivery reaps its terminal-failure rows after 7d, not 90d (#17611) - #17871
Conversation
… at 7d WIP — declaration + sweep pin, verification pending. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 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 860bf110a6d28bb1fb88b13bec0142b17dfa015c && git checkout 860bf110a6d28bb1fb88b13bec0142b17dfa015c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed8dea17bd510100320ab42dbac6ec2a78e99deb a6c8cd7f55a2687d91a9cb392ad55fc04f0fe068 && git checkout -B drift-repro ed8dea17bd510100320ab42dbac6ec2a78e99deb && git merge --no-ff a6c8cd7f55a2687d91a9cb392ad55fc04f0fe068
node scripts/docs-audit/affected-docs.mjs --json ed8dea17bd510100320ab42dbac6ec2a78e99deb |
Fixes #17611
Implements ruling C (director seat, decision batch #116 item 3, comment 5642380896; maintainer verbatim and untranslated: 「17611 同意」 to 「席位推荐 C 现在做,A 另立一卡;若要逐条审计选 C+B」). B is not taken. A is not taken here — it is priced on its own card, #17732, whose own ruling orders it second. Nothing in this PR touches
channel.ts,messaging-service.tsor fan-out.What was wrong
Fan-out writes one delivery row per
(event × recipient × channel). A tenant with no transport configured for one of those channels dead-letters that channel's row on its first attempt, and everynotifywrites another one. Production reading from the card: 2,876email/deadrows against 2,876inbox/success,max(attempts) = 1, zero pending, +316 rows/day. Those rows carry no work — nothing claims, retries or acks them again — yet they sat for the full 90d in the table the dispatcher's claim query reads on every hop.The change
One declaration, on
packages/services/service-messaging/src/objects/notification-delivery.object.ts:Zero new contract, as the ruling requires: no
MessagingChannelmember, no new status value, no change to fan-out.retention.onlyWhenis an existing spec key with a live consumer and three landed precedents (sys_job_queue,sys_automation_run,sys_upload_session).One deviation from the literal one-key reading, and why the ruling's own words require it
The ruling says the terminal scope lands "through the existing
retention.onlyWhenkey ... while non-terminal rows keep the object's 90d". Measured on the spec:lifecycle.retentionis a single optional block (packages/spec/src/data/object.zod.ts), not a list. Soretention: { maxAge: '7d', onlyWhen: terminal }alone would have leftpending/in_flight/successwith no age bound at all — unbounding the larger half of this table's growth (the card's own table shows 2,876successrows at the same +316/day) on the very card that exists to bound it, and dropping a bound the object ships today.So the 7d scope sits under the table window rather than replacing it: the
ttlleg restates the 90d the object has always declared, on the samecreated_atclockretentionreaps by. Non-terminal rows keep exactly today's behaviour. This is still zero new contract and still one file:retention+ttlon one object is a landed shape with two in-repo precedents —system-upload-session.object.ts:143-144andsystem-file.object.ts:180-181, both of which likewise put the short, scoped window onretentionand the other window onttl.Both legs run:
LifecycleService.reapObject(packages/objectql/src/lifecycle/lifecycle-service.ts:1022and:1083) takesttlandretentionin independentifbranches, not an either/or.retention's field is hard-codedcreated_atat:1097, which is what makes the two windows commensurable.Zone-2 assumptions, as measured
maxAge, noonlyWhenon the delivery object(^ or non-alpha)onlyWhen[space]*:returns 0 in that file; positive controlsys-automation-run.object.ts:64returns the$inshape;readonlyWhennegative control separates cleanly (1 hit repo-wide, elsewhere)dead/suppressedretention.onlyWhenis consumed by the Reaper atlifecycle-service.ts:1500/1510reap()buildsscope = onlyWhen ?? {}and spreads it intoreapWhere({ [field]: { $lt: cutoff }, ...scope }). Not edited; exercised end to end by the new suiteClause-②: noe4902d2b9, nothing touching retentionAssumption 3, reconciled
The
statusfield declares six members (notification-delivery.object.ts:98):pending,in_flight,success,failed,dead,suppressed. The ack paths that write this object —SqlNotificationOutbox.ack(sql-outbox.ts:264-275) andMemoryNotificationOutbox.ack(memory-outbox.ts:164-177) — produce exactlysuccess | suppressed | dead | pending, andclaim()writesin_flight.⇒
failedis a declared-but-unwritten member for THIS object. It issys_http_delivery's terminal status (http-outbox.ts,sql-http-outbox.ts,objects/http-delivery.object.ts:66), a different object in the same package. Naming it in the sweep scope would scope on a value that cannot occur. So the scope is the ruling's two, and no more — not silently widened, not silently narrowed. The object comment carries that reconciliation and thesys_automation_run#15223 lesson verbatim in intent: ⛔ widen this$inin the same change as a writer, because a widened writer against a narrow sweep scope means the new status is simply never aged out, silently, forever.successis terminal in the ordinary sense and is deliberately outside the scope — the ruling keeps delivery history at the table window, and the acceptance namessuccessas a row that must survive.Tests
packages/services/service-messaging/src/notification-delivery-retention-sweep.test.ts— the REAL declaration through the REAL provisioning pass (applySystemFields) through the REAL Reaper (LifecycleService) against a REAL SQL backend (live better-sqlite3), over a table the driver created from that same declaration. Same doctrine as the two sibling sweeps,plugin-auth/sys-session-ttl-sweep.test.tsandservice-storage/sys-upload-session-ttl-sweep.test.ts.Seven rows, one sweep, four verdicts:
d_dead_30ddeadd_suppressed_30dsuppressedd_dead_2ddeadd_success_30dsuccessd_pending_30dpendingd_in_flight_30din_flightd_success_100dsuccessttllegd_dead_30dandd_success_30dcarry the identicalcreated_at, so no age rule can separate their fates — only theonlyWhenstatus filter can.d_dead_2dis terminal but inside the window, so "everything terminal is reaped" fails too.d_success_100dis the regression guard on the table bound: it is what distinguishes "the 90d window was kept" from "it was traded away".Package suite,
pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2:pnpm --filter @objectstack/service-messaging typecheck— exit 0, andtsc --noEmit --listFilesconfirms the new test file is in the program (so the typecheck genuinely covers it, rather than excluding**/*.test.tsand reporting green about a file it never read).Ablation — the negative pin is not vacuous
This lane's standing commitment: every negative pin gets an ablation. The mutation was applied to the shipped file on disk, after the fix was committed, under a restoring
trap ... EXIT INT TERMwith absolute paths.Mutation: delete the
onlyWhenscoping line, keepmaxAge: '7d'.On-disk proof, not an editor's exit code:
No rebuild step is claimed and none is needed: the declaration under test is this package's own source, imported relatively (
./objects/notification-delivery.object.js), so vitest readssrcand not a dependency'sdist. That the src mutation reddened the suite with no rebuild is itself the resolution evidence — had the subject resolved through a built artifact, the ablation would have stayed green and certified nothing.Result — red, in the predicted direction (4 of 9, and precisely the 4 that depend on the scoping):
The load-bearing one is the behavioural failure, not the declaration
toEqual:Without
onlyWhen, the same 7d retention takes every 30d row whatever its status — the non-terminal rows included. That is exactly the outcome the negative pins discriminate against, so they are measuring the filter and not the sweep's mere existence.The five that stayed green are the ones that should: the two positive controls, the 7d-window control, the
ttlleg, and the two in-file counterfactuals (which supply their own declaration and are therefore insensitive to the file).Restore, verified by state and not by an exit code:
Gates
Derived from the real change set with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(re-derived after the ledger row landed, which added 8 families), then reconciled with--rancarrying exit codes:67 green. The 3 NOT MEASURED all exit 3, the code these gates use for
PREREQUISITE NOT MET— each refuses to run without a whole-repo build closure, which is CI'sBuild Core/TypeScript Type Checkrun and not a per-card one:pnpm check:dual-build-cjs-loads— "this gate reads built output, and some package has no dist/" (55 packages listed)pnpm check:i18n— needs the CLI plus the build closure of all ten extract-config packages.lifecycleblock, and touches nolabel,pluralLabelordescriptionpnpm check:type-check-debt— "17 workspace dependencies ... have no built type entry point on disk"⛔ None of these three is a red, and none is a pass. Recorded as NOT MEASURED with the reason, per the gates' own instruction that exit 3 "is NOT a pass: nothing was measured".
Repo-wide lint, run whole rather than narrowed —
pnpm exec eslint . --no-inline-config --format json, exit code captured before any pipe, ata6c8cd7f5:One generated file is in the diff:
scripts/engine-double-contract.pinned.jsongains one row for the new suite's engine double, written bynode scripts/check-engine-double-contract.mjs --write("789 rows, 1 added or grown, 0 lost"). The double opens withassertEngineDeleteDispatch(options), so it refuses exactly what the real engine refuses rather than re-deriving the rule.Changeset
.changeset/17611-terminal-delivery-retention.md,minoron@objectstack/service-messaging. It states the operational consequence in the first paragraph, as acceptance item 2 requires: dead and suppressed delivery rows are now deleted 7 days after creation, so any report or SLA reading that consulted them must read inside that window. It also flags a second consequence the ruling did not name but the change creates: an operator override ofretention_overrides.maxAgefor this object used to move the whole table's window and now moves the terminal-failure window only.Per the ruling's own instruction, this PR states the seat's confidence gap ① rather than closing it:
Nothing on this card proves the reporting deployment runs the lifecycle Reaper at all.
OS_LIFECYCLE_DISABLED=1or the plugin switch turns it off. The card's own arithmetic is consistent with either world: 2,876 rows at +316/day is about nine days, far short of any 90d window, so that reading can neither confirm nor falsify that the Reaper is sweeping there. If that deployment has lifecycle disabled, C yields nothing there and #17732 becomes the live card.This PR does not chase the reporter and does not attempt to close the gap — the ruling asked @hotlong for that reading on the card itself. What this PR does establish is the other half: that the declaration is honoured wherever the Reaper does run, measured end to end above rather than assumed.
Acceptance notes
Observations from reading the surface, filed nowhere and deliberately not acted on in this PR:
sys_job_queue.object.ts,sys_automation_run.object.ts,sys_session.object.ts); on disk they aresys-job-queue.object.ts,sys-automation-run.object.ts,sys-session.object.ts(hyphens), and they live inplatform-objects/service-automation, not beside each other. Cosmetic, and the shapes are exactly as the ruling describes.sys_notification_delivery'sstatusfield declaresfailed, which no writer of this object can produce (see Assumption 3 above). That is dead vocabulary on an authorable-looking enum rather than a defect with a repro, so it is noted here and not filed. Retiring it would be an ADR-0049 enforce-or-remove exercise on a field shared conceptually withsys_http_delivery, well outside this card.Generated by Claude Code