什么样的业务应用是 AI 可编写的?四道门禁:类型、验证、审查、治理
AI 可编写是目标格式的属性,不是模型的属性。四道门禁、一次真实运行:三个故意犯的错、49 条规则、三条定位到位的报错,然后一次干净通过。
一个业务应用是「AI 可编写」的,当且仅当 Agent 必然会犯的错误在触达用户之前有地方可死。这是目标格式的属性,不是模型的属性。ObjectStack 的 README 点名了四个这样的地方,称之为门禁:类型(Typed)、验证(Validated)、审查(Reviewed)、治理(Governed)。类型错误死在编辑器里。验证错误死在 os validate 上——它拒收那些能通过类型检查、却会在运行时静默失败的元数据。审查错误死在一份人能整个读完的 diff 里。治理错误即使上线了,也留在运行时的权限与审计围栏之内。下面是在 ObjectStack 17.7.0 上、用脚手架生成的支持台跑的一次真实运行,覆盖前两道门禁:按 README 列出的三类各故意犯一个错,49 条规则的门禁把每一个都定位到位、附带修正方案地拒收,修好之后一次干净通过。
两件本来 就成立的事
讲门禁之前,先替对立面把话说足,因为这篇文章的论证不需要它们中的任何一个是错的。
Vibe coding 做出来的应用是能用的。 你可以向模型描述一个支持台,一个下午就拿到一个能跑的版本,有界面、有数据库、有 API,用的是模型最拿手的任何语言。做原型、做一次性工具、做用完即弃的演示,这是正确的取舍,下文没有一句是反对它的。模型早已不是瓶颈。
手写代码是可审查的,而且工具链已经成熟。 一位资深工程师读 pull request、一套类型系统、一套测试、一个代码评审流程,每天都在抓真实的缺陷。对于新颖的逻辑没有替代品,ObjectStack 自己也这么说:「代码不会消失:它搬进了运行时,成为钩子、动作体、CEL 和受限 JSX」——而那些代码仍然要由人来读。
两者都没有回答的,是一旦由 Agent 来写、真正要紧的那个问题:写错的那一行去了哪里? 在一个自由形式的代码库里,一处错误的鉴权检查就是成千上万个 if 里的一个,审查面是整个仓库,运行时自身对权限什么也不强制。脚手架生成的项目里,CI 文件用一句话(说的是元数据,但对任何没有门禁的格式都成立)概括了这件事:一个错误「什么都不抛,只是对第一个碰上它的人表现得不对」。AI 可编写,意思就是这句话不再成立。
四道门禁, 按 README 的 表述
README 的那一节叫「Why the mistakes don’t ship」(错误为什么上不了线),表格短到可以整个译出来:
| 门禁 | 拦住什么 |
|---|---|
| 类型(Typed) | 严格的 TypeScript + Zod——形状错误死在编辑器里,Agent 写下它们几秒钟之后 |
| 验证(Validated) | os validate 拒收那些能通过类型检查、却会在运行时静默失败的元数据:悬空绑定、错误的 CEL 谓词、缺失的安全姿态 |
| 审查(Reviewed) | 你在 Console 里批准一份小而可读的 diff——不是一堆生成出来的胶水代码 |
| 治理(Governed) | 运行时在每一次调用上强制执行权限与审计,所以哪怕应用写错了,也留在围栏之内 |
这张表里有三件事决定了这个承诺是不是真的,值得在看记录之前先说清楚。
第一,门禁按代价排序。一个形状错误花 Agent 一秒钟。一个验证错误花它一轮。一次审查花一个人几分钟。一次被治理住的失败,是一起被控制住的事故。README 说整件事成立的原因,就在前两行:「Agent 的错误变成了定位到位、可修正的文字,它自己就能读、自己就能修」。
第二,第二道门禁之所以存在,是因为第一道不够,文档用一句话说明了原因:「ObjectStack 的元数据是数据,不是代码路径——所以大多数错误不会被 TypeScript 编译器抓到。它们通过 tsc,正常加载,然后在运行时静默失败。」一个谓词是字符串。一个行动作是名字。一个共享模型是可选键。三者带着错误都能通过类型检查。这正是验证门禁为之而建的那一类错误,也是下面这次运行瞄准的那一类。
第三,后两道门禁是人和运行时,不是工具,这篇文章不假装不是。文档里的审查门禁是 Studio 的「Review N changes」diff;在下面的运行里,它是一份由人读的 git diff。治理门禁是运行时的鉴权链和审计日志,这次运行从未启动它。记录证明的是:两道便宜的门禁抓住了 README 说它们会抓住的东西。它证明不了的,放在文末。
一个 支持台, 三个 错误, 一次 运行
下面的一切都是真跑出来的,不是拼出来的:Node 22,已发布的包。脚手架就是 README 自己的第一步:
$ npm create objectstack@latest support-desk
╔═══════════════════════════════════╗
║ ◆ Create ObjectStack v17.7.0 ║
╚═══════════════════════════════════╝
◆ New Environment
────────────────────────────────────────
Environment: support-desk
Namespace: support_desk
Template: blank — Minimal starter — one object, REST API, ready to extend
✓ Template files written (20)
→ Installing dependencies...
→ Installing AI skills for your coding agent...
✓ Environment created!
脚手架写下一份 AGENTS.md,其中唯一没有商量余地的一行就是门禁:「Never report a metadata change as done until npm run validate passes.」(在 npm run validate 通过之前,永远不要把一次元数据改动报告为已完成。)它还写下一个 CI 工作流,唯一的 job 跑 pnpm validate、pnpm lint 和 pnpm typecheck。在任何业务对象存在之前,门禁已经接好了。
接着是 README 里的需求,按 Agent 会写的方式写出来:一个 support_desk_ticket 对象,带主题、描述、一个优先级选择和一个状态选择;一个只在尚未解决的工单上显示的 Resolve 动作;一个「未解决工单」列表视图;一个 Support 导航组;再加一个给支持人员的权限集。整个应用是 11 个文件、新增 127 行。错误将落在其中两个文件里:
// src/actions/resolve-ticket.action.ts
export const ResolveTicketAction = defineAction({
name: 'resolve_ticket',
label: 'Resolve',
objectName: 'support_desk_ticket',
locations: ['record_header', 'list_item'],
visible: P`record.status != "resolved"`, // only on tickets not yet resolved
operation: 'update',
patch: { status: 'resolved' },
confirmText: 'Mark this ticket as resolved?',
});
// src/views/ticket.view.ts
export const TicketViews = defineView({
list: {
label: 'All tickets',
type: 'grid',
data,
columns: ['subject', 'priority', 'status'],
rowActions: ['resolve_ticket'],
},
// ... form, and an "Open tickets" list view filtered on status = open
});
先跑一次干净的,把规则数记在案:
$ npm run validate
◆ Validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (49)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...
✓ Validation passed (327ms)
Support Desk v0.1.0
Data: 2 Objects 6 Fields
UI: 1 Apps 1 Views 1 Actions
Logic: 0 Flows
Security: 1 Permissions
Runtime: 3 plugins
17.7.0 上是 49 条作者期规则。本站上一次跑同一道门禁是在 17.2.0 上,当时是 41 条;规则表在长,对一道门禁来说这是对的方向。这个数字由工具自己打印,等于已发布的 @objectstack/lint 包里 AUTHORING_RULES.length,没有第二个数字需要对账。
三个 错误
现在是 README 的三类,每类故意犯一个错。三个都是在普通 JavaScript 上训练出来的 Agent 会顺手写出的那种,三个都能通过类型检查:
// 1 · a bad CEL predicate: the bare identifier most template languages use
- visible: P`record.status != "resolved"`,
+ visible: P`status != "resolved"`,
// 2 · a dangling binding: a row action that names nothing
- rowActions: ['resolve_ticket'],
+ rowActions: ['resolve'],
// 3 · a missing security posture: the org-wide default left unsaid
- sharingModel: 'private',
三个错都在,npm run typecheck 照样是绿的:tsc 对一个字符串的内容、一个数组里的名字、一个可选键,都没有意见。门禁有:
$ npm run validate
◆ Validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (49)...
✗ Author-time rules failed (3 issues)
• stack · action 'resolve_ticket' visible: bare reference `status` — a formula/validation expression binds the record as the `record` namespace, not at top level, so `status` resolves to nothing and the expression silently evaluates to null. Write `record.status`.
source: `status != "resolved"`
rule: expression-invalid at stack · action 'resolve_ticket' visible
• view "#0" · list · rowActions: Row-action menu names action "resolve", which is defined by no action in this stack (neither `stack.actions` nor any object's `actions`). The button renders and does nothing when clicked — a dead affordance the runtime cannot dispatch. Did you mean "resolve_ticket"?
Define an action named "resolve" (in `stack.actions` or the object's `actions`) with the location this surface needs, remove the reference, or ignore this if the action is contributed by another installed package. Defined actions: resolve_ticket.
rule: action-name-undefined at views[0].list.rowActions[0]
• object "support_desk_ticket": custom object "support_desk_ticket" declares no sharingModel (OWD). The runtime fails CLOSED to 'private' (ADR-0090 D1), but the baseline must be an authored decision, not an accident — this is the exact shape of the leave_request incident, where an object with no sharingModel let an ordinary read/write grant read and edit every other user's records.
Declare sharingModel explicitly: 'private' (owner + shares; recommended default), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children).
rule: security-owd-unset at objects[1].sharingModel
ELIFECYCLE Command failed with exit code 1.
把这三条消息当作一本台账来读,而不是当作报错,因为每一条都写明了没有门禁时运行时会发生什么。叫它错误台账:对一个目标格式,列出 Agent 可能犯的每一类错误、抓住它的那道门禁,以及否则它会在运行时表现成什么症状。一个格式有多 AI 可编写,恰好等于这本台账有多少行不是空的。
| 错误 | 否则会以什么形态上线 | 被谁拦住 · 规则 |
|---|---|---|
visible: P`status != "resolved"` | 谓词「静默求值为 null」:Resolve 按钮在每一张工单上都被藏起来 | 验证 · expression-invalid |
rowActions: ['resolve'] | 「按钮渲染出来,点击后什么也不做——一个运行时无法分发的死 affordance」 | 验证 · action-name-undefined |
对象上没有 sharingModel | 运行时「fail closed 到 ‘private’」,但一个没说出口的基线正是「leave_request 事故的形状」 | 验证 · security-owd-unset |
字段上写 required: 'yes' | 什么也上不了线:tsc 以「Type ‘string’ is not assignable to type ‘boolean | undefined’」拒收 | 类型 · TS2322 |
第四行是类型门禁,在干净的树上单独跑,好让验证器的记录只关于验证:把 required: true 改成 required: 'yes',npm run typecheck 带着那条消息以退出码 2 结束,在任何别的东西运行之前。它是最便宜、也最无趣的一道门禁,而这正是它排在第一位的意义。
消息的两个属性承载了整个承诺。它们定位到位:一个动作和一个谓词槽位,一个视图和一个数组下标,一个对象和一条键路径。它们带修正方案:「Write record.status」、「Did you mean resolve_ticket?」、sharingModel 的四个合法值。读到这些的 Agent 一轮就能闭环。给 Agent 配置 ObjectStack 的那篇文章把它叫作修正式报错(corrective error),并论证一个格式手里杠杆最大的东西不是文档而是报错信息;这次运行是它的又三个数据点。
把那三行改回去,门禁重归安静:
$ npm run validate
→ Running author-time rules (49)...
✓ Validation passed (240ms)
Data: 2 Objects 6 Fields
UI: 1 Apps 1 Views 1 Actions
Security: 1 Permissions
Runtime: 3 plugins
运行里还有一行属于这里,因为它是门禁在做一件比拒收更微妙的事。权限集的第一版在一个私有对象上授予了读、建、改,却没说一次读能看多远,于是那次干净的运行带着一条提示:「permission set "support_agent": "support_desk_ticket" is private (OWD) and this set grants allowRead without a readScope — holders see ONLY records they own (plus explicit shares).」(权限集「support_agent」:「support_desk_ticket」是私有的(OWD),而这个权限集授予了 allowRead 却没有 readScope——持有者只能看到自己拥有的记录(加上显式共享的)。)这不是报错,门禁不把它当报错也是对的:一个支持人员只看自己工单的支持台,是一种正当的设计。它是一个浮到必须做决定的那个人面前的半个决定。修法是一个键,readScope: 'org',而这是一个产品决定,不是一次纠错。
门禁 抓不住 什么
讲门禁的文章必须说清门禁止于何处,而来源在这一点上异常直白。
业务逻辑的正确性。 文档:「护栏 1–3 证明元数据是良构的。它们证明不了它是你想要的。」门禁确认了 record.status 能解析。它对一张工单该不该能从列表里直接解决、resolved 是不是正确的终态、支持人员该不该看到组织内的每一张工单,都没有意见。更早那篇文章对同一条边界的说法是「门禁检查的是结构,不是意图」,49 条规则改变不了这一点。
审查者漏掉的需求。 审查门禁是一个人读一份 diff。它按构造就是小的——整个应用 127 行,三个错误三行——但小不等于读过。一个没问过承包商是否也持有 support_agent 权限集、就批准了 readScope: 'org' 的审查者,已经把一个决定放上线了,而门禁不会拦他,因为元数据是良构的。提示通道有用的程度,恰好等于有人去读它的程度。
新颖的逻辑。 钩子和动作体是代码。「业务本体」概念页说它们「在运行时的权限与审计围栏之内运行」,围栏限定了一个写错的函数体能碰到什么;它不能让那个函数体变对。那仍然是代码评审。
这次运行没有演练的部分。 这份记录是命令行上的前两道门禁。文档所描述的审查门禁——一次改动上线之前由人批准的 Studio diff——在这里是一份 git diff。治理门禁——文档描述为从匿名拒绝到字段级安全、六层各自 fail-closed 的鉴权链,加上每次调用的审计——没有启动,因为 os validate 是一个作者期检查,不写产物、不起服务。README 对那道门禁的主张是「哪怕应用写错了,也留在围栏之内」;这篇文章引用它,而不是演示它。
而两位对手仍然站着。 一个用完即弃的原型不需要四道门禁。一个测试充分、审查者细心的手写服务有它自己的台账,由人而不是工具来填。这里的主张比「这更好」窄:对于一个由 Agent 来写、由企业来跑的应用,台账应该由格式来填,因为 Agent 不会记得,而审查者读不完所有东西。
同一 道门禁, 从浏览 器进来
有一个事实让四道门禁的故事对从不打开终端的人也诚实:os validate 不是唯一的门。文档描述了第四扇:「每一次元数据写入——Studio 的设计器、PUT /api/v1/meta/*、一个 MCP/AI 作者——都落在 saveMetaItem 上,自 #4463 起,一次进入 state: 'active' 的写入会在持久化之前跑同一张表。」上面打印出 49 的那张 AUTHORING_RULES 表,同样会拒收一个从 Studio 发布、带悬空绑定的仪表盘,附带同样定位到位的消息。验证门禁是运行时的属性,不是 CLI 的属性。
还有 README 自己关于这个循环的最后一句:「想把同一个循环托管起来、在浏览器里用、什么都不用安装?那就是 ObjectOS——建立在 ObjectStack 之上的商业运行环境。」ObjectOS 既可托管在浏览器中、无需安装(ObjectOS Cloud),也可自管部署在你自己的基础设施上(ObjectOS Enterprise);无论哪个版本,你都可以导出本体,拿到开源的 ObjectStack 运行时上运行。
填你自己的台账
上面的运行走的是 README 的五分钟路径。唯一的改动是故意犯错,这是弄清一个格式在本文的意义上是否 AI 可编写的最快办法。
npm create objectstack@latest my-app && cd my-app
# 用你的编码 agent 打开它,说清业务需要什么
npx os dev --ui
要 README 描述的那个支持台。然后,在你看 Console 之前,打开 diff,照着这次运行做:从一个谓词里删掉 record.,在视图里拼错一个动作名,删掉一个 sharingModel,跑 npm run validate。数一数门禁替你填了几行,又把几行留给了你。你交给 Agent 的规则文件决定会犯多少错;四道门禁决定它们止于何处;你的台账是对两者诚实的度量。