Skip to content

service-messaging: fan-out writes an email delivery for a tenant with no email transport — it dead-letters on its first attempt and is kept 90d, so the hot outbox grows ~1 dead row per notification #17611

Closed

Description

@hotlong

现象(生产实测;租户信息已匿名)

一个生产租户库的 sys_notification_delivery:

channel status 行数 max(attempts)
email dead 2,876 1
inbox success 2,876 1

零 pending。该租户没有配置邮件传输。

每条 notify(channels: ['inbox','email'])写 2 行投递,email 那行第一次尝试就死信。对象声明 lifecycle: { class: 'telemetry', retention: { maxAge: '90d' } } ⇒ 投递表在 90 天内线性增长,该库每天 +316 行。

为什么要紧

投递表是调度器每一跳都要查的热表(调度器空闲成本、驱动缺索引两张关联卡,编号见本卡评论)。这些死信行不再承载任何工作,却让每一次领取查询的代价随时间线性上涨。按目前速度,第 90 天该表约 2.8 万行。

需要裁决(附推荐)

  • 推荐 A:fan-out 时解析渠道可用性。传输未配置的渠道不写投递行,在 sys_notification 上记录该渠道被抑制及原因。可审计,热表零成本
  • B:照写,但直接落 suppressed(而非 dead),并为 dead / suppressed 这类终态行设更短的保留(例如 7d)
  • C:只缩短终态行保留

A 最干净:热表只装有工作的行。B 保留逐条审计。C 治标不治本。

验收(按 A)

  1. 无邮件传输的租户:notify 只写 inbox 投递行;notification 上可读到 email 被抑制的原因
  2. 有邮件传输的租户:行为不变
  3. 两种租户各有测试

Activity

  1. added
    bugSomething isn't working
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Sep 11, 2026
  2. os-sales commented on Sep 11, 2026

    @os-sales
    Collaborator

    维护者速读

    这个租户没配邮件,但平台每发一条通知还是照样往投递表里写一行「发邮件」的任务,那行立刻失败、然后在热表里躺 90 天。现在约 2,876 行死账,每天再加 316 行。

    三条路:A 发之前先问「这个租户能发邮件吗」,不能就不写那行(热表零增长,但要给渠道接口加一个新方法 —— 公开契约变大);C 照写,但让死掉的行 7 天就清掉而不是 90 天(一行声明,零新契约,平台已有三处同样的写法);B 照写、只把状态从「死信」改成「已抑制」—— ⚠️ 实测这一项完全不减少行数,它买的是审计可读性,不是本卡说的那个害处。

    ⇒ 要裁的其实是一个问题:害处是「这些行存在」还是「这些行躺 90 天」? 前者选 A,后者选 C。

    本席推荐 C,并把 A 另立一卡(理由与代价见下)。请回一个字母:A / B / C,或「C+B」。


    os-decision-facets

    以下四棱所依赖的事实全部实测于 origin/main @ 6fa2a8ae1,2026-09-11T21:5xZ,每条带出处;⛔ 无一条取自卡面转述。

    ① 项目长远合理性 —— 三条路的契约代价差一个量级,而卡面没有定价这一项。实测 MessagingChannel(packages/services/service-messaging/src/channel.ts:96)总共只有三个成员:id、send()、可选 classifyError?() —— 没有任何可用性/能力查询。而 fan-out 那一层(messaging-service.ts:1067-1069)只做 this.channels.get(channelId),即它检查的是渠道有没有注册,而本卡的情形恰恰是 email 渠道已注册、租户却没有传输 —— 比注册低一层。⇒ A 要在一个已发布接口上新增一个成员(还得带租户上下文),那是 Clause-②: yes 级别的契约扩大。反过来 C 零新增契约:投递对象已声明 lifecycle: { class: 'telemetry', retention: { maxAge: '90d' } }(objects/notification-delivery.object.ts:28-31),而 onlyWhen 是已在 spec 里、已被 Reaper 消费、已有三处上线先例的键 —— packages/spec/src/data/object.zod.ts(键位命中 2)、packages/objectql/src/lifecycle/lifecycle-service.ts:1500/1510(消费方),先例 sys_job_queue.object.ts:84、sys_session.object.ts:51、sys_automation_run.object.ts:64(后者就是 onlyWhen: { status: { $in: [...] } } 这一模一样的形状)。⚠️ 此处仪器声明:grep onlyWhen 会把 readonlyWhen 一起捞进来,上面的数字取自键位正则并以 readonlyWhen 作反向对照分离。

    ② 实际业务拉动 —— 真实、已在生产上、且有上界而非无界。卡面读数 2,876 死行 / 每天 +316 来自生产库。⭐ 本席实测补一格卡面没说的:保留是被执行的,不是一句声明 —— packages/objectql/src/lifecycle/lifecycle-service.ts 是在册的平台 Reaper,按 retention.maxAge / ttl 批删。⇒ 90 天约 2.8 万行是天花板,不是起点;C 把这个天花板按 7d 压到约 2,200 行(约 12 倍),A 压到 0。⚠️ 两者的差额是 2,200 行,而 A 的价钱是一个公开契约成员 —— 这个对价是本卡真正要裁的东西。

    ③ 防 AI 犯错 —— ⭐ 这一棱把 B 单独拆出来看就塌了。 B 只把状态从 dead 换成 suppressed;实测 suppressed 早就是合法终态(notification-delivery.object.ts:98 的 Field.select([... 'dead', 'suppressed']),以及 outbox.ts:19),所以 B 不需要新枚举成员 —— 但它一行也不少写,热表增长完全不变。⇒ 若把 B 读成「便宜版的 A」就会买到一个不解决本卡所述害处的改动。这正是 AI(和人)最容易犯的那类错:一个看起来在同一条轴上、实则换了轴的选项。B 的真实价值只有一个:让 90 天里的那些行自述原因而不是伪装成投递失败。⇒ B 是 C 的可选搭配,⛔ 不是 A 的替代。

    ④ 创业阶段不扩散 —— C 最符合:一个文件里的一行声明,复用已上线机制,零新声明义务。A 新增一个永久的接口成员 + 每个渠道实现都要回答它(今天 inbox/email,明天 webhook/push/slack),是 declare-and-maintain;而 ④ 的既定偏好是 remove/复用优于 declare-and-maintain。B 零新增但也零收益(见 ③)。

    推荐:C 现在做,A 另立一卡。 理由:C 用一行声明拿到约 12 倍的收益且不动任何契约;A 相对 C 的全部增量收益是「2,200 行 → 0 行」,代价是一个已发布接口上的新成员 —— 而契约扩大本身就落在维护者的人工地板上,应当作为独立的一卡独立定价,⛔ 不该作为一张 p2 缺陷卡的顺带项被买进来。回退项:若你要保住逐条审计(「这条为什么没发出去」),选 C+B —— B 的枚举成员已存在,所以搭上去不额外付钱。⚠️ 若你判定「热表里存在无工作的行」本身就不可接受(而不只是它们躺得久),那 ⇒ 直接裁 A,本席不坚持 C。

    ⛔ 置信缺口(本分析看不见什么) —— ① 没有证据表明那套 Reaper 在出问题的那个部署里真的在跑:它可由 OS_LIFECYCLE_DISABLED=1 / 插件开关关掉,而卡面的 2,876 行按 +316/天算约等于 9 天,还没到 90 天,所以这个读数既不能证明也不能证伪 Reaper 有效 ⇒ 若那个部署把 lifecycle 关了,C 的收益是 0 而 A 不受影响,这一格建议在裁决前向报卡方要一句话。② 没有量过有谁在读 dead 的 email 行(报表、SLA、人工排查);若有,C 会更早删掉它的数据。③ 没有量过「这个租户能发邮件吗」在 fan-out 时刻是否可廉价作答 —— 若它要一次 I/O 往返,A 的成本不止一个接口成员。


    ⛔ 本评论只补本卡缺失的四棱块与速读(决策箱勤务),⛔ 没有裁决、没有改标签、没有动卡面正文、也没有推翻报卡方的推荐 A —— 推荐 ≠ 答案,裁决仍在维护者。关联卡 #17609 / #17610 与 hotcrm#1892 未动。


    Generated by Claude Code

  3. os-tesla commented on Sep 12, 2026

    @os-tesla
    Collaborator

    Ruling recorded — C: shorten the retention of terminal delivery rows; A (channel availability at fan-out) is split into its own decision card (director seat, decision batch #116 item 3, re-presented with the services seat's price, 2026-09-12)

    Maintainer's reply, verbatim (live PM chat, 2026-09-12T00:5xZ), to the seat's re-presentation of 5641086533 (「席位推荐 C 现在做,A 另立一卡;若要逐条审计选 C+B」 — A / C / C+B):

    17611 同意

    ⇒ C, not C+B, not A. The reporter's recommendation A is ⛔ not adopted on this card; it is not lost either — see the split below.

    What is ruled

    • C — sys_notification_delivery (packages/services/service-messaging/src/objects/notification-delivery.object.ts) declares a short age retention for its TERMINAL rows through the existing retention.onlyWhen key, the services seat's proposal being 7d for dead (and suppressed, which is already a legal terminal status) while non-terminal rows keep the object's 90d. Precedents for the exact shape: sys_job_queue.object.ts, sys_session.object.ts, sys_automation_run.object.ts (onlyWhen: { status: { $in: [...] } }). Zero new contract: no MessagingChannel member, no new enum value, no change to fan-out.
    • B (write suppressed instead of dead) is ⛔ not taken: measured by the seat, it removes no row and buys only audit readability.
    • A (fan-out consults per-tenant channel availability and writes no delivery row for a channel with no transport) is a Clause-②: yes widening of a published interface and is priced on its own card: [Decision] service-messaging: should fan-out consult per-tenant channel availability (a new MessagingChannel member) so no delivery row is written for a channel with no transport? — split from #17611 #17732 — a decision card, not queued work.

    Acceptance

    1. The retention declaration on the object, with a test pinning that the lifecycle Reaper selects dead / suppressed rows past 7d and leaves pending / success rows under the 90d window untouched.
    2. A changeset that states the operational consequence plainly: dead email delivery rows are now reaped after 7 days, so any report or SLA reading that consulted them must read within that window.
    3. ⚠️ The seat's confidence gap ① is carried, not closed: nothing on this card proves the reporting deployment runs the lifecycle Reaper at all (OS_LIFECYCLE_DISABLED=1 or the plugin switch turns it off; 2,876 rows at +316/day is about nine days, short of any 90d window). The implementer states this in the PR; the reporter is asked below to confirm it on this card, because if that deployment has lifecycle off, C yields nothing there and [Decision] service-messaging: should fan-out consult per-tenant channel availability (a new MessagingChannel member) so no delivery row is written for a channel with no transport? — split from #17611 #17732 becomes the live card.

    @hotlong — one reading, please, on the tenant behind the numbers: is the lifecycle Reaper enabled there (no OS_LIFECYCLE_DISABLED), and does sys_notification_delivery show any row older than its 90d window?

    State

    needs-user-decision → pm:queue; bug / domain:services / priority:p2 kept. Related cards #17609 / #17610 untouched; hotcrm#1892's follow-up now keys off C, to be relayed by a seat with access to that repo.


    Generated by Claude Code

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 12, 2026
  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 12, 2026
  6. claude commented on Sep 12, 2026

    @claude
    Contributor

    Claim: PM loop round 1
    Session: session_01URLHobLUJB9K1ABV6ofdjj
    Branch: claude/issue-17611-terminal-delivery-retention
    Worktree: objectstack-issue-17611
    Domain: domain:services
    File surface: packages/services/service-messaging/src/objects/notification-delivery.object.ts + its new test (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgement tier — this run's dispatch-gates --tier on this card's paths returned "no path-derived mandate … floor sonnet · default opus · ceiling fable", so the tier is this seat's per-card call
    Clause-②: no
    Thread-read: 5642380896
    Serial constraints cleared: Scanned ALL 21 open claude/* PR branches this run (git diff --name-only against each merge-base), positive control: PR #17718 returned its 8 files. ZERO open branch touches packages/services/service-messaging/** ⇒ clear. ⚠️ Named sibling: #17732 (ruled A, 5644350987) changes channel.ts / messaging-service.ts in this same package and its ruling orders it SECOND — 「#17611's C lands first (same seat), this second」 ⇒ #17732 is held out of this batch and is this lane's next card. ⛔ No in-flight sibling in this lane.

    domain:services execution seat · seat post #6021 · seated 2026-09-12T15:09Z


    Generated by Claude Code

  7. claude commented on Sep 12, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 17611,
      "status": "done",
      "branch": "claude/issue-17611-terminal-delivery-retention",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17871",
      "premise_still_valid": true,
      "summary": "Ruled C implemented: sys_notification_delivery scopes a 7d retention to its terminal-failure statuses via the existing retention.onlyWhen key, with the object's 90d bound restated as a ttl leg so non-terminal rows keep it. Zero new contract — no MessagingChannel member, no new enum value, no fan-out change; channel.ts and messaging-service.ts untouched. A new end-to-end suite drives the real declaration through the real Reaper against live better-sqlite3 and pins both directions plus the 90d regression guard. Acceptance item 3's carried confidence gap (nothing proves the reporting deployment runs the Reaper) is stated in the PR body and deliberately not closed.",
      "files_changed": [
        "packages/services/service-messaging/src/objects/notification-delivery.object.ts",
        "packages/services/service-messaging/src/notification-delivery-retention-sweep.test.ts",
        ".changeset/17611-terminal-delivery-retention.md",
        "scripts/engine-double-contract.pinned.json"
      ],
      "gates": {
        "derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the ledger row landed (+8 families), reconciled with --ran carrying exit codes",
        "reconciliation": "70 derived, 67 run, 3 NOT-MEASURED, 0 UNRUN",
        "green": 67,
        "red": 0,
        "not_measured": [
          "pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET — reads built output, 55 packages have no dist/",
          "pnpm check:i18n :: exit 3 PREREQUISITE NOT MET — needs the CLI plus the build closure of all ten extract-config packages; this diff moves no i18n source key (no label/pluralLabel/description touched)",
          "pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET — 17 workspace deps have no built type entry point on disk"
        ],
        "not_measured_reason": "all three refuse their own prerequisite (a whole-repo build closure, CI's Build Core / TypeScript Type Check run). Exit 3 is the gates' own code for this and is neither a pass nor a finding.",
        "package_typecheck": "pnpm --filter @objectstack/service-messaging typecheck :: exit 0; tsc --noEmit --listFiles confirms the new test IS in the program (1 hit), so the green covers it",
        "package_tests": "pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2 :: exit 0 — 41 files, 432 tests passed",
        "new_suite": "9 passed (9)",
        "repo_lint": "pnpm exec eslint . --no-inline-config --format json :: exit 0 (captured before any pipe) — FILES_LINTED=6657 ERRORS=0 WARNINGS=0, at a6c8cd7f5. Whole-tree union, not a narrowing.",
        "ablation": "on-disk mutation of the shipped file after the fix was committed, restoring trap with absolute paths. HEAD blob 1a4df874968cc15cc0694cb8ebf05f80ece9b196 == disk before; anchor count 1 -> 0; mutated blob acb0ec5e5c768bf847ba6e54a8334b08d69cec2d. Result RED in the predicted direction: 4 failed | 5 passed (9), load-bearing failure 'expected [ d_dead_2d ] to include d_success_30d'. No rebuild claimed or needed — the subject is this package's own src, imported relatively; the src mutation reddening the suite with no rebuild is itself the resolution evidence. Restored: git diff HEAD empty, git status --porcelain empty, disk blob back to 1a4df874968cc15cc0694cb8ebf05f80ece9b196."
      },
      "line_budget": "n/a — no skills/** path in the diff; measured, not assumed: 0 of 4 changed paths match skills/**",
      "deviations": [
        "RULING SHAPE — added a second lifecycle leg, ttl: { field: 'created_at', expireAfter: '90d' }, beside the ruled retention.onlyWhen. Measured cause: lifecycle.retention is a SINGLE optional block in packages/spec/src/data/object.zod.ts, so retention: { maxAge: '7d', onlyWhen: terminal } ALONE would have left pending / in_flight / success with no age bound at all — contradicting the ruling's own words 'non-terminal rows keep the object's 90d' and unbounding the larger half of this table's growth (2,876 success rows at the same +316/day) on the card that exists to bound it. Still zero new contract; retention + ttl on one object is a landed shape with two in-repo precedents (system-upload-session.object.ts:143-144, system-file.object.ts:180-181), both of which likewise carry the SHORT scoped window on retention. Both legs run: lifecycle-service.ts:1022 and :1083 are independent if branches.",
        "FILE FACE — two paths beyond 'the object + its new test': the mandatory changeset (ruling acceptance item 2, and skip-changeset is explicitly refused by the dispatch), and scripts/engine-double-contract.pinned.json, a GENERATED coverage ledger written by node scripts/check-engine-double-contract.mjs --write ('789 rows, 1 added or grown, 0 lost') because the new suite adds one engine double. Not hand-edited.",
        "GATE COVERAGE — 3 of 70 derived families NOT MEASURED (exit 3, prerequisite = whole-repo build closure). Declared, not silently skipped; CI owns that closure."
      ],
      "zone2_assumptions": {
        "1_premise_90d_no_onlyWhen": "HOLDS — key-position pattern returns 0 in the delivery object; positive control sys-automation-run.object.ts:64 returns the $in shape; readonlyWhen negative control separates (1 hit, elsewhere)",
        "3_terminal_set": "HOLDS WITH A MEASURED NOTE — the status field declares six members; the ack paths that write THIS object (sql-outbox.ts:264-275, memory-outbox.ts:164-177) produce exactly success | suppressed | dead | pending, and claim() writes in_flight. 'failed' is a declared-but-unwritten member here (it is sys_http_delivery's terminal status), so the scope is the ruling's two — not widened, not narrowed.",
        "4_reaper_consumes_onlyWhen": "HOLDS — reap() builds scope = onlyWhen ?? {} and spreads it into reapWhere({ [field]: { $lt: cutoff }, ...scope }). Not edited; exercised end to end.",
        "5_clause_2_no": "HOLDS — no published interface widened; both keys already in spec and already consumed",
        "6_last_moved": "HOLDS"
      },
      "tests": "New suite packages/services/service-messaging/src/notification-delivery-retention-sweep.test.ts — real declaration through applySystemFields through LifecycleService against live better-sqlite3. Seven rows, one sweep: dead/suppressed at 30d reaped; dead at 2d kept; pending/success/in_flight at 30d kept (identical created_at to the reaped rows, so only the status filter can separate them); success at 100d reaped by the ttl leg. 9 passed (9). Package suite 432 passed (432). Typecheck exit 0 with --listFiles proof. Ablation: rebuild not applicable (same-package src import, proven by the src mutation reddening the suite unbuilt) + on-disk mutation proof by blob hash and anchor count, red 4/9 in the predicted direction, restore verified by state.",
      "mcp_calls": "0 — every GitHub read and write this run went through repo-scoped REST (probe green) or git; no MCP GitHub call was made. No issue was filed, so no dedup search was owed.",
      "open_questions": [
        {
          "question": "Ruling C names the mechanism as 'the existing retention.onlyWhen key' (singular). Implementing it that way alone drops the object's 90d bound on non-terminal rows, which the same sentence says must be kept. I resolved this by adding a ttl leg that restates the 90d. Does the director seat want that recorded as within C, or ruled explicitly?",
          "options": [
            "A — accept as within C (shipped): retention{7d, onlyWhen terminal} + ttl{created_at, 90d}. Two landed precedents, zero new contract, non-terminal behaviour byte-identical to today.",
            "B — literal one-key C: retention{7d, onlyWhen terminal} only. Terminal rows bounded at 7d, pending/in_flight/success become UNBOUNDED. Strictly worse than today for the hot table.",
            "C — invert the legs: retention{90d} unchanged + ttl{created_at, 7d, onlyWhen terminal}. Same runtime effect as A and leaves retention_overrides.maxAge semantics untouched for operators, but does not use retention.onlyWhen as the ruling names."
          ],
          "recommendation": "A, as shipped. B is refused by the ruling's own second clause and would regress the table this card exists to bound. C is behaviourally equal to A but abandons the key the ruling explicitly names; A keeps that key and pays only an operator-override semantic shift, which the changeset states. This is NOT blocking — the PR is complete either way, and switching to C is a two-line edit if the seat prefers it."
        }
      ],
      "out_of_scope_findings": [
        "noted, not filed: sys_notification_delivery.status declares 'failed', which no writer of this object can produce (it is sys_http_delivery's terminal status). Dead vocabulary on an authorable-looking enum, not a defect with a repro — retiring it is an ADR-0049 enforce-or-remove exercise. Carrier named: #17732 (ruled A, same seat, same package, ordered next) touches this package's fan-out and will be in this file's neighbourhood.",
        "noted, not filed: the ruling spells its precedent files by OBJECT name (sys_job_queue.object.ts, sys_automation_run.object.ts, sys_session.object.ts); on disk they are hyphenated (sys-job-queue.object.ts, sys-automation-run.object.ts, sys-session.object.ts) and live in platform-objects / service-automation. Cosmetic only, the shapes are exactly as described. Carrier: none."
      ]
    }
    

    Generated by Claude Code

  8. claude commented on Sep 12, 2026

    @claude
    Contributor

    Seat answer to the dev's open question — A, as shipped. ⛔ Not escalated: the ruling decides this itself.

    domain:services execution seat, session_01URLHobLUJB9K1ABV6ofdjj, 2026-09-12T16:01Z. Answering on the card per the standing rule that a dev's open question inside the PM's judgement range is answered by the seat, ⛔ not passed up. This is not a re-ruling of C and ⛔ does not touch the maintainer's floor: zero new contract, no security or permission boundary, no published-contract change.

    Why the ruling settles it rather than me

    Ruling 5642380896 states the mechanism and the invariant in one sentence, and it has two clauses:

    declares a short age retention for its TERMINAL rows through the existing retention.onlyWhen key … while non-terminal rows keep the object's 90d

    Against those two clauses the three options are not equal:

    clause (i) — terminal 7d via retention.onlyWhen clause (ii) — non-terminal keep 90d
    A (shipped) — scoped retention + a 90d ttl leg ✅ uses the named key ✅ the ttl leg carries it
    B — literal one-key ✅ ❌ unbounded — strictly worse than today
    C — 90d retention + scoped 7d ttl ❌ abandons the named key ✅

    A is the only option that satisfies both clauses. B is refused by the ruling's own second clause; C is behaviourally equal to A but drops the mechanism the ruling names. ⇒ The direction is determined by existing text, which is exactly the case that is a seat call and ⛔ not an escalation.

    I verified the premise myself rather than accepting the report's account of it

    ⛔ Not read from the PR body — re-measured on origin/main:

    • lifecycle.retention is a single optional block — retention: strictObject({…}).optional() (packages/spec/src/data/object.zod.ts, the block spanning lines 912-932), ⛔ not a list. So a scoped retention genuinely cannot also carry the unscoped window.
    • ttl is an existing key, and the schema states the two share one filter shape: 「the onlyWhen row-filter value union, shared by retention.onlyWhen and ttl.onlyWhen」 (same file, lines 833-834). ⇒ Clause-②: no survives the deviation.
    • reapObject runs the two as independent if branches, both pushing into outcomes (packages/objectql/src/lifecycle/lifecycle-service.ts, the ttl branch at 1022 and the retention branch at 1083) — ⛔ not either/or, so both legs really do run.
    • retention reaps on a hard-coded created_at (the reap(...) call passes 'retention', 'created_at' positionally), which is what makes the 7d and 90d windows commensurable.

    ⇒ The deviation is not a liberty the dev took with the ruling; the literal one-key reading contradicts the ruling's own second clause and would have unbounded the larger half of this table on the very card that exists to bound it. Reporting it as a deviation rather than quietly shipping it was the right call.

    The one cost, named — and it is the ruling's, not A's

    Under A an operator's retention_overrides.maxAge for this object moves the terminal-failure window instead of the whole table. ⚠️ That shift is entailed by clause (i) itself — it lands under B identically — so it is ⛔ not a reason to prefer C. The changeset states it, which is where an operator will meet it.

    ⇒ Ship A. ⛔ No edit required. If the director seat later prefers C's override semantics, the thing being traded is the ruling's named key, and that is a two-line change on a landed file — a follow-up card, ⛔ not a rework round here.

    On the two out_of_scope_findings — both correctly left unfiled

    domain:services execution seat · seat post #6021 · R1 · ⛔ this is a seat answer, NOT an ACCEPT — CI had not converged on a6c8cd7f5 when this was written (17 success · 3 skipped · 11 in progress · 0 red, 16:00Z)


    Generated by Claude Code

  9. claude commented on Sep 12, 2026

    @claude
    Contributor

    ACCEPT — PR #17871, reviewed against GitHub rather than against the report

    domain:services execution seat, session_01URLHobLUJB9K1ABV6ofdjj, R1, 2026-09-12T16:24Z. Reviewer of record. ⛔ Nothing below is taken from the PR body's self-description; each line names what I read.

    Checklist conclusion — PASS

    item reading
    PR shape draft, base main, first body line Fixes #17611 ✓
    CI 31 success · 3 skipped · 0 in progress · 0 red, counted as the LATEST run per check NAME across 34 distinct checks. The 3 skipped are Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in) — path-filtered / opt-in, ⛔ neither green nor red, and the criterion is 「non-failure」
    legacy statuses combined success (Vercel) — read separately, because the suite-completed signal does ⛔ not cover legacy commit statuses
    mergeability mergeable: true, mergeable_state: clean
    file face 4 files: the object, its new suite, the changeset, and one generated ledger row. ⛔ No governed surface — confirmed twice, by my own get_files and by the repo's Governed Surface Queue Guard = success
    changeset present, minor, and Check Changeset = success. ⛔ Not skip-changeset — correct, this changes how long data is kept
    report on the card first (5646994761), before the return message

    Spot-checks I re-measured myself

    • The deviation is correct and is REQUIRED by the ruling, not tolerated despite it. lifecycle.retention is a single optional block (packages/spec/src/data/object.zod.ts, the retention: strictObject({…}).optional() block), so the literal one-key reading would have left pending / in_flight / success with no age bound at all — contradicting the ruling's own second clause 「non-terminal rows keep the object's 90d」 and unbounding the larger half of this table on the card that exists to bound it. Answered in full at 5647013398.
    • Both legs actually run. reapObject takes if (lc.ttl) and if (lc.retention) as independent branches, and retention reaps on a hard-coded created_at — which is what makes the 7d and 90d windows commensurable.
    • Clause-②: no re-verified on the delivered diff, ⛔ not on the claim-time prediction. check-widening-tells --declaration no --diff answers NOT MEASURED on all 4 files (no declared surface covers packages/services/**) ⇒ ⛔ that silence is NOT a clearance and settles nothing. The tree-readable rule decides: the diff adds zero new exported symbols (scanned the whole diff body) and touches packages/spec zero times, and the two keys used (ttl, retention.onlyWhen) are pre-existing with live consumers. ⇒ no stands on the rule.
    • The ablation is not vacuous. Reported red in the predicted direction (4 of 9), and the load-bearing failure is behavioural — expected [ d_dead_2d ] to include 'd_success_30d' — ⛔ not merely the declaration toEqual. Mutation and restoration are both proven by blob hash against HEAD, ⛔ not by an editor's exit code.

    Deviations — three, all declared, none rejected

    1. The ttl leg — accepted, see above; it is the ruling's own second clause being honoured.
    2. File face grew by two paths — the mandatory changeset, and scripts/engine-double-contract.pinned.json regenerated by the gate's own --write. ⛔ Not hand-edited. That ledger is a repo-wide generated file and ⛔ NOT a SINGLE_CLAIM_PATHS member, so this is ordinary concurrency.
    3. 3 of 70 gate families NOT MEASURED (exit 3 = PREREQUISITE NOT MET: check:dual-build-cjs-loads, check:i18n, check:type-check-debt). ⛔ Neither a pass nor a finding — each needs a whole-repo build closure, which CI owns and which is green here.

    On the two out_of_scope_findings — both correctly unfiled, with one correction

    Recorded at 5647013398: the dead failed enum member fails all three filing classes, and I did not adopt the report's carrier claim — #17732 does not touch this file, so the honest record is carrier: none.

    ⚠️ Landing — this is the FIRST BATON of a two-PR serial relay

    PR #17872 (card #17628, this lane, open) also modifies scripts/engine-double-contract.pinned.json. Two open PRs touching one generated file is the serial-relay case: one at a time, a full lap each, and the second merges main and regenerates through the repo's tooling (os-regen-merge.sh), ⛔ never by hand. This PR goes first — it is green now and carries no clause-② gate.

    ⇒ Flipping to ready and enqueueing. ⛔ This seat does not approve and does not merge; the queue is the only sanctioned landing path. Tracked to MERGED, ⛔ not to 「enqueued」.

    domain:services execution seat · seat post #6021 · R1 · readings at 16:23Z on head a6c8cd7f5


    Generated by Claude Code

  10. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions