feat(metadata-protocol,lint): 作者时规则接到运行时写入闸门 —— 第四扇门 (#4463) - #4715
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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
复核后三处改动(第二、三次推送)1.
|
…time-authoring-gate
已跟上 main(
|
Fixes #4463
问题
#4409/#4445 把 26 条作者时规则收进一张 registry,
os validate/os build/os lint由构造保证跑同一套。三扇门都在 CLI 上。 元数据的运行时写入路径 —— Studio 编辑、REST/metaitem CRUD、MCP/AI agent 授权 —— 最终都走saveMetaItem,那里只做 per-type 的 ZodsafeParse。26 条规则一条不跑。对租户来说这不是「四扇门里最弱的一扇」,而是唯一的一扇:
sys_metadataoverlay 行根本不在 CLI 的 config 文件里,os lint再完美也看不见,租户没有任何等价命令可跑。正文那个实测例 —— expression approver 是坏 CEL 的审批流(record.owner ==)—— 在 Zod 里approver.value就是个 string,schema 全绿,存进sys_metadata,经registerFlow注册,触发时节点在入口处失败。这正是 #4409 之后os lint一直在拦的那个 body。它同时是 AI 作者走的那扇门,这也是裁决里权重最高的一条:模型写出来的元数据恰恰最容易出错,却从唯一没有检查的入口进来。
做法:一个共享校验核 + 一个运行时闸门
不是四套规则引擎,也不是每个 surface 各写一遍。
共享核落在
packages/lint。AUTHORING_RULES从packages/cli/src/lint/搬到packages/lint/src/,连同五个原本住在 CLI 的规则模块(lintFlowPatterns/lintLivenessProperties/lintAutonumberFormats/lintViewRefs/data-model-rules)—— 规则逻辑零改动,只是搬家。CLI 改为调用同一核(裁决第 3 条),packages/spec/**零改动。依赖障碍是解决的,不是绕开的。 新增子路径导出
@objectstack/lint/runtime,metadata-protocol只 import 这个入口。新加的runtime-lazy-deps.test.ts钉住比lazy-deps.test.ts更强的一条:不只是 import 时不加载,跑闸门判一个真实 flow body 时也不加载typescript/sucrase。lazy-deps.test.ts本身一行未改、仍绿。surfaces维度是 P1 的结构性最小集,不是顺手做的 P3。 棘轮守卫要覆盖新 surface(正文说这条「不是建议,是这个 issue 存在的理由」),而守卫无法守一个没被声明的 surface —— 否则运行时门就只能硬编码一份规则名单,正是本单要禁止的「手工接线第四命令」。所以每条规则新增surfaces+ (runtimeTypes|surfaceReason)。P3 的其余部分(推广成 N-surface、docs 表格第四列)不在本 PR。落地行为
state: 'active'。draft 保存永远放行。publishMetaItem的 draft→active 提升也走同一闸门 —— 否则?mode=draft+POST /publish就是免费绕过,而这正是 Studio designer 每次编辑做的事INVALID_METADATA,复用 Zod 失败已有的结构化issues信封,每条带rule/path/where/message/hint。objectui 不需要新协议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 通过registerAuthoringGate在object上跑同一套 OWD posture 规则。再跑一遍 linter 会把一次拒绝用两种话术报两遍。合并到本表是 P2,是合并不是缺口。验收证据
1. 实测例被 422 拒绝,带结构化 issues
2. 同一份元数据 draft 保存放行 / 3. 逃生阀置位后放行且日志响亮 ——
protocol.runtime-authoring-gate.test.ts9 passed。4. CLI 三命令与运行时门跑的是同一个模块 —— 用「删一条规则,两侧同时红」证明
把
validateApprovalApprovers从AUTHORING_RULES删掉一条(不动任何其他文件):一次删除,两侧同时红。
5.
lazy-deps.test.ts仍绿且未被放宽 —— 该文件 diff 为空;新增的runtime-lazy-deps.test.ts是更强的一条,不是替代。6. 棘轮守卫覆盖新 surface,且能 sabotage 验证 —— 两个反向注入都被抓住:
测试
已合入
origin/main后重建 spec 并复跑上述三包。明确不在本单
object等类型上门。本 PR 里 advisory findings 走去重后的服务端日志,不进响应 —— 跑了规则却把结论丢掉,是本单要修的那个洞小一号的版本。surfaces推广成通用 N-surface 维度 + docs 「the gate」表格第四列。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Generated by Claude Code