Skip to content

fix(objectql,service-queue): lifecycle settings 覆盖不再能绕过消费者的保留窗下限 (#5195) - #5210

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5195-lifecycle-override-floor
Aug 4, 2026
Merged

fix(objectql,service-queue): lifecycle settings 覆盖不再能绕过消费者的保留窗下限 (#5195)#5210
os-zhuang merged 2 commits into
mainfrom
claude/issue-5195-lifecycle-override-floor

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5195

边门是什么

ADR-0057 P4 允许运维通过 lifecycle settings 命名空间按环境/租户覆盖任一对象的保留窗,而在此之前对覆盖值的唯一校验是「能不能解析」(lifecycle-service.tseffectiveWindowMs:覆盖胜过声明,只在解析失败时回落)。可保留窗并不只是运维一个人的事——别的代码可能依赖那些行还在

sys_job_queue 就是现成的例子。DbQueueAdapter 的 publish 去重靠「拿终态行的 created_at 比 idempotency 窗」,#5192 把这个顺序做成了构造期不变量:构造时拒绝「idempotencyWindowMs > 对象声明的 retention」。但构造期读不到 settings 覆盖:

// lifecycle → retention_overrides
{ "sys_job_queue": { "maxAge": "1h" } }

completed 行写下 1 小时就被清、publish 仍按 24h 去重 → 窗口内的重复投递被重新接受,日志里一行都没有。一个昨天刚立起来的强制不变量,今天就有一扇能绕开它的边门。

落点:消费者向 lifecycle 注册「下限」

不写死 sys_job_queue,也没动 packages/spec。消费方在运行期声明自己的契约能承受的最短窗:

lifecycle.registerRetentionFloor('sys_job_queue', {
  policy: 'retention',          // 或 'ttl'
  minWindowMs: 24 * 60 * 60 * 1000,
  declaredBy: 'com.objectstack.service.queue',
  consequence: '…低于下限时什么会静默坏掉',
  remedy: '…把覆盖改成什么才合法',
});

QueueServicePluginkernel:ready 注册,和 service-storage 注册 reap guard 是同一形状(duck-typed、best-effort、没有 lifecycle 服务时什么都不做——那种内核本来也没有 sweeper)。

为什么是运行期注册而不是 spec 上加 minRetention:队列的下限就是 DbQueueAdapterOptions.idempotencyWindowMs,一个 per-kernel 的构造参数。对象声明里的静态键只能是它的第二份拷贝,而且注定漂移。声明说的是「行保留多久」,下限说的是「消费方能承受行只保留多短」——两个不同作者、不同生命周期的数字。所以这条不需要动 spec,也不应该动 spec。

拒绝,不 clamp

覆盖低于下限时拒绝该覆盖,声明窗继续跑;不是 clamp 到下限。理由:

  • clamp 会强制执行一个声明里没有、settings 里也没有的第三个数字——运维看哪个面都预测不出行到底什么时候消失;而且这个数字会因为一个不相干的包改了自己的下限而移动。
  • 拒绝只有一个回落值:声明。这恰好是本文件里既有的处理方式(解析失败的覆盖也是回落到声明,「never fail open into no bound at all」),一条规则而不是两条。
  • 运维的意图不是被悄悄地打个折,而是被明确拒绝,并同时给出两个能让它合法的设置。

响亮程度

  • error 级(不是 warn)。按 AGENTS.md 的判据:降级之后系统从外面看完全正常——sweep 报告成功、表照常缩小——而被悄悄破坏的契约要到几天后的重复投递才显形。日志一行同时给出后果修复:

    [lifecycle] REJECTED the retention override '1h' (3600000ms) on sys_job_queue at global scope:
    it is below the 86400000ms floor registered by 'com.objectstack.service.queue'. Enforcing the
    declared 604800000ms window instead. Consequence: DbQueueAdapter dedups sys_job_queue publishes
    by comparing created_at against its 86400000ms idempotency window, so a shorter retention deletes
    the very rows that check reads — duplicate deliveries resume silently, with nothing in any log.
    Fix: set lifecycle.retention_overrides.sys_job_queue.maxAge to '24h' or longer, or lower
    QueueServicePlugin's db.idempotencyWindowMs to the window you actually want (both are measured
    from created_at).
    
  • 同时进 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 刚关掉的无界增长表,不划算。
  • 没注册下限的表完全不受影响:P4 覆盖行为一字未变。
  • 注册畸形的下限(缺 consequence/remedyminWindowMs ≤ 0、policy 非法)在注册时就抛——一条没人能照着做的 error 日志等于没有。

改动面

文件 内容
packages/objectql/src/lifecycle/lifecycle-service.ts registerRetentionFloor()LifecycleRetentionFloor / LifecycleFloorViolation、floor-aware 的 effectiveWindowMsreport.floorViolationsLifecycleLoggerLike.error?(缺省回落 warn)
packages/objectql/src/lifecycle/lifecycle-settings.ts retention_overrides 的运维说明补上「低于下限会被拒」
packages/services/service-queue/src/db-queue-adapter.ts idempotencyWindowMs getter + retentionFloor()(带构造时的真实窗口,remedy 里给的是运维能直接粘的时长字面量)
packages/services/service-queue/src/queue-service-plugin.ts kernel:ready 注册下限
docs/adr/0057-…md §3.3 新增 Amendment (#5195);scripts/adr-anchors.jsonlifecycle-service.ts 上锚
packages/services/service-queue/README.md 原来那句提醒式的「Keep it ≥ your idempotency window」换成实际强制的描述

测试

packages/objectql(14 条新用例)+ packages/services/service-queue(5 条新用例,跑真的 LifecycleService × 真的 SysJobQueue 声明):

  • 1h 覆盖被拒 / 合法覆盖(2d)仍生效 / 恰好等于下限合法(界是 不是 >)/ 无下限的表不受影响;
  • 租户级覆盖同样被拒;ttl 下限与 retention 下限互不干扰;最严下限生效;重复注册是替换;
  • 日志说一次、report 每轮都有;没有 error 方法的 logger 回落 warn;畸形下限注册即抛;
  • 端到端:publish → 投递 → 过 2 小时 → sweep,带下限时行还在、同 key 重发仍被去重;
  • 以及一条 REPRODUCES the bypass(不注册下限时行被清、重复真的落库)——证明这套 harness 有能力失败,不是恒真。

新写的假引擎全部把 delete() 路由到 assertEngineDeleteDispatch(options)(#4550 门禁,本分支跑绿)。

$ pnpm --filter @objectstack/objectql test
 Test Files  115 passed (115)
      Tests  1833 passed (1833)

$ pnpm --filter @objectstack/service-queue test
 Test Files  3 passed (3)
      Tests  35 passed (35)

$ pnpm --filter @objectstack/objectql --filter @objectstack/service-queue typecheck
packages/objectql typecheck: Done
packages/services/service-queue typecheck: Done

$ node scripts/check-engine-double-contract.mjs
check-engine-double-contract: OK — 17 pinned, 31 in the DEBT ledger, 1 exempt.
$ node scripts/check-startup-registry-verdict.mjs
✓ 40 startup/open-registry seam(s), none recording a verdict the boot can contradict.
$ node scripts/check-durability-degradation-log-level.mjs
✓ 14 durability-critical catch seam(s), all loud or rethrowing.
$ node scripts/check-adr-anchors.mjs
check-adr-anchors: OK (21 anchored file(s))

新用例逐条(verbose):

✓ LifecycleService — retention floors (#5195) > rejects a global override below the floor and keeps enforcing the declared window
✓ … > a legal override (≥ the floor) still wins over the declared window
✓ … > an override exactly AT the floor is legal (the bound is ≥, not >)
✓ … > an object with no registered floor is untouched — 1h still applies
✓ … > floors a TENANT-scoped override too — the same door one scope down
✓ … > a retention floor does not reject a ttl override (policies are separate windows)
✓ … > the strictest of several registered floors governs
✓ … > re-registering the same (object, policy, declaredBy) replaces rather than accumulates
✓ … > reports a DECLARED window below the floor, and still enforces it
✓ … > logs a standing violation once, but reports it on every sweep
✓ … > falls back to warn when the logger has no error method
✓ … > refuses a malformed floor at registration — an unactionable rejection helps nobody
✓ … > an unparseable override still keeps the declared window and is not a floor violation
✓ a lifecycle settings override cannot undercut the dedup window (#5195) > REPRODUCES the bypass when no floor is registered: the row is reaped and the duplicate lands
✓ … > rejects the override once the adapter has registered its floor — dedup keeps holding
✓ … > a legal override (≥ the dedup window) still takes effect
✓ … > the floor carries the CONFIGURED idempotency window, not the default
✓ … > QueueServicePlugin registers the floor at kernel:ready (the wiring, not just the ability)

changeset:.changeset/lifecycle-retention-floor.md(@objectstack/objectql minor —— 新增公开导出与 report 字段;@objectstack/service-queue patch)。

🤖 Generated with Claude Code

https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd


Generated by Claude Code

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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 10:29am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/service-queue.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/service-queue)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/service-queue)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/service-queue)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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

Copy link
Copy Markdown
Contributor Author

接手说明(同一会话续做,认领不变)

原 dev agent 在推送本 PR 后、修门禁红的过程中被 API 终止,没有留下交付报告。本次接手把分支上已有的实现逐块复核了一遍,并完整重跑了全部验证 —— 下面的证据是这次实跑的,不是沿用上面正文里的。

唯一的门禁红:check:slot-lookup 棘轮(已修)

queue-service-plugin.ts 的 erasure 计数 4 → 6。新增的两处都在下限注册那一段:let lifecycle: anygetServiceany 型参。

没有调基线、没有加豁免,而是在调用点补上真类型:db-queue-adapter.ts 里新增 LifecycleFloorRegistrar,只声明本包实际调用的那一个方法。

这不是为了让棘轮变绿而做的敷衍 —— 这处 any 恰好是整个改动里最不该有它的地方:它盖住的是唯一一处下限注册调用,而该调用包在一个「记 error 然后继续」的 try 里。registerRetentionFloor 一旦改名或换参数序,编译照过、运行期抛进 catch,结果是下限静默地没注册上 —— 正是本 PR 要关掉的那种静默绕过,只不过上移了一层。

registerRetentionFloor 在接口上故意可选:内核里可能跑着早于该能力的 lifecycle 服务,所以运行期的 typeof … === 'function' 探测是一道真检查,类型如实说出来,而不是被 any 连检查带调用一起藏掉。

复核结论:实现方向符合裁定

对照 issue 正文 + PM 分诊评论逐条核过 —— 覆盖低于下限时拒绝(非 clamp)、error 级 + 进 sweep report、没有写死 sys_job_queue(objectql 侧完全通用,队列只是第一个调用方)、未动 packages/spec、未碰 content/docs/releases/。改动面 11 个文件,已用 merge-base 核对确认。

除棘轮外没有发现其它不符合规格之处,实现予以保留。

完整重跑的验证证据

棘轮(先前红的那一条):

$ node scripts/check-slot-lookup-ratchet.mjs
✓ slot-lookup ratchet holds: 159 unswept site(s) in 34 file(s), none new.
  baseline key set verified against d25f20b: no files added.

测试(注:接手时 packages/objectql/dist 比 src 旧 8 分钟 —— service-queue 的用例是从构建产物导入 LifecycleService 的,所以先重建了 objectql,否则这份证据不作数):

$ pnpm --filter @objectstack/objectql --filter @objectstack/service-queue exec vitest run --maxWorkers=2
 Test Files  115 passed (115)      # objectql
      Tests  1833 passed (1833)
 Test Files  3 passed (3)          # service-queue
      Tests  35 passed (35)
EXIT=0

-t "5195" 逐条跑,确认新用例真的执行(objectql 13 条 —— 正文里写的 14 条是笔误;service-queue 5 条):

 Tests  13 passed | 1820 skipped (1833)     # objectql
 Tests  5 passed | 30 skipped (35)          # service-queue

typecheck / eslint / 其余门禁:

$ pnpm --filter @objectstack/objectql typecheck        → EXIT=0
$ pnpm --filter @objectstack/service-queue typecheck   → EXIT=0
$ npx eslint --no-inline-config (7 个改动文件)          → EXIT=0(无输出)
$ node scripts/check-adr-anchors.mjs
check-adr-anchors: OK (21 anchored file(s), every governing ADR still referenced).
$ node scripts/check-engine-double-contract.mjs
check-engine-double-contract: OK — 17 pinned, 31 in the DEBT ledger, 1 exempt.
$ node scripts/check-startup-registry-verdict.mjs
✓ 40 startup/open-registry seam(s), none recording a verdict the boot can contradict.
$ node scripts/check-durability-degradation-log-level.mjs
✓ 14 durability-critical catch seam(s), all loud or rethrowing.

远端 CI 全绿(head 97edc20c):23 个 check run 全部结束,21 success + 2 skipped,0 失败 —— 含先前红的 ESLint job(Slot-lookup ratchet 步骤在该 job 内)、TypeScript Type CheckTest Core ×3、Dogfood Regression Gate ×3、Temporal ConformanceCheck Changeset

PR 保持 draft,assignee 与认领未动。分支基于 d25f20b6,期间 main 已前进,合并前需要按常规重新验证。


Generated by Claude Code


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 10:45
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 7c2f7dd Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5195-lifecycle-override-floor branch August 4, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants