Skip to content

fix(convention): 持久性降级记 error 而非 warn —— 把 #4460 定成规则,并给规则装上机械检查 (#4632) - #4730

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-4632-durability-degradation-log-level
Aug 3, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-4632-durability-degradation-log-level

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4632

把 #4460 的点状修复定成规则,并且给规则装上牙齿。

一、规则落在哪,为什么

锚点选了 AGENTS.md,新增一节「Degradation log levels — warn vs error」,放在 “Route & surface ownership” 之后。理由:

  • 这是一条写 catch 时当场适用的判据,不是某个子系统的架构决策。ADR 回答「我们决定采用什么架构」,而这条回答「你现在敲的这一行该用哪个级别」—— 后者属于每个 agent 编辑前必读的 AGENTS.md。
  • 它的适用面比任何一个 ADR 都宽:任何包、任何 catch,不限于插件 init 期。挂在 ADR-0116(plugin ordering declared contract)的 addendum 上会把它误缩到「插件排序」这一个语境里 —— 而 [automation/approvals] 进程重启后审批决策静默失效:挂起 flow run 仍只存内存(#1518 标记 COMPLETED 但 17.0.0-rc.1 未生效),approve 落库却永不推进且零报错 #4420 只是碰巧发生在那里。
  • 它有现成的近邻:同文件 Prime Directive chore: version packages #10 的推论「never advertise a capability the runtime doesn't actually deliver(declared ≠ enforced)」,以及 “Route & surface ownership” 第 3 条「Absence must be loud … Prefer failing to falling back」。持久性降级记 warn,正是「对外宣称持久化、运行时并不交付」的一个实例。新节显式交叉引用了这两处。

文案是判定问句,不是形容词(按验收要求):

After the degradation, does the system still look "normal" from the outside, while something it claims is persisted has not actually landed?
Yes → error. No → warn/info is right.

并规定提到 error 的每一处必须在第一行同时给出后果与修复动作(照 service-automation/src/plugin.ts start() 的 #4460 范本),且只说一次,不是每次写失败都刷一遍。

同时明确写了反向失败:把功能性降级误提到 error 会训练所有人跳读 error,而那正是当初 #4420 那条 warn 没人读的原因。规则特别点出 if (!service) 的组合分支通常是功能性的、该留在 warn;真正咬人的是包住写入 / DDL / store 初始化的 catch。

二、机械检查:做了

「一条只写在 AGENTS.md 里、没有任何东西检查的约定,就是本仓反复修的那个『声明 ≠ 强制』形状」—— 这个批评是对的,所以做了闸门。

pnpm check:durability-log-level(scripts/check-durability-degradation-log-level.mjs,已接入 lint.yml 的必需 job):AST 遍历所有 catch,若其 try 块调用了词表内的持久化关键操作,则该 catch 必须 error(或 fatal)或重新抛出;记 warn/info/debug 或静默吞掉即失败。

几个刻意的设计:

闸门的局限,写在脚本头部而不是等人踩:它发现不了词表之外的新持久化缝隙,只能保证已经付过学费的那些不再退回 warn。这是棘轮,不是证明。发现新缝隙时,请在修它的同一个 PR 里把它加进词表。第二条局限:catch 的响亮判定扫的是整棵子树,所以一个通过嵌套 try 恢复的 catch(batch→sequential 那个)会因恢复路径的 error 而通过 —— 对那个形状这是正确判决,但确实意味着一个无关的嵌套 logger.error 也能满足闸门。收紧它会误伤合法的恢复型 catch,那是更糟的交易。

当前实测结果:8 处持久化关键缝隙,7 处已响亮,1 处 baseline(冻结包,见下)。

三、盘点结果(逐条,含判为第一类的)

判定问句 = 「降级后系统对外表现是否仍然『正常』,而声称已持久化的东西实际没有落盘?」

改为 error(第二类)

文件 / 行 后果
objectql/src/plugin.ts ~405 reload-time schema sync Studio 加一个字段 → 元数据落库、UI 显示、API 接受,而列从未创建。作者被告知「已保存」,实际没有
objectql/src/plugin.ts ~1002 / ~1018 per-object sync DDL 没跑;对象仍在 registry、仍有 REST 路由、仍然渲染,表/列不存在
objectql/src/plugin.ts 汇总行 原来失败任意多个仍打 info: Schema sync complete —— 这就是「看起来正常」的那一半。现在 failed > 0 时是 error 并报出计数
objectql/src/engine.ts ~5340 syncSchemas() 原本是空 catch {},注释写「log suppressed to avoid noise on already-synced tables」。但 syncSchema 契约要求幂等,能走到这个 catch 的驱动就是没同步成功;唯二调用方是 marketplace 插件安装与模板 seeding,它们随后就往这个失败意味着不存在的表里 INSERT,然后报告安装成功。这一处是闸门发现的,不在 PM 名单里
service-automation/src/plugin.ts ~882 wait-timer 外层 落盘成功,但没有任何 timer 被重新武装:重启前挂起的每个审批永久悬停
service-automation/src/builtin/wait-node.ts ~160 list 失败 直接 return 0,一个 timer 都没武装,整个子系统级别的失效
service-automation/src/builtin/wait-node.ts ~176 overdue resume 失败 该 run 的截止时间已过,不会再有任何东西唤醒它
service-automation/src/builtin/wait-node.ts ~205 re-schedule 失败 run 带着截止时间留在盘上,却没有 job 会唤醒它

关于 wait-node 这三处:数据确实落盘了,坏掉的是恢复。之所以仍判第二类 —— 持久化状态与运行时状态互相矛盾,而看起来没事的是运行时;对那个等审批的人来说,结果与 #4420 的丢失不可区分。这也是为了和同一子系统的外层 catch 保持一致:在一个文件里对同一后果给出两种级别,比任何一种选择都糟。

判为第一类,不改(逐条给理由)

文件 / 行 为什么留在 warn/info
mcp/src/plugin.ts 164 环境变量弃用提示,根本不是降级
objectql/src/plugin.ts 215 / 224 authored hook/action rebind 失败后旧绑定仍在,行为陈旧而非丢失
objectql/src/plugin.ts 431 legacy app.* 弃用提示
objectql/src/plugin.ts 611 / 682 registry ingest / event handler registry 未刷新,后续查询直接报错,失败是响亮的
objectql/src/plugin.ts 938 external object 注册 联邦对象未映射到远端表,使用时响亮失败;其 schema 本就归远端所有,无 DDL 可丢
objectql/src/plugin.ts 992 batch→sequential 会恢复。真正丢 DDL 的是随后的顺序路径,那一处已提到 error
objectql/src/plugin.ts 1279 存量形状转换成功的提示
objectql/src/plugin.ts 1692 / 1706 authored action runner action body 不执行 = 功能缺一块
service-analytics/src/plugin.ts 213 init 时没有 data 服务,每次查询重试;真的一直缺就在调用点 throw,响亮
service-analytics/src/plugin.ts 585 getReadScope 缺失 最接近边界的一条。后果是安全/机密性(analytics 查询不做租户隔离),不是持久性;而且单租户 host 没有 security 服务是合法组合,提到 error 会在每个单租户 host 每次启动制造噪音 —— 正是规则的反面失败。规则按维护者原文保持在「持久性 / 数据一致性」,我没有擅自扩到安全域
service-automation/src/plugin.ts 452 init 期 manifest 缺失是「还没到」,start() 会重试;真缺失时由 #4460 那条 error 兜底
service-automation/src/plugin.ts 491 / 758 / 766 / 1355 / 1412 / 1456 引擎缺失、flow 注册/拉取失败 = 某条 flow 不跑,功能性
service-automation/src/plugin.ts 847 / 910 已是写得很好的功能性 warn(触发器未绑定 / 声明式 connector 未注册),都已带修复动作
service-automation/src/plugin.ts 1180 降级 husk 注册失败,紧接着下一行就是 error
service-automation/src/builtin/wait-node.ts 184 !job 声明式缺席:从未组合 job 服务的 host 本就没有自动恢复能力,不存在「先承诺再打破」。提到 error 会在每个无 job host 的每次启动、每个挂起 run 各刷一次
trigger-api 64 / 68 / 80 触发器未安装 / 入站 503 / 端点未挂载 —— 都是功能缺一块,且 503 在请求时响亮
trigger-record-change 49 / 57 同上
trigger-schedule 53 / 62 同上

四、排除的两个包:有发现,已单开 issue

按本轮硬约束,packages/metadata/** 与 packages/metadata-protocol/** 零改动,发现只记录:

另有 packages/metadata/src/plugin.ts 388(Failed to attach FileSystemRepository)存疑:后果取决于 manager 无 repository 时的行为,冻结包内未深挖,未作断言。若确证属第二类,建议并入 #4728。

packages/spec/** 零改动;content/docs/releases/ 未触碰。

五、验证

  • pnpm check:durability-log-level —— self-test 10 例通过;实测 8 缝隙全部响亮或重抛(1 条 baseline)
  • pnpm --filter @objectstack/objectql test / pnpm --filter @objectstack/service-automation test
  • pnpm --filter @objectstack/objectql typecheck / pnpm --filter @objectstack/service-automation typecheck
  • 新增测试双向都钉:持久性降级必须 error 且文案含后果与修复动作;功能性降级(batch 回退、无 job 服务)必须留在 warn;全部成功时一句不吭

已加 changeset(.changeset/durability-degradation-log-level.md)。无 API / schema / 行为变更 —— 只改了已经在失败的路径报告的级别和文案。


Generated by Claude Code

…4632)

#4420: the durable suspended-run store attached to a table that was never
created. Every write failed into a warn nobody read, every restart dropped
all in-flight approvals, and the process reported perfect health throughout.
#4460 raised that ONE site to error. This makes it the rule, because the
class is what recurs.

The rule (AGENTS.md, new "Degradation log levels" section) is a judgment
QUESTION rather than an adjective, so it can be applied while writing the
catch: after the degradation, does the system still look "normal" from the
outside while something it claims is persisted has not actually landed?
Yes -> error. An error here owes the CONSEQUENCE and the FIX in its first
line, once, per the #4460 reference text in service-automation's start().

Anchored in AGENTS.md rather than an ADR addendum: this is an authoring
judgment every agent applies at every catch, not an architectural decision
scoped to one subsystem, and it is broader than the plugin-ordering context
ADR-0116 covers. It cross-references its two existing cousins -- Prime
Directive #10 (declared != enforced) and "Absence must be loud".

Sites raised to error, each judged individually:
  - objectql schema sync, per object and summary (the info line said
    "Schema sync complete" over a pass that lost DDL)
  - objectql reload-time schema sync (a Studio edit shows a field the
    column for which was never created)
  - ObjectQL.syncSchemas() -- an EMPTY catch; marketplace install and
    template seeding write into tables this failure means do not exist
  - service-automation suspended wait-timer re-arm, 4 paths

Left at warn deliberately: the batch->sequential schema-sync fallback (it
recovers) and "no job service registered" on the re-arm path (a declared
absence, not a broken promise). Over-applying this rule trains everyone to
skim error, which is what made the original #4420 warn unreadable.

Teeth: pnpm check:durability-log-level walks the AST for catch blocks
guarding a declared vocabulary of durability-critical operations and fails
when one degrades below error without rethrowing. Follows same-file helpers
so extracting a reporter cannot quietly defeat it; ships a 10-case
--self-test; exceptions are hand-written and shrink-only with no --fix flag.
Deliberately narrow -- a ratchet, not a proof; both limitations are stated
in the script header.

Findings in the two packages frozen this round are filed, not fixed:
#4728 (metadata database-loader silent DDL swallow, baselined) and
#4729 (metadata-protocol seed-loader: its own comment demands error).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
@vercel

vercel Bot commented Aug 3, 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 3, 2026 1:02am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file tests tooling size/xl labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/flows.mdx (via @objectstack/service-automation)
  • 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-automation)
  • 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-automation)
  • 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-automation)
  • content/docs/releases/v9.mdx (via @objectstack/service-automation)

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.

…context bag

CI caught what a filter-scoped typecheck could not:

  src/engine.ts(5356,15): error TS2353: Object literal may only specify known
  properties, and 'object' does not exist in type 'Error'.

`Logger` (packages/spec/src/contracts/logger.ts) is asymmetric on purpose:

  warn (message: string,                  meta?: Record<string, any>): void
  error(message: string, error?: Error,   meta?: Record<string, any>): void

Every escalation in the previous commit was written as a mechanical
warn -> error swap, which silently slides the context bag from the `meta`
slot into the `Error` slot. Excess-property checking makes that a hard
error -- but ONLY where a declaration is emitted, so it surfaced in the
tsup DTS build and in the repo-wide TypeScript Type Check, while every
runtime test stayed green (a plain object in the Error slot logs fine).

Four call sites corrected to (message, Error, meta):
  - engine.ts syncSchemas()                  -> e as Error, { object, tableName, driver }
  - plugin.ts reload-time schema sync        -> the caught error
  - plugin.ts reportSyncFailure()            -> the caught error, { object, tableName, driver }
  - plugin.ts pass summary                   -> undefined (no single error owns a whole pass)

The tests now assert the ARGUMENT POSITIONS rather than a flattened `meta`,
so this exact slide fails a test run too instead of only a build:
  expect(args[0]).toBeInstanceOf(Error)
  expect(args[1]).toMatchObject({ object: 'invoice' })

Also fixed the overdue-resume test: AutomationEngine.resume() REPORTS
machine-state problems in its result rather than throwing, so an
unregistered flow never reached the catch under test. It now injects a
throwing resume stub, which is what that catch actually guards.

Verified: turbo dependency-closure build, objectql + service-automation
builds WITH DTS, objectql typecheck, 7 + 25 tests, gate self-test 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny

Copy link
Copy Markdown
Contributor Author

CI 红的根因已修(e4fa4af)—— 顺带记一条本地绿 / CI 红的教训

根因:Logger 的 warn 与 error 签名不对称

packages/spec/src/contracts/logger.ts:

warn (message: string,                meta?: Record< string, any >): void
error(message: string, error?: Error, meta?: Record< string, any >): void

上一个 commit 里每一处提级都是机械的 warn → error 替换,而这个替换会把上下文对象从 meta 槽悄悄挪进 Error 槽。超额属性检查让它成为硬错误 —— 但只在会产出声明的地方,所以它炸在 tsup 的 DTS 构建和仓库级 TypeScript Type Check 上,而所有运行时测试照样全绿(往 Error 槽塞个普通对象,日志打得好好的)。

CI 报的那一条(engine.ts(5356,15))只是这批里第一个被编译到的;plugin.ts 里还有三处同样的错,DTS 在第一个错误处就停了,所以没报出来。四处一并按 (message, Error, meta) 修正:

位置 第二槽
engine.ts syncSchemas() e as Error + { object, tableName, driver }
plugin.ts reload-time schema sync 捕获到的 error
plugin.ts reportSyncFailure() 捕获到的 error + { object, tableName, driver }
plugin.ts 汇总行 undefined —— 整趟 pass 没有哪一个 error 能代表它

教训:--filter … typecheck 覆盖不到 DTS 编译路径

我本地跑的是 pnpm --filter @objectstack/objectql typecheck,它是绿的;红的是 build 里的 DTS 步骤。两者读同一份类型却在不同时机报错,而这一类「对象字面量塞进了错误的参数槽」只有在产出声明时才会被超额属性检查逮到。

结论:改动涉及公开签名 / 日志调用形状时,推送前必须跑带 DTS 的 build,typecheck 不能替代它。 另外这个 worktree 是全新的,依赖包一个 dist 都没有 —— 第一次 tsc 直接报了一屏 Cannot find module '@objectstack/types',那不是我的改动,是 AGENTS.md §9 说的「新 worktree 先刷构建状态」。正确姿势是 pnpm exec turbo run build --filter=<pkg>,靠 ^build 把整个依赖闭包带起来。

测试也补了这个洞

原来的断言把日志尾参拍平成一个 meta 看,所以参数槽错位它照样通过。现在直接断言参数位置:

expect(args[0]).toBeInstanceOf(Error)
expect(args[1]).toMatchObject({ object: 'invoice' })

同一处滑动现在测试就会红,不必等构建。

顺带修了 overdue-resume 那条用例:AutomationEngine.resume() 对「flow 不存在」这类机器状态问题是在结果里报告而不是抛出的,所以我原来用「没注册 flow 的引擎」根本进不到被测的 catch;改成注入一个会抛的 resume stub —— 那才是那个 catch 真正守护的东西。

复验(全部本地跑过)

closure build (turbo ^build)          exit 0
objectql build WITH DTS               exit 0   ← CI 挂的就是这步
service-automation build WITH DTS     exit 0
objectql typecheck                    exit 0
objectql tests                        7 passed
service-automation tests              25 passed (新增 + wait-node + suspended-run-wiring)
check:durability-log-level            self-test 10/10;8 缝隙全部响亮或重抛

(service-automation 没有 typecheck script —— 它在 ratchet 里带 DEBT 条目,其类型覆盖就是上面那条带 DTS 的 build。)


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 3, 2026 01:42
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 4c45be1 Aug 3, 2026
22 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4632-durability-degradation-log-level branch August 3, 2026 01:54
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rsession (objectstack-ai#18859)

Fixes objectstack-ai#18828

Clause-②: no

The claim protocol forbids a second `Claim:` — the rule is this file's
own, in the objectstack-ai#17366 docblock at
`scripts/pm/check-clause2-carriers.mjs:378` — and until this PR nothing
READ it. A thread that carried the forbidden second line was RANKED, not
refused: the governing-claim selector took the newest live claim that
parses a branch and printed the loser as `rejected: 1 … a SUPERSEDED
claim`, clean and green at exit 0, in the register reserved for a
transition the protocol DESIGNED. A writer-side prohibition with no
enforcing reader. `claimRepeats` and the C8 row are that reader.

⚠️ **This is a RESUMED delivery.** Three commits were already on this
branch from a run whose container was killed in its final-gates phase.
Nothing it wrote was rewritten and nothing was redone — every item of
the dispatch contract was re-verified against the tree, and all three
readings were re-taken at the current tip and are dated below. The
verified/fixed ledger is the first section.

## What was VERIFIED and what was FIXED

Every contract item was found already correct and is left
**byte-unchanged**. No code fix was needed; the only commit this run
adds is a merge of `origin/main` (the derivation was answering about a
stale tree — see Gates).

| contract item | finding |
|:---|:---|
| the reading is a VERDICT at `EXIT_PAIR_ADVERSE` (4), never a NOTE at 0
| **verified** — `C8` is pushed in `pairRows` (:4430); `pairNotes` does
not carry it, pinned |
| `claimCarrierSelection` stays a pure function of the rows it is handed
| **verified** — byte-identical to `origin/main` (sha256 of the whole
function `40f59ba86082c358` at both revs) |
| `CLAIM_COMMENT_MARKER` unwidened | **verified** — it is *imported*
from `check-half-states.mjs` and read through `markerMatches`; that file
is not in this diff at all |
| `CLAIM_SELECTION_RULE` and the governing-claim choice unchanged |
**verified** — byte-identical across revs; every hunk but two is a pure
insertion |
| SUPERSEDED / RETRACTED wordings byte-unchanged where they still apply
| **verified** — no hunk touches them; the fixture control below prints
the SUPERSEDED sentence identically at both revs |
| every pin (a)–(i) present, each with a non-vacuity control |
**verified** — 52 `t(...)` cases in the battery block; the ablation
shows all nine directions carried |
| battery registered in the roster with its case count | **verified** —
:792, pinned at 52, and the block declares exactly 52 |
| `SELF_TEST_BATTERY_FLOOR` raised by exactly one | **verified** — 31 →
32 (:806) |
| the live census extended for this shape, population named |
**verified** — the five measured instances replayed from their real
rows, with `objectstack-ai#18559` as the sanctioned-shape control |

`rowAuthor`, `laterOnThread` and `claimRetractions` are byte-identical
across the two revs as well.

## The before-reading — the fixture control through the exported reader

The same two rows (one author, two claims each parsing a branch, no
retraction between) through the same exported `claimCarrierSelection` /
`pairInputRecord` / `pairRows`, at `origin/main` `88aa326deb` and at
this branch:

| | `origin/main` `88aa326deb` | this branch |
|:---|:---|:---|
| `claims` / `live` / `pool` / `rejected` | 2 / 2 / 1 / 1 | 2 / 2 / 1 /
1 — **unmoved** |
| governing claim | `7100000002` | `7100000002` — **unmoved** |
| `rejected[0].reason` | `a SUPERSEDED claim — it is not the newest LIVE
claim that parses a branch …` | byte-identical |
| `claimRepeats` exported | **no — the reader does not exist at that
rev** | yes |
| `claim.repeat` in the record | absent | `1 author(s) holding more than
one LIVE claim comment …` |
| `pairRows` codes | *(none)* | `C8` |
| **verdict** | **exit 0 — nothing refused** | **exit 4
(`EXIT_PAIR_ADVERSE`)** |

That is the defect and the repair in one table: the selector does not
move, and the thread stops reading green.

## The live count — the five cards and the control, re-taken
2026-09-18T00:44:39Z

Read per card through the gate's own `markerMatches` /
`CLAIM_COMMENT_MARKER` and `claimRetractions`, not by eye. ⚠️ Those
threads may have left this state since.

| card | `Claim:` comments (author) | `Clause-②-correction:` |
`Release:` | the OLD reading | C8 today | card state | open delivering
PR |
|:---|:---|:---|:---|:---|:---|:---|:---|
| objectstack-ai#18540 | 2 — `os-support-ai` (5719079496, 5720020876) | 0 | 0 |
SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** |
| objectstack-ai#18677 | 2 — `os-support-ai` (5720104138, 5720190458) | 0 | 0 |
SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** |
| objectstack-ai#18748 | 2 — `os-support-ai` (5720212595, 5720888122) | 0 | 0 |
SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** |
| objectstack-ai#18651 | 2 — `os-support-ai` (5721424769, 5721997887) | 0 | 0 |
SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** |
| objectstack-ai#18778 | 2 — `os-support-ai` (5721425530, 5722028692) | 0 | 0 |
SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** |
| objectstack-ai#18559 *(control)* | 1 — `os-support-ai` (5721425131) | **1**
(5721779100) | 0 | *(nothing rejected)* | silent | closed | **none** |

The card's table reproduces exactly. All six cards are now **closed**,
and the open-PR column is empty for every one of them: the only open PR
cross-referenced from any of these threads is objectstack-ai#18857, whose body's
closing keyword names `objectstack-ai#18780` instead — `prDeliversCard` answers
`false` for all six and `true` for `objectstack-ai#18780` (the control leg), so it is
not paired with any of them. ⛔ The repair of the five is
`os-support-ai`'s; this PR only names them, and posts nothing on those
cards.

## The escalation probe — the triage's p1 condition, MEASURED

The triage marked this p2 because all five instances were one seat
self-superseding, and named the escalation condition it had not run: a
second `Claim:` from a DIFFERENT session on one card, which would be a
silent ownership transfer printed green. I ran it.

**Population, read 2026-09-18T00:47:37Z → 00:48:42Z:** every open card
on both boards this gate reads — **529 open cards in
`objectstack-ai/objectstack`, 413 in `objectstack-ai/objectui` (942
total)**, of which **880** carry at least one comment and were read;
**163** carry at least one claim comment. 9 comment lists sit at the
100-comment cap and are UNJUDGED past it, exactly as objectstack-ai#18683 prescribes.
0 parse failures.

**The answer is not 0 — it is 12.** Twelve open cards carry LIVE
`Claim:` comments from two or more DIFFERENT authors with no retraction
between them:

| repo | card | authors holding live claims |
|:---|:---|:---|
| objectstack | `objectstack-ai#13503` | `claude[bot]` + `baozhoutao` |
| objectstack | `#14026` | `hotlong` + `claude[bot]` |
| objectstack | `objectstack-ai#15811` | `os-bill` + `os-litant` |
| objectstack | `objectstack-ai#17852` | `os-warren` + `os-litant` |
| objectui | `objectstack-ai#4730` | `yinlianghui` + `os-sales` |
| objectui | `objectstack-ai#7070` | `os-warren` + `claude[bot]` |
| objectui | `objectstack-ai#7696` | `os-justin` + `os-tesla` |
| objectui | `objectstack-ai#7804` | `os-tesla` + `os-sam` + `os-justin` |
| objectui | `objectstack-ai#7848` | `claude[bot]` + `baozhoutao` |
| objectui | `objectstack-ai#7924` | `os-warren` + `os-sales` |
| objectui | `objectstack-ai#8115` | `claude[bot]` + `yinlianghui` |
| objectui | `objectstack-ai#9370` | `os-tesla` + `os-justin` |

⚠️ **This PR is deliberately SILENT on all twelve** — and that silence
is pinned, per direction (b). Refusing an ownership transfer between
sessions is not this card's to do: it is a different state, it would
need its own remedy sentence, and a row that answered both would make
one sentence out of two states. This is reported here and in the
dispatch report so the seat can file it; ⛔ it is not folded in.

## Who the new exit 4 meets before it lands

The seat needs this before landing, so I swept it rather than assuming.
Two facts:

1. **No CI job turns red.** `check:pm-clause2-carriers` — the only
wiring, `.github/workflows/lint.yml:1140` — runs `--self-test` and
nothing else. No workflow runs `--pair` or a sweep, so landing this
changes no required context. The exit 4 appears only when a seat runs
`--pair` or a sweep by hand.
2. **On the objectstack board: nobody.** Of 32 open PRs in objectstack,
**none** delivers a card that would newly earn a C8. Twenty open cards
across both boards would earn the row (report-only, listed in the
dispatch report), but only one is reachable through an open PR, and it
is in the sibling repo: **`objectstack-ai/objectui#9584`** (open, not
draft; its closing keyword names `objectui#9499`), which delivers a card
carrying two live claims by `os-try-charles` (5663366106 on 2026-09-14,
5690579598 on 2026-09-16). That pair answers exit 4 at its next
`--pair`. `DEFAULT_SWEEP_REPO` is `objectstack-ai/objectstack`, so
objectui is only ever read when passed explicitly.

⛔ Nothing was posted on objectstack-ai#9499, objectstack-ai#9584 or any of the twelve.

## The reading, and WHERE it is computed

`claimRepeats` (:1898) is a **sibling pure reader beside
`claimRetractions`, built on it** — the same map decides membership here
and for governance, so the pool and this row cannot describe two
different retractions. It names every author holding more than one LIVE
claim comment, orders them by the file's one recency rule
(`laterOnThread`, to ORDER the record, never to pick a winner), and
resolves no state, no row and no exit code. `c8SecondClaimSameSeat`
(:4380) renders the verdict; `pairRows` (:4430) pushes it as row `C8`;
`pairInputRecord` adds `claim.repeat` (:5876, declared in
`INPUT_RECORD_PAIR_FIELDS` at :5641) as the READING — one derivation
feeding both, so the record and the verdict cannot disagree about how
many claims a seat holds or which they are.

**MEMBERSHIP first, and that is what makes the state repairable.** The
state is read over LIVE claims only. A re-claim after a `Release:` is
the protocol working and reads exactly as it did before. And a seat that
already wrote a second claim has an act that clears the row: `Release:`
what it holds, then one fresh `Claim:`. A rule written over the writing
*moment* instead ("no retraction strictly BETWEEN the two lines") would
have been unrepairable by construction — nothing un-writes a comment —
so the row would have been a permanent red with a remedy nobody could
execute.

### The four axes

- **实际业务需求** — measured, not assumed. Five live instances on the
objectstack board at filing, re-confirmed today, every one of them read
green before this row; plus 20 open cards across both boards that carry
the state now. The first signal in five occurrences came from a dev
reading a docblock, not from any instrument. This is a real shape
occurring repeatedly, not a speculative surface.
- **项目长远合理性** — contract-first, and no workaround. The rule already
existed in writing at :378; this adds the reader that enforces it, in
the same file, over the same thread, through the same membership
derivation governance uses. No new exit code was minted, no second
selector, no second reader of the marker. The prohibition and its reader
now live one screen apart.
- **防 AI 写代码犯错** — this is the axis that decides the exit. A second
`Claim:` re-enters the pool as the newest claim and becomes what every
downstream reader is handed — the property the correction key was
deliberately designed NOT to have. Rendering that as a NOTE at exit 0 is
precisely the tolerant-consumer shape this repo refuses: an adverse fact
printed green is how a batch of identical mistakes stays invisible.
Declaring the prohibition and not enforcing it is the "声明而未兑现" gap; the
repair is to enforce it loudly, at `EXIT_PAIR_ADVERSE`. The row also ⛔
never prescribes WHICH repair — choosing between a correction and a
release would be choosing whether the card is being re-taken, which is
the seat's judgement, so it names both and writes nothing.
- **创业阶段不扩散需求** — the surface added is one file, one pure reader, one
row, one record field. It refuses exactly one shape the protocol already
forbade in writing and re-blocks no legal workflow: a card claimed once
reads as it always did, a re-claim after a `Release:` reads as it always
did, a `Clause-②-correction:` is not a claim and never was. The
cross-seat question, which is a genuine second capability, is explicitly
NOT taken here.

## The pins — per direction, each with a non-vacuity control

| | direction | reading |
|:---|:---|:---|
| (a) | same author, two claims, no retraction | **named, exit 4**; the
row carries both ids, the author and both repairs |
| (b) | DIFFERENT authors | supersession as today, exit 0 — ⛔ not this
state |
| (c) | same author after a `Release:` **or** the id-naming retraction |
RETRACTED as today, exit 0; the `⛔ NOT superseded` wording
byte-unchanged |
| (d) | a `Clause-②-correction:` as the later row | silent; the objectstack-ai#17366
exit still reads the declaration off it |
| (e) | a DECORATED second claim (bold, backticked) | counted through
`markerMatches` exactly as a bare one; the raw constant refuses both, so
the counting is the sibling's ONE reading |
| (f) | a second claim whose `Branch:` parses to zero branches | still
named — the prohibition is on the WRITING, not the parse |
| (g) | three claims by one seat | **ONE** refusal naming all three, not
two |
| (h) | an unattributable row (`rowAuthor` null) | fail closed, as
`claimRetractions` does — and `null` never groups with `null` |
| (i) | a later same-author comment that QUOTES or DISCUSSES the word |
silent — the marker is read at line start |

Direction (i) has a control in the wild on this very card: the triage
comment 5722477144 contains the word `Claim:` mid-line, and
`markerMatches` refuses it — card objectstack-ai#18828 reads one claim comment, so
`--pair` on this PR is silent.

**Roster line** (:792): `'objectstack-ai#18828: a SECOND \`Claim:\` by ONE seat — the
writer-side prohibition, finally READ': 52` — and the battery block
declares exactly 52 `t(...)` cases.
**Floor** (:806): `SELF_TEST_BATTERY_FLOOR` **31 → 32**, raised by
exactly one.

## The ablation

Run from the **committed** fix, twice, each leg proving its mutation
landed on disk before the reading was taken and proving its restore by
an empty `git diff HEAD` and by blob hash — never by an editing
command's exit code. `HEAD` blob
`3a270ef2eb5f33780e04e4732714f8e88d74a017`.

| leg | mutation | mutated blob | result |
|:---|:---|:---|:---|
| baseline | none | `3a270ef2eb…` | **941 cases pass, exit 0** |
| **A** — the repeat detection neutered (a group is never reported) |
`72115d2796ead200f93aa855c8ba5820a83f5f8f` | | **23 of 941 failed**,
exit 1 |
| **B** — the SAME-AUTHOR check removed (the author no longer decides
the grouping) | `40cfadd4f5740f34210675ceb998fb2977823769` | | **3 of
941 failed**, exit 1 |

Both legs restored: `git diff HEAD` empty, blob back to `3a270ef2eb…`.

**Total case count is 941 in all three runs** — the rest of the
self-test is byte-identical in its case count, and in both legs **0 of
the failures fall outside the objectstack-ai#18828 battery**.

Leg A is the interesting one, because it shows the per-direction
controls doing their job. Five of the nine directions assert SILENCE and
therefore *cannot* go red when the detection is removed — their
non-vacuity controls go red instead. All nine directions are carried:

- pin itself red: **(a) (e) (f) (g)**
- carried by its control: **(b) (c) (d) (h) (i)** — "make those two
authors ONE", "drop the retraction", "write that same correction as a
SECOND `Claim:`", "give that same row a login", "move that same word to
the OPENING of a line"

Leg B is the narrower, sharper one: removing only the author test reds
**3** cases, and pin (b) is among them. That is the pin which
distinguishes this card from the cross-seat question — proof the author
test is load-bearing and that (b) is not vacuous.

**Self-test count: 889 before → 941 after** (+52, exactly the registered
battery). The 889 was measured by running `--self-test` in a detached
worktree at `origin/main` `88aa326deb`.

## Gates

Derived from the worktree with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no hand-fed path list).
⚠️ The first derivation printed **STALE TREE** — the branch was 2
commits behind `origin/main` and 8 files the derivation reads had
changed — so `origin/main` was merged in first and the list re-derived
on the merged tree at `7424cf3f44`; the `--repo` assertion holds against
this checkout's `origin`.

**34 derived, 34 run, all exit 0.** Each exit code captured
redirect-then-`$?`, never across a pipe.

```
node scripts/check-adr-0087-registration.mjs --base origin/main    :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test           :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main       :: exit 0
node scripts/check-changeset-no-major.mjs --self-test              :: exit 0
node scripts/check-ci-filter-parity.mjs                            :: exit 0
node scripts/check-closing-keyword-parity.mjs                      :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test          :: exit 0
node scripts/check-comment-mask-corpus.mjs                         :: exit 0
node scripts/check-declaration-mirrors.mjs                         :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test             :: exit 0
node scripts/check-scripts-symbol-anchors.mjs                      :: exit 0
node scripts/check-scripts-symbol-anchors.mjs --self-test          :: exit 0
node scripts/check-self-test-wired.mjs                             :: exit 0
node scripts/check-self-test-wired.mjs --self-test                 :: exit 0
node scripts/check-self-test-workflow-commands.mjs                 :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test     :: exit 0
node scripts/check-whole-set-label-write.mjs                       :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test           :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test                 :: exit 0
pnpm check:agent-test-spelling                                     :: exit 0
pnpm check:bash32-floor                                            :: exit 0
pnpm check:changeset-gate-self-tests                               :: exit 0
pnpm check:cli-command-ids                                         :: exit 0
pnpm check:cross-package-test-inputs                               :: exit 0
pnpm check:driver-memory-census                                    :: exit 0
pnpm check:entry-guard                                             :: exit 0
pnpm check:nul-bytes                                               :: exit 0
pnpm check:parse-guard                                             :: exit 0
pnpm check:pm-clause2-carriers                                     :: exit 0
pnpm check:pm-dispatch-gates                                       :: exit 0
pnpm check:pnpm-filter-targets                                     :: exit 0
pnpm check:ratchet-remedy-authority                                :: exit 0
pnpm check:refd-timer-probe                                        :: exit 0
pnpm check:watch-hint-literal                                      :: exit 0
```

Reconciled with `--ran`, exit codes included: **34 derived, 34 run, 0
NOT-MEASURED, 0 UNRUN** — "a DERIVED zero — all 34 recorded an exit code
and none of them is 3".

Repo-wide `pnpm lint` (`eslint . --no-inline-config`): **exit 0**. The
heavy run took a ticket through `scripts/pm/os-verify-lock.sh` (slot
`issue-18828-dev`), queued behind the seat's own `dispatch-gates.mjs
--self-test`.

`node scripts/pm/check-clause2-carriers.mjs --pair` on this PR is
reported in the dispatch report — this card carries one claim comment,
so the row is silent on it.

`skip-changeset`: `scripts/pm/**` publishes nothing from any released
package — the whole diff is one non-published script.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则

2 participants