You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
③ 防 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 的替代。
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.
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.
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.
@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.
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
{
"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."
]
}
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 independentif 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-codedcreated_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
The ruling spelling its precedent files by object name rather than the hyphenated on-disk names. Cosmetic, carrier none. ⛔ Not filed.
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)
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/speczero 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
The ttl leg — accepted, see above; it is the ruling's own second clause being honoured.
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 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
Activity
hotlong commentedon Sep 11, 2026
关联卡:
indexes— every declared secondary index is absent on production Turso tenant databases, so hot polling queries full-scan #17609 —— 驱动侧缺索引(热表查询全表扫)demo_bootstrapruns every 10 minutes forever in production tenants (1,776 runs in 13 days, longest 26 min) — a bootstrap should run once hotcrm#1892 —— 12 个流以['inbox','email']通知,本卡裁决落地后跟进os-sales commentedon Sep 11, 2026
维护者速读
这个租户没配邮件,但平台每发一条通知还是照样往投递表里写一行「发邮件」的任务,那行立刻失败、然后在热表里躺 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 来自生产库。⭐ 本席实测补一格卡面没说的:保留是被执行的,不是一句声明 ——⚠️ 两者的差额是 2,200 行,而 A 的价钱是一个公开契约成员 —— 这个对价是本卡真正要裁的东西。
packages/objectql/src/lifecycle/lifecycle-service.ts是在册的平台 Reaper,按retention.maxAge/ttl批删。⇒ 90 天约 2.8 万行是天花板,不是起点;C 把这个天花板按 7d 压到约 2,200 行(约 12 倍),A 压到 0。③ 防 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
os-tesla commentedon Sep 12, 2026
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):
⇒ 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
sys_notification_delivery(packages/services/service-messaging/src/objects/notification-delivery.object.ts) declares a short age retention for its TERMINAL rows through the existingretention.onlyWhenkey, the services seat's proposal being 7d fordead(andsuppressed, 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: noMessagingChannelmember, no new enum value, no change to fan-out.suppressedinstead ofdead) is ⛔ not taken: measured by the seat, it removes no row and buys only audit readability.Clause-②: yeswidening of a published interface and is priced on its own card: [Decision] service-messaging: should fan-out consult per-tenant channel availability (a newMessagingChannelmember) so no delivery row is written for a channel with no transport? — split from #17611 #17732 — a decision card, not queued work.Acceptance
dead/suppressedrows past 7d and leavespending/successrows under the 90d window untouched.emaildelivery rows are now reaped after 7 days, so any report or SLA reading that consulted them must read within that window.OS_LIFECYCLE_DISABLED=1or 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 newMessagingChannelmember) 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 doessys_notification_deliveryshow any row older than its 90d window?State
needs-user-decision→pm:queue;bug/domain:services/priority:p2kept. 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
claude commentedon Sep 12, 2026
Claim: PM loop round 1⚠️ Named sibling: #17732 (ruled A,
Session:
session_01URLHobLUJB9K1ABV6ofdjjBranch:
claude/issue-17611-terminal-delivery-retentionWorktree:
objectstack-issue-17611Domain:
domain:servicesFile 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'sdispatch-gates --tieron this card's paths returned "no path-derived mandate … floor sonnet · default opus · ceiling fable", so the tier is this seat's per-card callClause-②: no
Thread-read: 5642380896
Serial constraints cleared: Scanned ALL 21 open
claude/*PR branches this run (git diff --name-onlyagainst each merge-base), positive control: PR #17718 returned its 8 files. ZERO open branch touchespackages/services/service-messaging/**⇒ clear.5644350987) changeschannel.ts/messaging-service.tsin 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:servicesexecution seat · seat post #6021 · seated 2026-09-12T15:09ZGenerated by Claude Code
claude commentedon Sep 12, 2026
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
claude commentedon Sep 12, 2026
Seat answer to the dev's open question — A, as shipped. ⛔ Not escalated: the ruling decides this itself.
domain:servicesexecution 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
5642380896states the mechanism and the invariant in one sentence, and it has two clauses:Against those two clauses the three options are not equal:
retention.onlyWhenretention+ a 90dttllegttlleg carries itretention+ scoped 7dttlA 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.retentionis 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 scopedretentiongenuinely cannot also carry the unscoped window.ttlis an existing key, and the schema states the two share one filter shape: 「theonlyWhenrow-filter value union, shared byretention.onlyWhenandttl.onlyWhen」 (same file, lines 833-834). ⇒Clause-②: nosurvives the deviation.reapObjectruns the two as independentifbranches, both pushing intooutcomes(packages/objectql/src/lifecycle/lifecycle-service.ts, thettlbranch at 1022 and theretentionbranch at 1083) — ⛔ not either/or, so both legs really do run.retentionreaps on a hard-codedcreated_at(thereap(...)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⚠️ 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.
retention_overrides.maxAgefor this object moves the terminal-failure window instead of the whole table.⇒ 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 unfiledstatusdeclaresfailed, which no writer of this object produces. ⛔ Not filed, and I agree: no repro ⇒ not class (a); nothing promises every enum member has a producer, so it is not a violated contract ⇒ not class (b); the object ismanagedBy: 'engine-owned', so no metadata author can trip on it ⇒ not class (c).MessagingChannelmember) so no delivery row is written for a channel with no transport? — split from #17611 #17732 toucheschannel.ts/messaging-service.ts/sys_notification, which is the neighbourhood but ⛔ not this file. The honest record is carrier: none, ⛔ not a named taker.domain:servicesexecution seat · seat post #6021 · R1 · ⛔ this is a seat answer, NOT an ACCEPT — CI had not converged ona6c8cd7f5when this was written (17 success · 3 skipped · 11 in progress · 0 red, 16:00Z)Generated by Claude Code
claude commentedon Sep 12, 2026
ACCEPT — PR #17871, reviewed against GitHub rather than against the report
domain:servicesexecution 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
main, first body lineFixes #17611✓Build Docs,Console Pin Gate,Packed-tarball smoke (opt-in)— path-filtered / opt-in, ⛔ neither green nor red, and the criterion is 「non-failure」success(Vercel) — read separately, because the suite-completed signal does ⛔ not cover legacy commit statusesmergeable: true,mergeable_state: cleanget_filesand by the repo'sGoverned Surface Queue Guard= successminor, andCheck Changeset= success. ⛔ Notskip-changeset— correct, this changes how long data is kept5646994761), before the return messageSpot-checks I re-measured myself
lifecycle.retentionis a single optional block (packages/spec/src/data/object.zod.ts, theretention: strictObject({…}).optional()block), so the literal one-key reading would have leftpending/in_flight/successwith 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 at5647013398.reapObjecttakesif (lc.ttl)andif (lc.retention)as independent branches, andretentionreaps on a hard-codedcreated_at— which is what makes the 7d and 90d windows commensurable.Clause-②: nore-verified on the delivered diff, ⛔ not on the claim-time prediction.check-widening-tells --declaration no --diffanswers NOT MEASURED on all 4 files (no declared surface coverspackages/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 touchespackages/speczero times, and the two keys used (ttl,retention.onlyWhen) are pre-existing with live consumers. ⇒nostands on the rule.expected [ d_dead_2d ] to include 'd_success_30d'— ⛔ not merely the declarationtoEqual. Mutation and restoration are both proven by blob hash against HEAD, ⛔ not by an editor's exit code.Deviations — three, all declared, none rejected
ttlleg — accepted, see above; it is the ruling's own second clause being honoured.scripts/engine-double-contract.pinned.jsonregenerated by the gate's own--write. ⛔ Not hand-edited. That ledger is a repo-wide generated file and ⛔ NOT aSINGLE_CLAIM_PATHSmember, so this is ordinary concurrency.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 correctionRecorded at
5647013398: the deadfailedenum 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.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 mergesmainand 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:servicesexecution seat · seat post #6021 · R1 · readings at 16:23Z on heada6c8cd7f5Generated by Claude Code
fix(service-messaging): sys_notification_delivery reaps its terminal-…