Skip to content

fix(example-showcase): 让 4 条「声明了但运行时从不兑现」的接线真正生效 (#4774) - #4975

Merged
xuyushun441-sys merged 3 commits into
mainfrom
claude/issue-4774-showcase-inert-wirings
Aug 3, 2026
Merged

fix(example-showcase): 让 4 条「声明了但运行时从不兑现」的接线真正生效 (#4774)#4975
xuyushun441-sys merged 3 commits into
mainfrom
claude/issue-4774-showcase-inert-wirings

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #4774
Fixes #4888
Fixes #4891

启动日志里剩下的这 4 条是 showcase 自己的问题。它们的根因是同一类:声明在 authoring 期被接受,运行时却静默地什么也不做,只留一行 boot warning。参考应用最不该做的就是这个——它是给人抄的。

STALE-PREMISE 复核

issue 是 08-01/08-02 的观察,而 #4794 今天改了首启 attestation 行为。所以先在当前 origin/main(6bc93dc)上原样冷启复现了一遍,4 条全部仍在:

⚠ Boot diagnostics — 8 warnings logged during startup:
  WARN [migration] NOT attesting 'adr-0104-file-references' on this new datastore: this boot
       already wrote 10 value(s) that the migration's own contract rejects
       (showcase_task.cover: Expected an opaque sys_file id). …                        (×2)
  WARN [security] capability has no owning package — not materialized
       {"name":"showcase.export_data"}                                                  (×3)
  WARN [AppPlugin] job handler not found in bundle.functions — skipping
       {"appId":"com.example.showcase","job":"showcase_health_sweep","handler":"sweepProjectHealth"}
defineStack: flows[24].nodes[1].config.retry.backoffMs: 'retryDelayMs' → 'backoffMs' …   (×2)

第 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 调用它时给的上下文是 IJobServiceJobHandler 形状 { jobId, data },里面没有数据引擎——flow function 默认是纯函数,返回值由下游声明式节点落库(#4343 / #4396)。后台 job 恰恰是这个契约没覆盖的情形:它下游没有任何节点会替它落库。所以它在 onEnable(app 唯一被交给活引擎的地方)拿句柄,并在 functions 里以 effect: 'writes' 声明。该声明不授予任何东西,它只是让这次运行报告「无法统计这些写入」,而不是静默地谎称没写过。

2. showcase.export_data 永不 materialize → 先判归属,再补 app 侧

按 PM 指示先把归属查到底。结论是平台侧缺陷,已拆出 #4967,没有在 showcase 里绕过去:

  • bootstrapDeclaredCapabilitiescap._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. retryDelayMsbackoffMs

两处 try_catch 改为正名写法,不再依赖 load 时的 retry-policy-converged(该 conversion 在 protocol 18 退役)。

顺带确认了 issue 里问的那点:maxRetryDelayMs 不在这次改名范围内。它是 RetryPolicySchema 的正式键(单次退避延迟的上限),#4661 的收敛只把 automation 侧的 retryDelayMs 并到 backoffMs。所以保留,并加了一条反向断言防止有人「为了对称」把它一起删掉。

4. 非法 cover 种子值 → 移除,而不是编一个合形状的 id

Field.image() 的存储形态是 opaque sys_file id;seed 写的是内联 data:image/svg+xml URI。代价不是噪音:由于一次启动不能证明它自己刚刚违反的契约(#4769 / attestFreshDatastore),全新库因此永远无法 attest adr-0104-file-references——参考应用的每一次全新安装,闸门从第一天起就开着。

按 PM 裁定,seed 不需要建真正的 sys_file 记录。那么「改成合形状」只剩两条路,选了后者:

  • ❌ 编一个合形状但没有 sys_file 行支撑的 id:会被 ADR-0104 自己的对账(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 条症状全部消失:

⚠ Boot diagnostics — 2 warnings logged during startup:
    WARN Driver already registered, skipping {"driverName":"com.objectstack.driver.sql"}
    WARN StorageServicePlugin: storage adapter swapped (LocalStorageAdapter → LocalStorageAdapter). …

剩下 2 条与本单无关,改动前后完全一致,已另行立案(#4968 是其中的假告警)。

--log-level info 下三条正向证据都在:

INFO [migration] new datastore attested at creation: adr-0104-file-references, adr-0104-value-shapes
     — no legacy data can exist here, so the gated behaviour is enabled from birth
INFO [AppPlugin] Scheduled background jobs {"appId":"com.example.showcase","count":1,"failed":0}
INFO [security] declared capabilities seeded into sys_capability (ADR-0066 D1)
     {"seeded":1,…,"declaredNames":["showcase.export_data"],"total":1}

pnpm typecheck / pnpm test(11 files, 84 tests)/ pnpm validate / eslint 全绿;check:i18n-coveragecheck:doc-authoringcheck:release-notescheck:adr-anchors 亦绿。

测试

新增 test/inert-wirings.test.ts(24 条)。四条守卫都验证过能在修复前失败,不是空转:

  • job:每个声明的 job,其 handler 必须是 functions 的键、必须可调用(这就是 AppPlugin 做的那次查表);健康判定规则的单元测试 + 一次对假引擎的完整 sweep(证明只扫在办项目、只写变化行)。
  • capability:每个声明必须解析出 owning package;任何 permission set 都不得授予未声明的 capability。
  • retry:刻意读源码文本,不读解析后的 stack。conversion 在 defineStack 期间就已经把退役拼写改写掉了,所以任何对解析结果的断言都会空转通过——真正要变、也真正会在 protocol 18 停止加载的,是作者写下的东西。(这一条最初就是照解析结果写的,跑负向验证时才发现它永远为真。)扫描时剥掉注释,好让文档可以自由地提到这个退役键。
  • seed:任何文件类字段的种子值都必须通过它自己的 ADR-0104 存储形态;外加一条更易读的:seed 里不得出现内联 data:image/ 或 picsum/placehold 链接。

范围

只动 examples/app-showcase/** 与一个 changeset。packages/** 只读;packages/spec/**metadata-protocol/src/protocol.tscontent/docs/releases/ 零改动。

本次拆出的平台/UI issue:#4967#4968objectstack-ai/objectui#3317

🤖 Generated with Claude Code

https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX


Generated by Claude Code

启动日志里剩下的 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
@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 5:54pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Aug 3, 2026
claude added 2 commits August 3, 2026 17:35
合并 main 后 `pnpm build` 红:#4925 之外的既有缺陷被这次改动踩到。
`{ handler, effect: 'writes' }` 这个声明形态无法通过 `objectstack build`
—— CLI 把它降级成 `{ handler: 'sweepProjectHealth', effect: 'writes' }`
(lowerCallables 自己的测试就是这么断言的),而 `FlowFunctionEntrySchema`
的三个联合分支只接受:裸 callable、handler 是 callable 的声明、裸字符串
ref,唯独没有「handler 已被降级为字符串的声明」。于是从源码加载全绿
(dev / validate / vitest),只有构建产物这条路失败——正是 #4343 为裸形态
修掉的那个不对称,晚了一个形态。已拆 #4976。

`packages/spec` 本轮零改动,所以 showcase 改用裸函数形态,并在原处留下
不要改回去的理由 + issue 链接。运行时没有损失:`effect` 只有 script 节点
的 `unmeasuredEffect` 一个消费者,job 这条路的 `collectBundleFunctions`
本来就只保留 handler。

守卫相应调整为「functions 条目必须是构建能承载的形态」,并注明 #4976 落地
后应当删除该守卫而不是绕过它。

复核(合并 main 后重跑):`pnpm build` 全仓 71/71 绿,showcase 构建产物
bundle 到 2 个 handler;typecheck / 84 tests / validate 绿;全新库冷启仍是
2 条无关告警,且 `[migration] new datastore attested at creation` 与
`[value-shape] this deployment has verified …— enforced` 均出现。

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

Copy link
Copy Markdown
Contributor Author

追记:合并 main 后踩到 #4976,effect: 'writes' 暂时撤回

开 PR 后 main 又前进了(含 #4925「flow-node config 拒绝未知键」),按 AGENTS.md §10「clean merge ≠ working merge」把 main 并进来重跑——pnpm build 红:

functions:
  ✗ functions
    invalid_union: Invalid input

不是被 #4925 打到,是本 PR 自己踩到一个既有缺陷。 { handler, effect: 'writes' } 这个声明形态无法通过 objectstack build:CLI 先把它降级成 { handler: 'sweepProjectHealth', effect: 'writes' }(lowerCallables 自己的测试就是这么断言的),而 FlowFunctionEntrySchema 的三个联合分支是「裸 callable / handler 是 callable 的声明 / 裸字符串 ref」,唯独没有「handler 已被降级为字符串的声明」。直接验证:

lowered declared entry parses? false   // { handler: 'sweepProjectHealth', effect: 'writes' }
lowered BARE entry parses?    true     // 'sweepProjectHealth'

所以从源码加载全绿(dev / validate / vitest),只有构建产物这条路失败——正是 #4343 为裸形态修掉的那个不对称,晚了一个形态:#4396 教会了 lowering,没同步扩联合。已拆 #4976(含一条建议:补一个跨越该边界的 pin test——现在 lower-callables.test.ts 只断言产出形状,spec 侧只解析作者形态,没有测试同时走这两步)。

packages/spec 本轮零改动,所以 showcase 改用裸函数形态,并在原处留了「不要改回去」的理由与链接。运行时没有损失:effect 只有 script 节点的 unmeasuredEffect 一个消费者,而 job 这条路的 collectBundleFunctions 本来就只保留 handler、把 effect 丢掉——也就是说,给一个只作 job handler 的函数标 effect 今天本身就是一处「声明了没人读」。守卫相应改成「functions 条目必须是构建能承载的形态」,并注明 #4976 落地后应当删除该守卫,而不是绕过它。

合并后重跑:全仓 pnpm build 71/71 绿(showcase 产物 bundle 到 2 个 handler),typecheck / 84 tests / validate 绿。全新库冷启仍是 2 条无关告警,并且这次拿到了更强的证据——闸门不只 attest,而是从出生就开始 enforce:

INFO [migration] new datastore attested at creation: adr-0104-file-references, adr-0104-value-shapes
INFO [value-shape] this deployment has verified the value-shape scan — reference and structured-JSON
     value shapes are enforced (ADR-0104 / #3438)
INFO [value-shape] this deployment has verified the file-as-reference migration — media value shapes
     are enforced and released field files may be collected (ADR-0104 / #3617)
INFO [AppPlugin] Scheduled background jobs {"appId":"com.example.showcase","count":1,"failed":0}
INFO [security] declared capabilities seeded into sys_capability {"seeded":1,…}

defineStack 的 conversion 提示为 0 条。

本次拆出的 issue 更新为:#4967#4968#4976objectstack-ai/objectui#3317


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment