Skip to content

feat(metadata-protocol,lint): 作者时规则接到运行时写入闸门 —— 第四扇门 (#4463) - #4715

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-4463-runtime-authoring-gate
Aug 2, 2026
Merged

feat(metadata-protocol,lint): 作者时规则接到运行时写入闸门 —— 第四扇门 (#4463)#4715
os-zhuang merged 4 commits into
mainfrom
claude/issue-4463-runtime-authoring-gate

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4463

问题

#4409/#4445 把 26 条作者时规则收进一张 registry,os validate / os build / os lint 由构造保证跑同一套。三扇门都在 CLI 上。 元数据的运行时写入路径 —— Studio 编辑、REST /meta item CRUD、MCP/AI agent 授权 —— 最终都走 saveMetaItem,那里只做 per-type 的 Zod safeParse。26 条规则一条不跑。

对租户来说这不是「四扇门里最弱的一扇」,而是唯一的一扇:sys_metadata overlay 行根本不在 CLI 的 config 文件里,os lint 再完美也看不见,租户没有任何等价命令可跑。正文那个实测例 —— expression approver 是坏 CEL 的审批流(record.owner ==)—— 在 Zod 里 approver.value 就是个 string,schema 全绿,存进 sys_metadata,经 registerFlow 注册,触发时节点在入口处失败。这正是 #4409 之后 os lint 一直在拦的那个 body。

它同时是 AI 作者走的那扇门,这也是裁决里权重最高的一条:模型写出来的元数据恰恰最容易出错,却从唯一没有检查的入口进来。

做法:一个共享校验核 + 一个运行时闸门

不是四套规则引擎,也不是每个 surface 各写一遍。

  1. 共享核落在 packages/lint AUTHORING_RULESpackages/cli/src/lint/ 搬到 packages/lint/src/,连同五个原本住在 CLI 的规则模块(lintFlowPatterns / lintLivenessProperties / lintAutonumberFormats / lintViewRefs / data-model-rules)—— 规则逻辑零改动,只是搬家。CLI 改为调用同一核(裁决第 3 条),packages/spec/** 零改动。

  2. 依赖障碍是解决的,不是绕开的。 新增子路径导出 @objectstack/lint/runtime,metadata-protocol 只 import 这个入口。新加的 runtime-lazy-deps.test.ts 钉住比 lazy-deps.test.ts 更强的一条:不只是 import 时不加载,跑闸门判一个真实 flow body 时也不加载 typescript / sucraselazy-deps.test.ts 本身一行未改、仍绿。

  3. surfaces 维度是 P1 的结构性最小集,不是顺手做的 P3。 棘轮守卫要覆盖新 surface(正文说这条「不是建议,是这个 issue 存在的理由」),而守卫无法守一个没被声明的 surface —— 否则运行时门就只能硬编码一份规则名单,正是本单要禁止的「手工接线第四命令」。所以每条规则新增 surfaces + (runtimeTypes | surfaceReason)。P3 的其余部分(推广成 N-surface、docs 表格第四列)不在本 PR。

落地行为

决策 实现
D1 只挡 state: 'active'。draft 保存永远放行。publishMetaItem 的 draft→active 提升也走同一闸门 —— 否则 ?mode=draft + POST /publish 就是免费绕过,而这正是 Studio designer 每次编辑做的事
D2 选项 (c):按被写 item 的类型建每次写入的 stack 快照,首期跑 flow / approval / expression / reference 四族
D3 gating → 422 INVALID_METADATA,复用 Zod 失败已有的结构化 issues 信封,每条带 rule / path / where / message / hint。objectui 不需要新协议
D4 只挡新写入,读路径不拒绝。逃生阀 OS_ALLOW_UNLINTED_METADATA_WRITES=1,置位后放行且每次都完整打日志

差分求值是 D4 的结构化实现,不是防御性补丁:规则跑两遍(上下文含/不含被写 item),只有这次写入 新增 的 finding 才能拒绝它。否则保存 flow A 会因为一条早就存在、可能是包里发来的坏 object 谓词而 422 —— 那比本单要补的洞更糟。

降级放行:逐条列出

裁决允许「个别规则在运行时上下文缺输入则降级为 warn」,要求显式记录。本 PR 没有任何规则被降级为 warn。 采取的是另一种显式收窄:每条不上运行时门的规则在 registry 里写明理由,三类共享常量:

  • RUNTIME_NEEDS_FULL_SNAPSHOT —— 规则读的是快照没带的全栈集合(pages / dashboards / nav / translations / seeds)。运行时宇宙其实更全(D2 说的那个反直觉红利),但构造那个快照是 P2;拿半个快照跑,会把租户没放进这次写入的元数据报成「缺失」。
  • RUNTIME_HEAVY_SOURCE_PARSE —— 规则要用 typescript / sucrase 解析作者写的源码(react/jsx 页面、L2 JS body)。这两个依赖正是 kernel boot path 不能碰的;Studio 的页面编辑另有 save-time 编译路径。
  • RUNTIME_OBJECT_WRITES_P2 —— 规则判的是 object/field 声明。object 写入是产品里最热的元数据路径(Studio 每次改字段),一个误判 422 的爆炸半径是整个产品,所以 P1 先上 flow(issue 的实测例和全部验收标准都是 flow),等闸门跑过真实流量再放宽。

两条单独写了具体理由,因为它们不是「以后再说」:

  • validateCapabilityReferences —— 唯一一条运行时宇宙让它更严的规则(「可能由别的包提供」这个 advisory 对冲在活的 capability registry 面前是可判定的)。那是 advisory→gating 的严重性变更,该单独一个 PR,不该搭在接线 PR 上。
  • validateSecurityPosture —— 这个 surface 上已经被另一套机制挡住了:plugin-security 通过 registerAuthoringGateobject 上跑同一套 OWD posture 规则。再跑一遍 linter 会把一次拒绝用两种话术报两遍。合并到本表是 P2,是合并不是缺口。

验收证据

1. 实测例被 422 拒绝,带结构化 issues

[invalid_metadata] flow/leave_approval failed author-time validation:
  flows[0].nodes[1].config.approvers[0].value: [approval-expression-invalid]
  expression approver does not parse as CEL: Unexpected token: EOF
status 422 · code INVALID_METADATA · rulesRun 含 validateApprovalApprovers

2. 同一份元数据 draft 保存放行 / 3. 逃生阀置位后放行且日志响亮 —— protocol.runtime-authoring-gate.test.ts 9 passed。

4. CLI 三命令与运行时门跑的是同一个模块 —— 用「删一条规则,两侧同时红」证明

validateApprovalApproversAUTHORING_RULES 删掉一条(不动任何其他文件):

--- SIDE A: 共享核侧 packages/lint ---
 x the runtime gate and the CLI commands read the SAME array
 x every rule @objectstack/lint exports is wired into a registry
 x the registry is non-empty and still holds the rules that motivated it
 x rejects the issue's worked example with a located, fixable finding
 x runs the four rule families #4463 P1 wired, from the shared table
--- SIDE B: 运行时门侧 packages/metadata-protocol ---
 x refuses an ACTIVE save of the broken approval flow with a 422
 x gates the draft->active PROMOTION, so the draft door is not a bypass
 x OS_ALLOW_UNLINTED_METADATA_WRITES=1 allows the write and says so loudly
 x survives a host whose registry cannot list objects

一次删除,两侧同时红。

5. lazy-deps.test.ts 仍绿且未被放宽 —— 该文件 diff 为空;新增的 runtime-lazy-deps.test.ts 是更强的一条,不是替代。

6. 棘轮守卫覆盖新 surface,且能 sabotage 验证 —— 两个反向注入都被抓住:

# 注入 A:运行时门直接手工接线一条规则
 x the runtime gate consumes the registry and names no rule of its own
   -> packages/metadata-protocol/src/runtime-authoring-gate.ts
      calls registry rule(s) directly: validateApprovalApprovers.

# 注入 B:第四扇门根本不接核
 x the runtime gate consumes the registry and names no rule of its own
   -> must run the shared core: expected ... to match /runRuntimeAuthoringRules\s*\(/

测试

packages/lint              Test Files  53 passed (53)   Tests  928 passed (928)
packages/metadata-protocol Test Files  27 passed (27)   Tests  225 passed (225)
packages/cli               Test Files  65 passed (65)   Tests  581 passed (581)
typecheck (lint, cli)      Done
check-type-check-coverage  OK  (metadata-protocol 原始 tsc 错误数 59 -> 59,零增量)
spec check:generated       All 8 generated artifacts are up to date

已合入 origin/main 后重建 spec 并复跑上述三包。

明确不在本单

  • P2:advisory findings 进响应 + objectui 渲染;registry 快照推广到全部 runtime-safe 规则;object 等类型上门。本 PR 里 advisory findings 走去重后的服务端日志,不进响应 —— 跑了规则却把结论丢掉,是本单要修的那个洞小一号的版本。
  • P3:surfaces 推广成通用 N-surface 维度 + docs 「the gate」表格第四列。

🤖 Generated with Claude Code

https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny


Generated by Claude Code

…e publish gate (#4463)

The 26 author-time rules #4409 collected behind one registry ran on `os validate`,
`os build` and `os lint` — three CLI commands. Every runtime metadata write (Studio,
REST /meta CRUD, MCP/AI authoring) reaches `saveMetaItem`, which did a per-type Zod
`safeParse` and stopped, so none of the 26 ran there. For a tenant that was not the
weakest of four doors but the only one: a sys_metadata overlay row is not in the
CLI's config file, so no command could have caught it.

Shape follows the ruling: one shared core, one runtime gate.

- Move AUTHORING_RULES (and the five rule modules that lived beside it) from
  packages/cli into @objectstack/lint; the CLI now calls the same table.
- Add the kernel-safe subpath entry @objectstack/lint/runtime, guarded by a new
  runtime-lazy-deps.test.ts: neither typescript nor sucrase loads at import or
  while gating. lazy-deps.test.ts is untouched.
- Declare `surfaces` + `runtimeTypes` / `surfaceReason` per rule, and extend the
  #4445 ratchet to the new surface: a rule that answers neither fails, a gate that
  hand-wires a rule fails, a gate that does not run the core fails.
- Gate `state: 'active'` saves and the draft->active promotion for `flow` with the
  flow/approval/expression/reference families; 422 in the existing structured-issues
  envelope. Drafts pass (D1). Evaluation is differential, so only the submitted item
  can refuse its own write and stored rows are never re-judged (D4).
- Escape hatch OS_ALLOW_UNLINTED_METADATA_WRITES=1, loud on every use.

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

vercel Bot commented Aug 2, 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 2, 2026 9:48pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/lint, @objectstack/metadata-protocol.

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

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, @objectstack/lint)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/validating-metadata.mdx (via packages/cli)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/plugins/index.mdx (via @objectstack/cli)
  • content/docs/plugins/packages.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli)
  • content/docs/releases/v16.mdx (via @objectstack/cli)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

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.

claude added 2 commits August 2, 2026 21:05
…sh repo double (#4463)

Two CI failures on the first push, both real.

1. `check:nul-bytes` — the differential evaluator's finding fingerprint used
   three BARE NUL bytes as its separator. Runtime behaviour was fine; the file
   was not: grep/ripgrep classify a file containing a raw NUL as binary and
   return zero matches for it, silently, so the module disappears from code
   search and every grep-driven lint. git never warned because it sniffs only
   the first 8000 bytes and this NUL sat at byte 5361. Particularly bad here —
   the ratchet this PR adds is itself grep-driven, and the vanishing file was
   in the package it scans. Now the escaped U+0000 form: byte-identical at
   runtime, greppable at rest. (The shorter escape is deliberately avoided —
   a following digit turns it into a legacy octal escape.) Matches the
   existing convention at packages/rest/src/rest-server.ts:1065.

2. `Test Core` — objectql's publish double stubbed `getOverlayRepo` with
   `promoteDraft` alone, so `promoteDraftForPublish` reading the pending draft
   for the gate failed with `repo.get is not a function`. The real
   `SysMetadataRepository` has `get`; the double was narrower than the contract
   it stands in for (#4550's shape), so the double is what is wrong. Widened
   it. The gate is NOT made conditional on the method existing — a repo-shaped
   capability sniff would silently reopen the `?mode=draft` + `POST /publish`
   bypass this PR closes, which is worse than the TypeError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
…thoring gate (#4463)

Found while auditing every internal `saveMetaItem` caller, not from a red
build — `migrateStoredMetadata` re-saves rows that ALREADY EXIST with
`mode:'publish'`, so the new gate refused any stored row carrying a violation.

That inverts the tool and contradicts D4. The migration's job is to rewrite a
stored body into the current dialect; it is not an author publishing anything.
Refusing meant a tenant holding one pre-existing bad flow could never
canonicalize that row: the migration reported `outcome:'failed'` and left the
body in the OLDER dialect — worse than the state it was asked to improve, and
precisely the "存量行走 ADR-0087 的老路" D4 promises.

Carve-out is keyed on `source === 'migrate-stored'`, which is stated by the
SERVER and never forwarded from a request (see the provenance note at the top
of `saveMetaItem`), so no caller can spell its way past the gate — pinned by a
test that walks four near-miss spellings and expects 422 for each.

`duplicatePackage` is deliberately NOT exempt: it mints brand-new rows under
new names, and a copy of a broken flow is a new broken flow. Its per-row
failure reporting already surfaces that.

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

Copy link
Copy Markdown
Contributor Author

复核后三处改动(第二、三次推送)

1. check:nul-bytes 红 —— 已修

fingerprint 分隔符用了三个裸 NUL 字节。运行时没错,文件有错:含裸 NUL 的文件会被 grep/ripgrep 判为 binary 并静默返回零匹配,模块从代码搜索和所有 grep 驱动的 lint 里消失。git 不报警是因为它只嗅探前 8000 字节,而这个 NUL 在第 5361 字节。

对本 PR 尤其讽刺:新加的棘轮守卫自己就是 grep 驱动的,而消失的文件正在它要扫的包里。修复前 grep -n fingerprint packages/lint/src/runtime-gate.ts 的输出就是 binary file matches,修复后正常返回三行。

改成转义形式(运行时字节完全等价),沿用 packages/rest/src/rest-server.ts:1065 的既有惯例;短转义按提示避开(后跟数字会变成 legacy octal escape)。

check-nul-bytes: OK (2938 tracked source file(s), no raw NUL bytes).

顺带扫了本 PR 触碰的全部 31 个文件,无第二处。(写 commit message 时我自己又不小心带进去一个,已在提交前查出并清掉 —— 这个坑比看起来容易踩。)

2. Test Coreobjectql 回归 —— 已修,修的是替身不是闸门

protocol-publish-package-drafts.test.tsmakePublishablegetOverlayRepo 替身成只有 promoteDraft 的对象,而 promoteDraftForPublish 为了跑闸门要先读 pending draft,于是 repo.get is not a function

真实的 SysMetadataRepository get —— 替身比它所替代的契约,这是 #4550 的形状,所以错的是替身,补齐替身。

没有repo.get?.() / typeof repo.get === 'function' 之类的能力嗅探消红:那会让 draft→active 闸门在任何 repo 不完整的宿主上静默失效,等于把本 PR 刚关掉的后门无声地重新打开,比 TypeError 糟得多。

✓ protocol.publishMetaItem — seed self-apply > applies the seed body on publish and reports seedApplied
✓ protocol.publishMetaItem — seed self-apply > suppresses the self-apply when _skipSeedApply is set (package batch path)
✓ protocol.publishMetaItem — seed self-apply > does not touch the loader for non-seed publishes
Test Files  1 passed (1)      Tests  16 passed (16)

3. 主动发现并修掉的第三处:os migrate meta --stored 被闸门误伤(D4 回归)

不是红的 CI 报出来的,是我按「改 protocol.ts 的爆炸半径包含跨包消费者」这条教训,回头把每一个内部 saveMetaItem 调用点都过了一遍发现的。

migrateStoredMetadata(os migrate meta --stored --apply)用 mode: 'publish' 重存已经存在的行。闸门会把任何带违规的存量行拒掉 —— 这把工具反转了,也和 D4 直接冲突:租户只要有一条早年写下的坏 flow,那一行就永远无法被规范化,迁移报 outcome: 'failed' 并把 body 留在更旧的方言上,比它被要求改善的状态还差。这正是「存量走 ADR-0087 的老路」要防的。

修法:按 source === 'migrate-stored' 豁免。安全,因为 source服务端声明的、从不从请求转发(saveMetaItem 顶部的 provenance 注释写明了这点),调用方无法拼出这个词绕过闸门 —— 另加一条测试走四种近似拼法,每种都必须 422。

duplicatePackage 故意不豁免:它是用新名字铸造全新的行,一份坏 flow 的拷贝就是一条新的坏 flow;它本来就按行报 failure。

本地全量验证(四个跨包消费者)

packages/lint              Test Files  53 passed (53)    Tests   928 passed (928)
packages/metadata-protocol Test Files  27 passed (27)    Tests   227 passed (227)
packages/objectql          Test Files 103 passed (103)   Tests  1638 passed (1638)   ← 上轮 1 failed / 3 failed
packages/cli               Test Files  65 passed (65)    Tests   581 passed (581)
check:nul-bytes            OK          pnpm lint (eslint) 干净
typecheck (lint, cli)      Done        check:adr-anchors  OK (17 anchored files)
check-changeset-fixed      ✓           pnpm install --frozen-lockfile  OK

另外为了确认闸门不会误伤仓内既有元数据,离线把仓库里所有 flow 形状的 JSON body 灌进 runRuntimeAuthoringRules 跑了一遍:0 条会被拒。CI 的 Dogfood Verify CLI / Dogfood Regression Gate 也全绿,与之一致。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 21:23
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

已跟上 main(b8cbc57d0),仅做这一件事

MERGE_CONFLICT 弹出后重建 worktree,把 origin/main(ad047d2ee)merge(不是 rebase —— 四个提交已过复核,rebase 会打乱审阅历史)进分支。现在 git rev-list --count HEAD..origin/main = 0

文本冲突:零。 git diff --name-only --diff-filter=U 为空,pnpm-lock.yaml 未被合并触碰,没有任何生成物需要重新生成。队列那次 MERGE_CONFLICT 与本地实际合并结果不一致,应是推测性合并的产物。

#4718 确实有语义影响,而且正落在本 PR 搬动的文件上

#4718(移除 app.areas[].visible / requiredPermissions)修改了 packages/cli/src/utils/lint-liveness-properties.test.ts(+58 行)—— 正是本 PR 搬去 packages/lint/src/ 的五个模块之一的测试

这本该是一次 modify/delete 冲突;没有冲突,是因为 git 的重命名检测跟随了这次移动,把 #4718 的修改干净地应用到了新路径。已逐条核实两个新用例落在新位置且真的在跑:

packages/lint/src/lint-liveness-properties.test.ts:273
  it('is silent on the fail-open area gates — retired in 17.0.0 (#4651)')
packages/lint/src/lint-liveness-properties.test.ts:301
  it('the area-gate silence is a real verdict, not a lint that stopped loading ledgers')

packages/lint 的用例数 928 → 929(其中一个替换了旧断言,一个新增),数字本身就是「#4718 的断言在新路径上跑起来了」的证据 —— 若重命名跟随失败,它们会连同旧文件一起消失,而两边都不会报错。

另按提示自查了两个被删的键在本 PR 触碰的包里的出现位置:除上面这个被搬动的测试外,其余全部命中 validate-capability-references.* / validate-action-name-refs.ts / CHANGELOG.md —— 都是 packages/lint本 PR 未搬动、未修改的既有文件,main 上已与 #4718 自洽。packages/metadata-protocol 零命中。

合并后全量复验(四个包,不只受影响的那个)

packages/lint              Test Files  53 passed (53)    Tests   929 passed (929)   ← 928→929
packages/metadata-protocol Test Files  27 passed (27)    Tests   227 passed (227)
packages/objectql          Test Files 103 passed (103)   Tests  1638 passed (1638)
packages/cli               Test Files  65 passed (65)    Tests   581 passed (581)

typecheck (lint, cli)      Done
pnpm lint (eslint)         clean
check:nul-bytes            OK (2940 tracked source files, no raw NUL)
check:adr-anchors          OK (17 anchored files)
check:type-check-coverage  OK
pnpm install --frozen-lockfile   OK(锁文件未变)
spec build + check:generated     ✓ All 8 generated artifacts are up to date

合并前重建了 packages/spec(它在 main 侧动过)再跑上述全部。

范围未变:三条已复核的裁定(surfaces per-rule 声明、P1 只挡 flowmigrate-stored 豁免)一字未动,本次只有一个 merge commit。等 CI 全绿后由 PM 重新入队,我不自行 merge、不自行入队。


Generated by Claude Code

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

Labels

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

Projects

None yet

2 participants