…he 'system' sentinel (#4556)
`recorded_by` is declared `Field.lookup('sys_user', { readonly: true })` — a
foreign key — while the write path filled it with `actor ?? 'system'`. Every
actor-less metadata write therefore stored the STRING `'system'` in a column
whose declared type says "an id of a sys_user row", and no such row exists.
Declared != actual, at the data layer.
Per the maintainer's 2026-08-02 ruling, the fix is on the WRITE path, not the
declaration: `recorded_by` stays a lookup, an actor-less write stores NULL, and
NULL means "system-initiated (boot sync, migration, scheduled job)". No magic
system-user account, no actor-kind companion column.
- `sys-metadata-history.object.ts`: `recorded_by` carries a `description`
stating the NULL semantics, so the declaration says what the column holds.
- `metadata-core/types.ts`: `PutOptions.actor` / `DeleteOptions.actor` widen to
`string | null` and stay REQUIRED, so every call site must say which of the
two it is; `MetadataEvent.actor` and `MetadataItem.authoredBy` become
nullable.
- `sys-metadata-repository.ts`: both history writes store `opts.actor ?? null`;
the three read paths surface `null` instead of inventing `'unknown'`;
`close()`'s synthetic drain event carries no actor.
- `protocol.ts`: the five `?? 'system'` sites that flow into `recorded_by`
(save / publish / revert-commit / rollback / delete) pass `null`. The three
that do NOT are left alone: `sys_metadata_audit.actor` is a `text` column
whose declaration already admits `'system'`, and `PublishMaterializer.actor`
is a plugin callback argument that reaches no lookup column.
- New `os migrate recorded-by` rewrites stored `'system'` to NULL through the
ADR-0119 D2 migration journal (chunk-atomic, resumable, dry run by default,
idempotent on re-run). The plan itself lives with the code that wrote the
sentinel, in `metadata-protocol/src/migrations/`.
Tests: repository-level NULL round-trip, the plan under the real journal runner
(idempotence, chunking, compensation, transaction binding), and an end-to-end
protocol suite against a real ObjectQL engine where `recorded_by` is declared
as the real readonly lookup — which also pins that create/publish/delete
authoring still passes the #4441 integrity check.
Deliberately NOT touched: #4441's `readonly` narrowing in `objectql/engine.ts`
and #4551's audit skip. See the PR description.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Fixes #4556
按维护者 2026-08-02 的裁决(issue 评论 5156972029)落地:改写入路径,不放宽声明类型的语义。
recorded_by仍是lookup('sys_user'),无 actor 的写入存NULL,不再存哨兵字符串'system'。⛔ 不引入「系统用户」魔法账号,⛔ 不加 actor-kind 字段。为什么这是「改写入面」而不是「改声明」
recorded_by声明为外键,写入却是actor ?? 'system'—— 落库的是字符串'system',不是任何sys_user行的 id(SystemUserId.SYSTEM = 'usr_system'在新运行时下也不再自动供给,写成它同样解析不到)。任何按声明来读这个字段的消费者(expand、报表的 owner 列、审计时间线的「谁改的」)都拿到一个解析不出来的 id。NULL是「无链接」的标准表达,也正是这一列deleteBehavior: 'set_null'已有的含义。落地清单
metadata-core/src/objects/sys-metadata-history.object.tsrecorded_by加description,写明「NULL = 系统发起(boot 同步 / 迁移 / 定时任务)」;声明与实际值就此一致metadata-core/src/types.tsPutOptions.actor/DeleteOptions.actor放宽为string | null且保持 required;MetadataEvent.actor、MetadataItem.authoredBy变 nullablemetadata-protocol/src/sys-metadata-repository.tsopts.actor ?? null;三处读路径不再把缺失渲染成'unknown';close()的合成排空事件不再伪造 actormetadata-protocol/src/protocol.ts?? 'system'候选里,只改真正流向recorded_by的 5 处metadata-protocol/src/migrations/recorded-by-sentinel.ts(新)'system'→NULL的 ADR-0119 D2 migration plancli/src/commands/migrate/recorded-by.ts(新)os migrate recorded-by,默认 dry run,--apply才写platform-objects/src/apps/translations/*.objects.generated.tsdescription进 i18n schema 成为该字段的help,四个 locale bundle 随之再生(见「i18n 再生」一节)actor保持 required 而不是变 optional,是刻意的。 变 optional 的话,一个忘记传 actor 的调用点会静默地变成「系统写入」;保持 required 但可为null,编译器强迫每个调用点明确表态是哪一种。这条对 AI 生成的代码尤其要紧 —— 少写一个字段不会再变成一个假外键。protocol.ts那 8 处:逐处判断的结论PM 抓出的是按
?? 'system'形状匹配的候选集,不是全都写recorded_by。改动 5 处,保留 3 处:repo.put/promoteDraft/restoreVersion/delete→recorded_by):saveMetaItem、publishMetaItem、revertCommit、rollbackMetaItem、deleteMetaItem。sys_metadata_audit的两处(读 + 写):那一列是Field.text,其description原文就是 "Acting principal — user id, system id, or 'system'"。声明与实际一致,'system'在那里是诚实的,改它反而制造新的不一致。PublishMaterializer.actor:那是插件回调的入参(args.actor ?? 'system'),不落任何 lookup 列;目前唯一注册的 materializer(plugin-security)根本没读它。改错比漏改更糟,所以留给它自己的单子。存量迁移:走 migration journal,不手写 boot backfill
os migrate recorded-by通过 ADR-0119 D2 的runMigrationJournal(#4617 / PR #4668)执行,因此天然拿到 chunk 级事务、chunk_done写在事务内、崩溃后os migrate resume可续。plan 本体放在写出哨兵的那个包里(metadata-protocol),CLI 只是操作者意图的入口 —— 与「boot 负责发现、CLI 负责动作」的既有分工一致。os migrate的子命令不得意外改库)。load()只选仍带哨兵的行,所以第二次--apply选到 0 行、提交 0 个 chunk、什么都不改 —— 有测试钉住。actor ?? 'system'写入,两种写法都表示「无 actor」,只有NULL在声明类型里表达得出来。changeset 里写明了这一点。ctx.context同时携带isSystem—— 这正是 ObjectQL 允许写readonly列的条件。漏掉它的话迁移会静默地什么都不做却报成功,所以专门有一条测试断言每次update的 context 是{ __tx: true, isSystem: true }。测试
metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts—— 写入落null(不是undefined、不是字符串),三条读路径返回null而不是'unknown'。metadata-protocol/src/migrations/recorded-by-sentinel.test.ts—— 在真实 runner 上跑:只改哨兵行、真 actor 不动、journal 事件序列、重跑幂等、分 chunk、失败时补偿回滚(fake engine 实现了真 rollback,否则这些断言对一个从不开事务的 plan 也会通过)。objectql/src/protocol-recorded-by-null.test.ts—— 对真实 ObjectQL 引擎跑协议方法,且recorded_by按生产声明注册为readonly的lookup('sys_user')(不是既有测试里的text替身)。这条同时覆盖了 data: a lookup accepts an id that does not exist in the referenced object — including the RBAC permission-set link tables #4441 当初被咬的回归:create / publish / delete 三条创作路径仍通。saveMetaItem改回?? 'system',2 条断言立刻红(expected 'system' to be null)。cli/src/commands/migrate/recorded-by.test.ts—— 钉住「默认不写库」这条 Schema sync is additive-only: non-additive metadata changes (required→optional, type, drop, rename) silently diverge from existing DBs; need drift detection + os migrate #2186 不变式。i18n 再生(第二个 commit)
首轮 CI 的
TypeScript Type Check红了,失败点不是tsc而是同 job 里的check-i18n-bundles:字段的description会进 i18n schema 成为该字段的help,所以 platform-objects 的四个 locale bundle 变陈旧。按工具自述的修法处理:
node scripts/check-i18n-bundles.mjs --write(merge 模式,既有翻译一律保留)。diff 恰好是每个 locale 新增一个helpkey,无任何无关漂移。非英文 locale 里填的是源文,这是该工具「待翻译」的设计语义,不是翻译主张。复验:
node scripts/check-i18n-bundles.mjs→ OK(9 个包全部 in sync);pnpm check:i18n-coverage→ OK(12 个 config,660 条已入基线,none new)。(顺带记录一个 AGENTS.md §9 的现场:
check:i18n-coverage一开始报的是Cannot find module .../connector-mcp/dist/index.mjs,那是 worktree 构建状态陈旧,不是本 PR 的问题 —— 把 plugins / connectors 构建齐之后即绿。)关于 #4441 / #4551 两处豁免的判断(本单不动,已开 #4743)
按 PM 约束,
objectql/src/engine.ts的readonly收窄与dangling-reference-audit.ts的巡检跳过本 PR 一行未动。我的判断:recorded_by里有哨兵」,而是「只有调用方提供的值才由调用方负责」—— 非系统调用方写 readonly 字段的值在写入前就被stripReadonlyFields/stripReadonlyForInsert剥掉了,留下的一定是平台自己写的。这个论证与本 PR 无关,独立成立。但那段注释现在会误导人:它把一个已经不存在的 wart 当作收窄的理由记在那里,下一个读到的人会以为收窄是个可以拆掉的临时补丁。isSystem写入仍可产生悬空 lookup 引用——需要一条只报告不拦截的巡检(#4441 残留) #4551 的巡检跳过反而值得重新评估。 它自己的文档承认,剔掉recorded_by之后,被豁免的 readonly 引用字段就只剩created_by/updated_by/organization_id—— 这些是货真价实的 id,而且真的会悬空(删掉一个用户,他创建过的每一行created_by就指向不存在的行)。那恰恰是巡检本该报告的一类。收窄这个跳过是有实际价值的,但影响面不小(删过用户的库会瞬间亮起大片),需要单独决策。两条已记入 #4743(未认领,含 A/B/C 三个方向与倾向性建议)。
边界
packages/spec/**零改动(git diff --stat可查)。协议侧的MetadataEvent/MetadataItem/PutOptions都在metadata-core,spec 里的recordedBy本来就是.optional()。content/docs/releases/。order, notdirection(#4674) #4720 在protocol.ts的排序改动 —— 已 mergeorigin/main(含 fix(metadata-protocol): sort audit history and global search byorder, notdirection(#4674) #4720),无冲突,两边改动都在(合并后重跑了 metadata-core / metadata-protocol / objectql 全量测试)。验证输出
(
metadata-protocol在 #4311 的 DEBT 台账里、没有typecheck脚本,所以单独跑了tsc --noEmit -p:59 条报错全部落在既有的*.test.ts债务文件里,protocol.ts与sys-metadata-repository.ts零报错。)第一轮远端 CI:除
TypeScript Type Check外全绿(Test Core、Dogfood Regression Gate 1/2 + 2/2、Dogfood Verify CLI、Build Core、Temporal Conformance、ESLint、Check Changeset、Check PR Size、重复认领闸门)。TypeScript Type Check的红由上面的 i18n 再生 commit 修复。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny