启动日志里剩下的 4 条 showcase 自身告警,根因都是同一类:声明在
authoring 期被接受,运行时却静默地什么也不做,只留一行 boot warning。
1. 夜间 job 从来没跑过。`showcase_health_sweep` 声明
`handler: 'sweepProjectHealth'`,而全仓没有任何同名函数,AppPlugin
每次启动都跳过它。按「每种能力至少出现一次」补上真实实现:用预算
燃尽率与任务交付进度的落差重算 `showcase_project.health`。job handler
由 job service 以 `{ jobId, data }` 调用、拿不到数据引擎(flow function
默认是纯函数),所以引擎句柄在 `onEnable` 捕获,并以 `effect: 'writes'`
声明——这样这次运行报告的是「无法统计这些写入」,而不是谎称没写过。
2. `showcase.export_data` 没有 owning package,永远不会写进 `sys_capability`,
于是 `OpsPermissionSet` 授予了一个不会存在的权限。补上 ADR-0086 D3 的
`packageId`(spec 自己给出的作者声明入口)。归属判定的结论是平台侧缺陷:
app 声明的 capability 拿不到 registry 的 `_packageId` 戳,且被拒绝的声明
仍会抑制向后兼容的派生——已拆成 #4967,没有在 showcase 里绕过去。
3. 两处 `try_catch` 仍写 `retryDelayMs`,只靠 `retry-policy-converged`
在 load 时改写才能工作,而该 conversion 在 protocol 18 退役。改为正名
`backoffMs`。`maxRetryDelayMs` 不在这次改名范围内,是 `RetryPolicySchema`
的正式键,保留。
4. 10 条 `showcase_task.cover` 是内联 `data:image/svg+xml` URI,而
`Field.image()` 的存储形态是 opaque `sys_file` id。由于一次启动不能证明
它自己刚刚违反的契约(#4769),全新库因此永远无法 attest
`adr-0104-file-references`——参考应用的每一次全新安装,闸门从第一天起就
开着。移除这些值;`cover` 字段与 gallery 绑定保留,由上传真实封面来填充。
种子不能诚实地铸造合法值:没有 sys_file 行支撑的合形状 id 会被 ADR-0104
自己的对账判为 `unowned_reference`(阻断级),而且「编一个看起来对的 id」
正是参考应用最不该教的模式。
验证(全新库冷启,`--fresh`):启动告警从 8 条降到 2 条,4 条症状全部消失,
且 `[migration] new datastore attested at creation: adr-0104-file-references,
adr-0104-value-shapes`、`[AppPlugin] Scheduled background jobs {count:1,
failed:0}`、`[security] declared capabilities seeded {seeded:1}` 均出现。
剩余 2 条与本单无关,已另行立案(#4968)。
新增 `test/inert-wirings.test.ts` 逐条设卡。其中 retry 一条刻意读源码文本
而非解析后的 stack:conversion 在 `defineStack` 期间就已经把退役拼写改写掉,
任何对解析结果的断言都会空转通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX
Fixes #4774
Fixes #4888
Fixes #4891
启动日志里剩下的这 4 条是 showcase 自己的问题。它们的根因是同一类:声明在 authoring 期被接受,运行时却静默地什么也不做,只留一行 boot warning。参考应用最不该做的就是这个——它是给人抄的。
STALE-PREMISE 复核
issue 是 08-01/08-02 的观察,而 #4794 今天改了首启 attestation 行为。所以先在当前
origin/main(6bc93dc)上原样冷启复现了一遍,4 条全部仍在:第 4 条的现场确实按 #4794 预测变了:第二次启动的 10 条 ERROR 没有了,取而代之的是首启无法 attest。issue 正文描述的 value-shape 行仍在,结论不变。
1. 夜间 job 从来没跑过 → 补实现
showcase_health_sweep声明handler: 'sweepProjectHealth',而全仓没有任何同名函数。按「每种能力至少出现一次」补了真实实现,而不是删掉声明。healthFor()用预算燃尽率与已交付进度的落差判定:burn = spent/budget,done = mean(task.progress)/100,drift = burn - done;超预算或 drift ≥ 0.30 判红,≥ 0.15 判黄,否则绿。只扫active/on_hold(planned还没得烧,completed/cancelled是已定的事实),且只写真正变了的行。为什么引擎句柄要在
onEnable里捕获——这点值得说清楚,因为它是平台契约决定的,不是偷懒:job handler 和script节点走同一个defineStack({ functions })注册表(collectBundleFunctions),而 job service 调用它时给的上下文是IJobService的JobHandler形状{ jobId, data },里面没有数据引擎——flow function 默认是纯函数,返回值由下游声明式节点落库(#4343 / #4396)。后台 job 恰恰是这个契约没覆盖的情形:它下游没有任何节点会替它落库。所以它在onEnable(app 唯一被交给活引擎的地方)拿句柄,并在functions里以effect: 'writes'声明。该声明不授予任何东西,它只是让这次运行报告「无法统计这些写入」,而不是静默地谎称没写过。2.
showcase.export_data永不 materialize → 先判归属,再补 app 侧按 PM 指示先把归属查到底。结论是平台侧缺陷,已拆出 #4967,没有在 showcase 里绕过去:
bootstrapDeclaredCapabilities取cap._packageId ?? cap.packageId。_packageId这一半对 app 声明的 capability 永远不可能命中:AppPlugin 走metadata.registerInMemory(...)(不盖戳),而盖戳的那条路SchemaRegistry.registerItem(..., packageId)由ObjectQLEngine.use()的metadataArrayKeys驱动,那个列表里有permissions却没有capabilities。所以 permission set 能 materialize、capability 不能,而 spec 里自称「fallback」的packageId实际上是必填的,却没有任何地方这么说。declaredNames在 upsert 决定任何事情之前就无条件 push 了,而bootstrapSystemCapabilities拿它来跳过派生。于是一个被拒绝的声明照样抑制了向后兼容的派生——加上这条显式声明,比不声明还糟:本来会存在的占位符没有了,也没有东西补上,capability 在任何一行里都不存在。showcase 侧能修的修掉:补上 ADR-0086 D3 的
packageId。这不是绕过——它是 spec 自己给出的作者声明入口(packageId: '[ADR-0086 D3] Owning package id (author-declared fallback…)'),顺带把这个字段也演示了;将来平台盖上_packageId后戳优先,两者一致。关于 #4632「是否该更响亮」:该改动落在平台包,按硬约束没有在本 PR 做,评估记在 #4967 第 3 节(倾向不是抬级别而是改措辞——现在的 warn 报的是 capability 名,没告诉读者后果,也没点名是哪个 permission set 在授予它)。
3.
retryDelayMs→backoffMs两处
try_catch改为正名写法,不再依赖 load 时的retry-policy-converged(该 conversion 在 protocol 18 退役)。顺带确认了 issue 里问的那点:
maxRetryDelayMs不在这次改名范围内。它是RetryPolicySchema的正式键(单次退避延迟的上限),#4661 的收敛只把 automation 侧的retryDelayMs并到backoffMs。所以保留,并加了一条反向断言防止有人「为了对称」把它一起删掉。4. 非法 cover 种子值 → 移除,而不是编一个合形状的 id
Field.image()的存储形态是 opaquesys_fileid;seed 写的是内联data:image/svg+xmlURI。代价不是噪音:由于一次启动不能证明它自己刚刚违反的契约(#4769 /attestFreshDatastore),全新库因此永远无法 attestadr-0104-file-references——参考应用的每一次全新安装,闸门从第一天起就开着。按 PM 裁定,seed 不需要建真正的 sys_file 记录。那么「改成合形状」只剩两条路,选了后者:
os migrate files-to-references)判为unowned_reference——阻断级,比原来的 warn-first 更糟。而且「编一个看起来对的 id」正是参考应用最不该教的模式。cover字段与gallery.coverField绑定保留(能力仍被演示),由上传真实封面来填充——那才是受管文件该有的来历。Gallery 在没有任何记录带封面时会收起封面区,所以卡片是干净的紧凑空态,不是破图。顺带发现:即便给它一个完全正确的值,objectui 的 gallery 也渲染不出来——
ObjectGallery.tsx把字段值当 URL 字符串读,而 ADR-0104 的读路径会把 id 就地展开成{ id, url, … }对象。已开 objectstack-ai/objectui#3317。验证
全新库冷启(
objectstack dev --fresh),启动告警 8 → 2,4 条症状全部消失:剩下 2 条与本单无关,改动前后完全一致,已另行立案(#4968 是其中的假告警)。
--log-level info下三条正向证据都在:pnpm typecheck/pnpm test(11 files, 84 tests)/pnpm validate/eslint全绿;check:i18n-coverage、check:doc-authoring、check:release-notes、check:adr-anchors亦绿。测试
新增
test/inert-wirings.test.ts(24 条)。四条守卫都验证过能在修复前失败,不是空转:handler必须是functions的键、必须可调用(这就是 AppPlugin 做的那次查表);健康判定规则的单元测试 + 一次对假引擎的完整 sweep(证明只扫在办项目、只写变化行)。defineStack期间就已经把退役拼写改写掉了,所以任何对解析结果的断言都会空转通过——真正要变、也真正会在 protocol 18 停止加载的,是作者写下的东西。(这一条最初就是照解析结果写的,跑负向验证时才发现它永远为真。)扫描时剥掉注释,好让文档可以自由地提到这个退役键。data:image/或 picsum/placehold 链接。范围
只动
examples/app-showcase/**与一个 changeset。packages/**只读;packages/spec/**、metadata-protocol/src/protocol.ts、content/docs/releases/零改动。本次拆出的平台/UI issue:#4967、#4968、objectstack-ai/objectui#3317。
🤖 Generated with Claude Code
https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX
Generated by Claude Code