fix(objectql,service-queue): lifecycle settings 覆盖不再能绕过消费者的保留窗下限 (#5195) - #5210
Conversation
ADR-0057 P4 lets an operator override any object's retention window through the `lifecycle` settings namespace, and the only validation on that override was "does it parse". A retention window is not only the operator's business: other code can depend on the rows still being there. `sys_job_queue` is the worked example. `DbQueueAdapter` dedups publishes by comparing a terminal row's `created_at` against its idempotency window, and #5179 made the ordering an invariant by refusing — at construction — an idempotency window longer than the object's DECLARED retention. A settings override the constructor cannot see (`retention_overrides.sys_job_queue.maxAge = '1h'`) walks straight around it: completed rows are reaped an hour after they are written, publish keeps dedupping against 24h, and duplicate deliveries resume with nothing in any log. A consumer may now register a retention floor at runtime — `lifecycle.registerRetentionFloor(object, { policy, minWindowMs, declaredBy, consequence, remedy })` — declaring the shortest window its own contract survives: - an override below the floor, GLOBAL or TENANT-scoped, is REJECTED and the declared window keeps running. Not clamped to the floor: a clamp enforces a third number written in neither the declaration nor the settings, and it moves whenever an unrelated package changes its floor. Rejection has one fallback, the declaration, which is how an unparseable override already resolves ("never fail open into no bound at all"); - the rejection is `error`-level with the consequence AND the fix, because what it prevents leaves the system looking healthy; it is also on the sweep report as `floorViolations`, machine-readable, every sweep; - a DECLARED window below a floor is reported the same way and still enforced — refusing to reap would trade a broken consumer contract for the unbounded table #5179 just closed; - objects with no registered floor are untouched: P4 behaves exactly as before. Floors are runtime wiring, not spec surface — the same call ADR-0057's reap-guard amendment makes, plus a reason of their own: the queue's floor IS `DbQueueAdapterOptions.idempotencyWindowMs`, a per-kernel construction option, so a static key on the object's `lifecycle` block could only be a copy that drifts. No `packages/spec` change. `QueueServicePlugin` registers `sys_job_queue`'s floor on `kernel:ready` carrying the window the adapter was actually constructed with, so a non-default `db.idempotencyWindowMs` is covered too. The ordering is now enforced from both ends: the constructor rejects a too-long idempotency window, the floor rejects a too-short `maxAge`. Tests cover the rejected 1h override (global and tenant), a legal override still winning, an override exactly at the floor, objects with no floor being unaffected, ttl/retention floors staying separate, strictest-floor-wins, re-registration replacing, log-once/report-always, and the end-to-end queue scenario — including a test that REPRODUCES the bypass with no floor registered, so the harness is proven to be able to fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…ng it to `any` (#5195) The floor registration added two `getService`-erasure sites to queue-service-plugin.ts (`let lifecycle: any` and `getService<any>('lifecycle')`), growing the file's `check:slot-lookup` ratchet count 4 → 6. That file is grandfathered for its EXISTING sites only, and the baseline never grows. Fixed at the call site rather than by touching the baseline or adding an exemption: `LifecycleFloorRegistrar` declares the one method this package calls on the slot, so the registration is type-checked. That is not ratchet appeasement — `any` on this particular call is the worst place in the change to have it: a renamed or re-ordered `registerRetentionFloor` would compile, then throw at runtime inside the `try` that logs and continues, leaving the floor silently unregistered. That is exactly the silent bypass #5195 exists to close, reintroduced one layer up. `registerRetentionFloor` is optional on the interface on purpose: a kernel may carry a lifecycle service predating floors, so the runtime `typeof … === 'function'` probe is a real check and the type now says so, instead of an `any` hiding both the check and the call. Verified: `check:slot-lookup` back to 159 unswept sites, none new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
接手说明(同一会话续做,认领不变)原 dev agent 在推送本 PR 后、修门禁红的过程中被 API 终止,没有留下交付报告。本次接手把分支上已有的实现逐块复核了一遍,并完整重跑了全部验证 —— 下面的证据是这次实跑的,不是沿用上面正文里的。 唯一的门禁红:
|
Fixes #5195
边门是什么
ADR-0057 P4 允许运维通过
lifecyclesettings 命名空间按环境/租户覆盖任一对象的保留窗,而在此之前对覆盖值的唯一校验是「能不能解析」(lifecycle-service.ts的effectiveWindowMs:覆盖胜过声明,只在解析失败时回落)。可保留窗并不只是运维一个人的事——别的代码可能依赖那些行还在。sys_job_queue就是现成的例子。DbQueueAdapter的 publish 去重靠「拿终态行的created_at比 idempotency 窗」,#5192 把这个顺序做成了构造期不变量:构造时拒绝「idempotencyWindowMs > 对象声明的 retention」。但构造期读不到 settings 覆盖:completed 行写下 1 小时就被清、publish 仍按 24h 去重 → 窗口内的重复投递被重新接受,日志里一行都没有。一个昨天刚立起来的强制不变量,今天就有一扇能绕开它的边门。
落点:消费者向 lifecycle 注册「下限」
不写死
sys_job_queue,也没动packages/spec。消费方在运行期声明自己的契约能承受的最短窗:QueueServicePlugin在kernel:ready注册,和 service-storage 注册 reap guard 是同一形状(duck-typed、best-effort、没有 lifecycle 服务时什么都不做——那种内核本来也没有 sweeper)。为什么是运行期注册而不是 spec 上加
minRetention:队列的下限就是DbQueueAdapterOptions.idempotencyWindowMs,一个 per-kernel 的构造参数。对象声明里的静态键只能是它的第二份拷贝,而且注定漂移。声明说的是「行保留多久」,下限说的是「消费方能承受行只保留多短」——两个不同作者、不同生命周期的数字。所以这条不需要动 spec,也不应该动 spec。拒绝,不 clamp
覆盖低于下限时拒绝该覆盖,声明窗继续跑;不是 clamp 到下限。理由:
响亮程度
error级(不是warn)。按 AGENTS.md 的判据:降级之后系统从外面看完全正常——sweep 报告成功、表照常缩小——而被悄悄破坏的契约要到几天后的重复投递才显形。日志一行同时给出后果与修复:同时进 sweep report:
LifecycleSweepReport.floorViolations,机器可读,每轮都报;日志按「同一处违规只说一次」去重(AGENTS.md:say it once),避免每小时一条 error 把人训练成跳过 error。覆盖面
maxAge: '1h'是同一扇边门往下一层)。retention(maxAge,含 rotation fallback)与ttl(expireAfter)是两个独立的下限,互不误伤。(object, policy, declaredBy)重复注册是替换而非累积。error+ report),但仍然执行——拒绝清扫等于用「消费方契约坏了」换回 service-queue: completed 任务行无人清理 —— purge() 零生产调用方、sys_job_queue 未声明 retention,队列表只增不减 #5179 刚关掉的无界增长表,不划算。consequence/remedy、minWindowMs ≤ 0、policy 非法)在注册时就抛——一条没人能照着做的 error 日志等于没有。改动面
packages/objectql/src/lifecycle/lifecycle-service.tsregisterRetentionFloor()、LifecycleRetentionFloor/LifecycleFloorViolation、floor-aware 的effectiveWindowMs、report.floorViolations、LifecycleLoggerLike.error?(缺省回落warn)packages/objectql/src/lifecycle/lifecycle-settings.tsretention_overrides的运维说明补上「低于下限会被拒」packages/services/service-queue/src/db-queue-adapter.tsidempotencyWindowMsgetter +retentionFloor()(带构造时的真实窗口,remedy里给的是运维能直接粘的时长字面量)packages/services/service-queue/src/queue-service-plugin.tskernel:ready注册下限docs/adr/0057-…mdscripts/adr-anchors.json给lifecycle-service.ts上锚packages/services/service-queue/README.md测试
packages/objectql(14 条新用例)+packages/services/service-queue(5 条新用例,跑真的LifecycleService× 真的SysJobQueue声明):≥不是>)/ 无下限的表不受影响;error方法的 logger 回落warn;畸形下限注册即抛;REPRODUCES the bypass(不注册下限时行被清、重复真的落库)——证明这套 harness 有能力失败,不是恒真。新写的假引擎全部把
delete()路由到assertEngineDeleteDispatch(options)(#4550 门禁,本分支跑绿)。新用例逐条(verbose):
changeset:
.changeset/lifecycle-retention-floor.md(@objectstack/objectqlminor —— 新增公开导出与 report 字段;@objectstack/service-queuepatch)。🤖 Generated with Claude Code
https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
Generated by Claude Code