diff --git a/content/blog/views-dashboards-apps-are-projections-of-the-ontology/cover-en.svg b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/cover-en.svg new file mode 100644 index 0000000..5089a88 --- /dev/null +++ b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/cover-en.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + WHAT CHANGES FOR REVIEW + One field, + seven surfaces. + Add a field to the definition and the table, the API, + the MCP tools and the Console follow by themselves. + The views that name their columns do not. + + + + + budget: Field.currency({ label: 'Budget' }) + + + + + The naming test: names the field, or only the object? + + + + + FOLLOWED, NO EDIT · DERIVED + SQLite table todo_task + 27 → 28 columns + GET /api/v1/data/todo_task + 27 → 28 keys + POST with budget: 1200 + 400 → 201 + MCP describe_object + 19 → 20 fields + Console create form + 16 → 17 inputs + + + + + UNCHANGED · PINNED TO FIELD NAMES + All Tasks list view + 6 columns, as authored + zh-CN translation + budget → "Budget" + Dashboard dataset task_metrics + 3 measures, by name + + + Measured on ObjectStack 18.0.0-next.2, examples/app-todo at commit 43567a8f: one five-line diff, eleven readings before and after. + diff --git a/content/blog/views-dashboards-apps-are-projections-of-the-ontology/cover.svg b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/cover.svg new file mode 100644 index 0000000..959c6fa --- /dev/null +++ b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/cover.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + 这对审阅意味着什么 + 改一个字段, + 七个面自己跟上。 + 给定义加一个字段,数据表、API、MCP 工具 + 和控制台自己跟上。 + 写明了列名的视图不会。 + + + + + budget: Field.currency({ label: 'Budget' }) + + + + + 命名测试:写明的是字段,还是只有对象? + + + + + 未经编辑就跟上 · 推导 + SQLite 表 todo_task + 27 → 28 列 + GET /api/v1/data/todo_task + 27 → 28 个键 + POST 带 budget: 1200 + 400 → 201 + MCP describe_object + 19 → 20 个字段 + 控制台新建表单 + 16 → 17 个输入项 + + + + + 没有变化 · 钉在字段名上 + All Tasks 列表视图 + 6 列,原样 + zh-CN 翻译 + budget → "Budget" + 仪表盘数据集 task_metrics + 3 个度量,按名字 + + + 在 ObjectStack 18.0.0-next.2 上测量,examples/app-todo 取自提交 43567a8f:一份五行 diff,前后各十一个读数。 + diff --git a/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.mdx b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.mdx new file mode 100644 index 0000000..638e7ac --- /dev/null +++ b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.mdx @@ -0,0 +1,124 @@ +--- +title: "Views, Dashboards and Apps Are Projections of the Ontology: What That Changes for Review" +description: "Add one field to a bundled ObjectStack app: the table, REST API, MCP describe call and Console form follow by themselves; the pinned views, dataset and translation do not. The naming test says which is which." +author: ObjectStack Team +date: 2026-11-10 +status: published +topic: app-building +audience: developer +solutions: [] +industries: [] +cover: ./cover-en.svg +tags: + - Ontology + - Executable business ontology + - Reviewable diff + - Metadata review +--- + +This is the whole diff. Five lines, one field, added to the `todo_task` object of the Todo example that ships in the ObjectStack repository: + +```diff + actual_hours: Field.number({ + label: 'Actual Hours', + scale: 2, + min: 0, + }), ++ ++ budget: Field.currency({ ++ label: 'Budget', ++ min: 0, ++ }), +``` + +Booted on the ObjectStack runtime, those five lines changed the database table, the REST read and write, the metadata endpoint, the MCP `describe_object` and `query_records` answers and the Console's create form — seven surfaces, none of which was edited. They did not change the three list views, the dashboard's dataset or the Chinese translation, because each of those names fields by name. The first group is derived from the definition; the second is pinned to it. **The naming test** sorts any projection into one of the two before you open it: *if it names the field, you review it; if it names only the object, the runtime already did.* The rest of this post is the measurement behind that sentence, taken on ObjectStack `18.0.0-next.2` with the example's source at commit `43567a8f`, and what it changes about reading a diff. + +## Where the definition of a projection lives + +Three places already define what a projection is, and this post does not restate them. [The definition post](/en/blog/what-does-the-ontology-is-the-software-mean/) names the four projections and gives the removal test for telling core from projection. [The ontology page](/en/ontology/) states the rule in one line: change the core and the projections follow; remove a projection and the business it showed is unchanged. The [glossary entry](/en/glossary/ontology-projection/) adds the consequence for a reviewer: a diff that touches only projections changes presentation, and a diff that touches the core changes authority. + +What none of them says is *how* a projection follows. "Follow" covers two different mechanisms, and a reviewer needs to know which one is in front of them. That is the question the run below answers. + +## The run: one field, eleven readings + +The setup is the bundled Todo example (`examples/app-todo`), copied out of the repository at commit `43567a8f` and run on the published `18.0.0-next.2` packages with `os dev`. The database is the default SQLite file; the API key was minted over REST from the seeded dev admin. Every reading was taken twice: once on the committed definition, once after the five-line diff above and a restart with `--compile`. "Before" and "after" are the two answers. + +| Surface | How it was read | Before | After | Follows? | +| :--- | :--- | :--- | :--- | :--- | +| Database table `todo_task` | `PRAGMA table_info` on the SQLite file | 27 columns | 28; `budget float` added to the existing table | Derived | +| REST read | `GET /api/v1/data/todo_task` | 27 keys per record | 28 keys | Derived | +| REST write | `POST /api/v1/data/todo_task` with `budget: 1200` | `400` — `Unknown field 'budget' on object 'todo_task'`, code `INVALID_FIELD` | `201`, record created | Derived | +| Metadata endpoint | `GET /api/v1/meta/object/todo_task` | 27 fields | 28; `{ label: 'Budget', type: 'currency', min: 0, … }` | Derived | +| MCP `describe_object` | `tools/call` on `/api/v1/mcp` | 19 fields | 20 | Derived | +| MCP `query_records` | `where: { budget: { $gte: 1000 } }, fields: ['subject', 'budget']` | — | `[["Renew the domain", 1200]]` | Derived | +| Console create form | `/_console/apps/todo_app/todo_task/new` | 16 inputs | 17; *Budget* between *Actual Hours* and *Notes*, in declaration order | Derived | +| Console record detail | the new record's page | — | `BUDGET 1,200.00` in *Details* | Derived | +| Console list view *All Tasks* | `GET /api/v1/meta/view` → `todo_task.default` | 6 columns | 6 columns, unchanged | Pinned | +| Translation, `zh-CN` | `GET /api/v1/meta/object/todo_task` with `Accept-Language: zh-CN` | `subject` → 主题 | `budget` → `Budget` (English falls through) | Pinned | +| Dashboard dataset `task_metrics` | the dataset definition | 6 dimensions, 3 measures, each naming a field | unchanged; nothing can chart `budget` until a measure names it | Pinned | + +Two readings did not move and are not projections: the OpenAPI document still lists 14 paths for `todo_task` with no per-field schema, and the MCP `tools/list` still answers the same 12 tools (`list_objects`, `describe_object`, `validate_expression`, `query_records`, `aggregate_records`, `get_record`, `create_record`, `update_record`, `delete_record`, `list_actions`, `run_action`, `resume_run`). Both describe objects, not fields; the field shows up inside their answers, not in their shape. + +![The naming test: one five-line diff in the middle; on the left the surfaces that name only the object and followed it without an edit, with their before and after readings; on the right the projections that name the field and stayed exactly as authored; at the foot, the build gate's own warning that the new field has no consumer](./naming-test-en.svg) + +One more reading came for free. `os dev --compile` runs the author-time rules before it writes the artifact, and this is what the gate printed about the new field: + +```text +⚠ object "todo_task" · field "budget": declared, but nothing in this stack displays or reads it (inert) + fix: add it to a view column or a form section, or remove the declaration + rule: field-no-consumers at objects[0].fields.budget +``` + +`os explain field-no-consumers` defines a consumer as "a view column, an inline grid column, a form section, a page binding, a flow node, a dataset or cube member, a dashboard widget, a formula, a validation, a hook or an action that names it", and separates it from a carrier, which "names a field without reading it", such as "a translation label, a seed value, an import-mapping target, a permission grant". The gate's list is the right-hand column of the table. It is the runtime telling you, before boot, which projections are pinned to a field and that none of them has picked this one up yet. + +## The naming test + +Why do seven surfaces follow and four stay put? Not because some are more important. Because of what each one names. + +The table, the REST routes, the metadata endpoint and the MCP tools are generated from the object. The SQL driver's schema sync walks `obj.fields` and creates a column for every entry; the data routes are registered per object and read whatever fields the object has; `describe_object` maps `Object.entries(def.fields)` into its answer; the Console, finding no form view declared for `todo_task`, lays out the object's own fields. None of these holds a list of field names. They hold the object's name and ask the definition for the rest on every call, which is why the field reached all of them with no edit and why it reached them in declaration order. + +The list views, the dataset and the translation bundle are authored metadata too, and each one spells out field names: `columns: [{ field: 'subject' }, { field: 'status' }, …]`, `measures: [{ name: 'est_hours', field: 'estimated_hours', … }]`, `fields: { subject: { label: '主题' }, … }`. A projection that names the field is making a choice about it — this column, this label, this aggregate — and the runtime honours the choice exactly. It does not add what you did not ask for. + +So the test, stated for a reviewer: + +> **The naming test.** For each projection the diff could reach, ask whether it names the field or only the object. If it names only the object, it is derived: the diff you are reading is the whole change, and the runtime has already propagated it. If it names the field, it is pinned: it did not move, and whether it should have is your call. + +It composes with the removal test from the definition post. The removal test sorts a definition into core or projection. The naming test sorts the projections a core change can reach into derived and pinned. Between them a reviewer knows, from the diff alone, what the runtime did and what is still waiting for a decision. + +## What this changes about reading the diff + +The practical consequence is an order of work, and it is shorter than the one most teams follow. + +**First, the definition line is the review for every derived surface.** There is no table migration to read alongside it, no serializer, no controller, no tool manifest. In the run above, the `budget` column exists because the field does; the REST write accepts `budget` because the field does; the agent's `describe_object` shows it because the field does. If the field is right, those are right, and if the field is wrong, they are wrong in exactly the way the field is. Reviewing the five lines reviewed seven surfaces, which is the sentence the title promised and the sentence the glossary entry means by "a diff that touches the core changes authority". + +**Second, search the pinned projections for the field's name — and expect to find nothing.** An added field will not appear in any view, dataset or translation, because none of them has been edited. That is not an omission the runtime hides; it is the warning the gate printed. The reviewer's question is the one the gate's `fix:` line poses: should this field have a consumer, and which one? For `budget`, probably a column on *All Tasks* and a `zh-CN` label; probably not a dashboard measure yet. Those are three small, deliberate edits to three named files, and each is as reviewable as the first five lines. + +**Third, treat a removal as a different kind of change.** Everything above is about an added field. The driver's schema sync is additive only: it creates missing tables and adds missing columns, and it never alters or drops existing ones, so a removed or renamed field leaves its column behind as drift that `os migrate` reports and a human reconciles. The derived surfaces follow a removal instantly — the REST record loses the key, `describe_object` loses the field — while the table does not. And every pinned projection that still names the removed field is now wrong, which the same gate will tell you about as a dangling binding. The naming test still applies; the list of things to look at is simply longer, and it starts with the migration. + +This ordering is also the missing half of the [four-pass review checklist](/en/blog/how-to-review-ai-generated-app-changes/), whose fourth pass is "fields and views, last". The naming test says which views: the ones that name the field. The rest were reviewed when the field was. + +## In defence of pinning + +It would be easy to read the right-hand column of the table as a limitation, and the honest reading is the opposite. + +A grid that added every new column automatically would reorder a screen every time a developer added a field for a flow to write into. A dashboard that charted every numeric field would chart `recurrence_interval`. A translation that invented labels would be a translation nobody wrote. Pinned projections are pinned because a human made a presentation decision, and a runtime that silently overrode it would be the thing this whole format exists to prevent: behaviour that nobody declared. The Console's own fallback — lay out every field when no form view is declared — is the exception that proves it: it applies only where nobody has decided anything yet. + +What the format gives the reviewer is not that pinned projections follow, but that they are *findable*. They are metadata in the same repository, they name the field, and the gate enumerates them. The alternative, in a hand-written codebase, is a column list in a React component, a serializer in another directory, a label in a JSON file and a chart config in a third system, with nothing that knows they are about the same field. + +## What this does not cover + +A measurement on one example is a measurement on one example. Four things stay outside it. + +**Hand-tuned views stay hand-tuned.** Every list view in the Todo example declares its columns, and so does every view in the CRM example. A new field reaches a pinned grid only when someone adds it, and that someone is often the agent that added the field, in the same PR. The naming test tells you to look there; it does not do the looking. + +**Translations must be written.** The runtime falls back to the declared label, so a missing `zh-CN` entry renders `Budget` rather than nothing. That is the right default and still a gap a Chinese user sees. A translation is a pinned projection in the strictest sense: every string in it is a decision. + +**External consumers are outside the stack.** The gate says so itself: "a consumer can legitimately live outside the stack — an API client, a hook or package this stack does not carry, a Studio-authored view". A dashboard in another tool that selects `estimated_hours` by name is pinned to that field and nothing in the repository knows it. The naming test applies to it; the enumeration does not. + +**Semantic changes are not structural changes.** Adding a field is the easy case, which is why it was chosen for a clean before-and-after. Change the *meaning* of a field instead — add a `cancelled` option to `status`, say — and the derived surfaces follow just as faithfully, but the Todo example's *Overdue Tasks* view and its *Overdue Tasks* metric both encode overdue as `status != completed`, so a cancelled task with a past due date would count as overdue on both. That is a reading of the view and dataset definitions, not a measurement, and it is the kind of consequence the naming test points you at — both projections name `status` — without deciding for you. + +## Point your agent at it + +The run above is reproducible from the repository and takes about a minute once the packages are installed, which means it is also something your agent can do for you. Point a coding agent at an ObjectStack project — the scaffolder writes an `AGENTS.md` and installs the skills bundle, the running app serves MCP at `/api/v1/mcp`, and [how to configure it](/en/blog/how-to-point-your-agent-at-objectstack/) covers the rule file, the skills and the tool server — and when it opens a change to a field, ask it for the naming-test table of its own diff: which surfaces followed, which projections name the field, and what it proposes to do about each. The gate's `field-no-consumers` line is the first row of that table, and the agent can read it before you do. + +Everything in this post ran on the open-source stack. "Want the same loop hosted, in the browser, nothing to install? That's ObjectOS, the commercial runtime environment built on ObjectStack." ObjectOS runs hosted in the browser with nothing to install (ObjectOS Cloud) or self-managed on your own infrastructure (ObjectOS Enterprise), and on either edition you can export your ontology and run it on the open-source ObjectStack runtime. The naming test does not change with the host; the definition it reads is the same file. diff --git a/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.zh-Hans.mdx b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.zh-Hans.mdx new file mode 100644 index 0000000..6489087 --- /dev/null +++ b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.zh-Hans.mdx @@ -0,0 +1,124 @@ +--- +title: 视图、仪表盘和应用是本体的投影:这对审阅意味着什么 +description: 给 ObjectStack 自带示例加一个字段并逐面实测:数据表、REST、MCP 与控制台表单自己跟上,钉死字段名的视图、数据集和翻译没动。命名测试告诉审阅者哪个是哪个。 +author: ObjectStack Team +date: 2026-11-10 +status: published +topic: app-building +audience: developer +solutions: [] +industries: [] +cover: ./cover.svg +tags: + - 本体 + - 可执行业务本体 + - 可审阅的 diff + - 元数据审阅 +--- + +下面就是整份 diff。五行,一个字段,加在 ObjectStack 仓库自带的 Todo 示例的 `todo_task` 对象上: + +```diff + actual_hours: Field.number({ + label: 'Actual Hours', + scale: 2, + min: 0, + }), ++ ++ budget: Field.currency({ ++ label: 'Budget', ++ min: 0, ++ }), +``` + +在 ObjectStack 运行时上启动之后,这五行改动了数据库表、REST 的读与写、元数据接口、MCP 的 `describe_object` 与 `query_records` 应答,以及控制台的新建表单——七个面,没有一个被编辑过。它们没有改动三个列表视图、仪表盘的数据集和中文翻译,因为这三样东西都逐个写明了字段名。前一组是从定义推导出来的,后一组是钉在定义上的。**命名测试**在你打开任何一个投影之前就能把它归到两组之一:它写明了这个字段,你就审它;它只写明了对象,运行时已经替你审过了。本文余下的部分,是这句话背后的测量——在 ObjectStack `18.0.0-next.2` 上、示例源码取自提交 `43567a8f`——以及它对读一份 diff 这件事改变了什么。 + +## 投影的定义住在哪里 + +投影是什么,已经有三处定义,本文不重复。[定义那篇](/zh-Hans/blog/what-does-the-ontology-is-the-software-mean/)点名了四种投影,并给出区分核心与投影的删除测试。[本体页](/zh-Hans/ontology/)用一句话说明规则:改动核心,投影跟着变;删掉一个投影,它所展示的业务不变。[术语表条目](/zh-Hans/glossary/ontology-projection/)补上了对审阅者的推论:只碰投影的 diff 改的是呈现,碰到核心的 diff 改的是权限与事实。 + +它们都没说的是,投影是*怎么*跟着变的。「跟着变」底下藏着两种不同的机制,审阅者得知道眼前的是哪一种。下面这次运行回答的就是这个问题。 + +## 这次运行:一个字段,十一个读数 + +对象是自带的 Todo 示例(`examples/app-todo`),从仓库提交 `43567a8f` 复制出来,用已发布的 `18.0.0-next.2` 包以 `os dev` 启动。数据库是默认的 SQLite 文件;API key 用种子化的开发管理员通过 REST 签发。每个读数取两次:一次在提交原样的定义上,一次在上面那五行 diff 之后、带 `--compile` 重启之后。「之前」和「之后」就是这两次的答案。 + +| 面 | 怎么读的 | 之前 | 之后 | 跟着变? | +| :--- | :--- | :--- | :--- | :--- | +| 数据库表 `todo_task` | 对 SQLite 文件执行 `PRAGMA table_info` | 27 列 | 28 列;`budget float` 加到了已有的表上 | 推导 | +| REST 读 | `GET /api/v1/data/todo_task` | 每条记录 27 个键 | 28 个键 | 推导 | +| REST 写 | `POST /api/v1/data/todo_task`,带 `budget: 1200` | `400`——`Unknown field 'budget' on object 'todo_task'`,错误码 `INVALID_FIELD` | `201`,记录已创建 | 推导 | +| 元数据接口 | `GET /api/v1/meta/object/todo_task` | 27 个字段 | 28 个;`{ label: 'Budget', type: 'currency', min: 0, … }` | 推导 | +| MCP `describe_object` | 对 `/api/v1/mcp` 发 `tools/call` | 19 个字段 | 20 个 | 推导 | +| MCP `query_records` | `where: { budget: { $gte: 1000 } }, fields: ['subject', 'budget']` | — | `[["Renew the domain", 1200]]` | 推导 | +| 控制台新建表单 | `/_console/apps/todo_app/todo_task/new` | 16 个输入项 | 17 个;*Budget* 按声明顺序出现在 *Actual Hours* 和 *Notes* 之间 | 推导 | +| 控制台记录详情 | 新记录的页面 | — | *Details* 区显示 `BUDGET 1,200.00` | 推导 | +| 控制台列表视图 *All Tasks* | `GET /api/v1/meta/view` → `todo_task.default` | 6 列 | 6 列,没有变化 | 钉死 | +| 翻译,`zh-CN` | 带 `Accept-Language: zh-CN` 的 `GET /api/v1/meta/object/todo_task` | `subject` → 主题 | `budget` → `Budget`(回落到英文) | 钉死 | +| 仪表盘数据集 `task_metrics` | 数据集定义本身 | 6 个维度、3 个度量,每个都点名一个字段 | 没有变化;在某个度量点名 `budget` 之前,没有任何图表能画它 | 钉死 | + +另有两个读数没有变化,而它们也不是投影:OpenAPI 文档仍然为 `todo_task` 列出 14 条路径,没有逐字段的 schema;MCP 的 `tools/list` 仍然应答同样的 12 个工具(`list_objects`、`describe_object`、`validate_expression`、`query_records`、`aggregate_records`、`get_record`、`create_record`、`update_record`、`delete_record`、`list_actions`、`run_action`、`resume_run`)。两者描述的都是对象,不是字段;字段出现在它们的应答里,而不是它们的形状里。 + +![命名测试:中间是那份五行 diff;左边是只写明对象、未经编辑就跟上了定义的面,附前后读数;右边是写明了字段名、原样未动的投影;底部是构建门禁自己打出的警告——新字段没有任何消费者](./naming-test.svg) + +还有一个读数是白送的。`os dev --compile` 在写出工件之前会跑一遍编写期规则,下面是门禁对这个新字段打印的原话: + +```text +⚠ object "todo_task" · field "budget": declared, but nothing in this stack displays or reads it (inert) + fix: add it to a view column or a form section, or remove the declaration + rule: field-no-consumers at objects[0].fields.budget +``` + +`os explain field-no-consumers` 把「消费者」定义为「a view column, an inline grid column, a form section, a page binding, a flow node, a dataset or cube member, a dashboard widget, a formula, a validation, a hook or an action that names it」,并把它和「载体」区分开——载体「names a field without reading it」,例如「a translation label, a seed value, an import-mapping target, a permission grant」。门禁的这份清单,正是上表的右半栏。这是运行时在启动之前就告诉你:哪些投影是钉在字段上的,而其中还没有任何一个接住这个新字段。 + +## 命名测试 + +为什么七个面跟着变、四个面原地不动?不是因为有的更重要。是因为它们各自写明了什么。 + +数据表、REST 路由、元数据接口和 MCP 工具都是从对象生成的。SQL 驱动的结构同步遍历 `obj.fields`,为每一项建一列;数据路由按对象注册,读对象当下有的全部字段;`describe_object` 把 `Object.entries(def.fields)` 映射成它的应答;控制台发现 `todo_task` 没有声明表单视图,就按对象自己的字段排版。这些地方没有一处持有一份字段名清单。它们持有的是对象名,每次调用时向定义要其余的一切——所以字段不经编辑就到达了它们全部,而且是按声明顺序到达的。 + +列表视图、数据集和翻译包同样是编写出来的元数据,而且每一个都逐字写明字段名:`columns: [{ field: 'subject' }, { field: 'status' }, …]`、`measures: [{ name: 'est_hours', field: 'estimated_hours', … }]`、`fields: { subject: { label: '主题' }, … }`。写明了字段的投影是在对这个字段做一个选择——这一列、这个标签、这个聚合——运行时会原封不动地兑现这个选择。你没要的,它不会替你加。 + +于是,把测试说给审阅者听: + +> **命名测试。** 对这份 diff 可能波及的每一个投影,问一句:它写明的是这个字段,还是只有对象?只写明对象的,是推导出来的:你正在读的 diff 就是全部改动,运行时已经把它传播到位。写明了字段的,是钉死的:它没有动,该不该动,由你判断。 + +它和定义那篇的删除测试是配套的。删除测试把一份定义归为核心或投影;命名测试把一次核心改动能波及的投影归为推导或钉死。两条合在一起,审阅者光凭 diff 就知道运行时做了什么、还有什么在等一个决定。 + +## 这对读 diff 改变了什么 + +实际后果是一套工作顺序,而且比多数团队现在遵循的那套更短。 + +**第一,定义那几行,就是对所有推导面的审阅。** 没有要陪着读的建表迁移,没有序列化器,没有控制器,没有工具清单。在上面的运行里,`budget` 列存在是因为字段存在;REST 写入接受 `budget` 是因为字段存在;Agent 的 `describe_object` 显示它是因为字段存在。字段对,它们就对;字段错,它们就以和字段一模一样的方式错。审五行审掉了七个面——这正是标题承诺的那句话,也正是术语表条目所谓「碰到核心的 diff 改的是权限与事实」的意思。 + +**第二,在钉死的投影里搜这个字段的名字——并且预期什么都搜不到。** 新加的字段不会出现在任何视图、数据集或翻译里,因为它们一个都没被编辑过。这不是运行时藏起来的遗漏,而是门禁打印出来的那条警告。审阅者要问的,正是门禁 `fix:` 那一行提出的问题:这个字段该不该有一个消费者,是哪一个?对 `budget` 来说,大概率是 *All Tasks* 上的一列和一个 `zh-CN` 标签;大概率暂时不是一个仪表盘度量。那是对三个具名文件的三处小而有意的编辑,每一处都和最初那五行一样可审。 + +**第三,把删除当成另一种改动。** 上面说的全是加字段。驱动的结构同步只做加法:它建缺失的表、加缺失的列,从不修改或删除已有的列,所以一个被删掉或改名的字段会把它的列留在原地,成为 `os migrate` 报告出来、由人来对账的漂移。推导面会立刻跟上删除——REST 记录丢掉那个键,`describe_object` 丢掉那个字段——数据表却不会。而每一个仍然写明被删字段的钉死投影现在都错了,同一道门禁会把它当作悬空绑定报出来。命名测试照样适用;只是要看的清单更长,而且从迁移开始。 + +这套顺序也补上了[四遍审阅清单](/en/blog/how-to-review-ai-generated-app-changes/)缺的那一半——它的第四遍是「字段和视图,放最后」。命名测试说的是:哪些视图。写明了这个字段的那些。其余的,在审字段的时候已经审过了。 + +## 替钉死说句话 + +把上表的右半栏读成一种局限很容易,而诚实的读法恰恰相反。 + +一张会自动把所有新列加进来的网格,会在每次开发者为了让某条流程有地方写而加个字段时,把整个页面重排一遍。一张会把所有数值字段都画出来的仪表盘,会把 `recurrence_interval` 画出来。一份会自己发明标签的翻译,是一份没人写过的翻译。钉死的投影之所以钉死,是因为有人做过一个呈现上的决定;一个悄悄推翻这个决定的运行时,正是这整套格式要防止的那件事——没有任何人声明过的行为。控制台自己的回退——没声明表单视图时就把所有字段排出来——恰好是证明这条规则的例外:它只在还没有任何人做过决定的地方生效。 + +这套格式给审阅者的,不是钉死的投影会跟着变,而是它们*找得到*。它们是同一个仓库里的元数据,它们写明了字段名,门禁能把它们枚举出来。另一种情况——一份手写的代码库——是某个 React 组件里的一份列清单、另一个目录里的一个序列化器、一个 JSON 文件里的一个标签、第三套系统里的一份图表配置,而没有任何东西知道它们说的是同一个字段。 + +## 本文不覆盖的 + +在一个示例上的测量,就是在一个示例上的测量。有四件事留在它之外。 + +**手工调过的视图仍然是手工的。** Todo 示例里每个列表视图都声明了列,CRM 示例里每个视图也是。新字段到达一张钉死的网格,只能靠有人把它加进去——而这个「有人」常常就是加这个字段的那个 Agent,在同一个 PR 里。命名测试告诉你往那里看;它不替你看。 + +**翻译必须有人写。** 运行时会回落到声明的标签,所以缺一条 `zh-CN` 会渲染成 `Budget`,而不是空白。这是对的默认值,但仍然是中文用户看得见的缺口。翻译是最严格意义上的钉死投影:它里面的每一个字符串都是一个决定。 + +**外部消费者在栈之外。** 门禁自己就这么说:「a consumer can legitimately live outside the stack — an API client, a hook or package this stack does not carry, a Studio-authored view」。另一个工具里按名字选取 `estimated_hours` 的仪表盘,钉在那个字段上,而仓库里没有任何东西知道它。命名测试对它适用;枚举对它不适用。 + +**语义改动不是结构改动。** 加字段是最简单的情形,所以才选它来做一次干净的前后对照。换成改一个字段的*含义*——比如给 `status` 加一个 `cancelled` 选项——推导面会同样忠实地跟上,但 Todo 示例的 *Overdue Tasks* 视图和它的 *Overdue Tasks* 指标都把「逾期」编码成 `status != completed`,于是一条截止日期已过的已取消任务,在两处都会被算作逾期。这是对视图和数据集定义的一次阅读,不是一次测量,而它正是命名测试会把你指过去——两个投影都写明了 `status`——却不替你拍板的那类后果。 + +## 把你的 Agent 指向它 + +上面这次运行可以从仓库复现,包装好之后大约一分钟,这意味着它也是你的 Agent 可以替你做的事。把一个编码 Agent 指向一个 ObjectStack 项目——脚手架会写出 `AGENTS.md` 并安装技能包,运行中的应用在 `/api/v1/mcp` 提供 MCP,[如何配置它](/en/blog/how-to-point-your-agent-at-objectstack/)覆盖了规则文件、技能和工具服务器——当它开出一个改字段的变更时,向它要它自己这份 diff 的命名测试表:哪些面跟上了,哪些投影写明了这个字段,它打算对每一个怎么办。门禁那行 `field-no-consumers` 是这张表的第一行,而 Agent 可以比你先读到它。 + +本文的一切都跑在开源栈上。「想把同一个循环托管起来、在浏览器里用、什么都不用安装?那就是 ObjectOS——建立在 ObjectStack 之上的商业运行环境。」ObjectOS 既可托管在浏览器中、无需安装(ObjectOS Cloud),也可自管部署在你自己的基础设施上(ObjectOS Enterprise);无论哪个版本,你都可以导出本体,拿到开源的 ObjectStack 运行时上运行。命名测试不随宿主而变;它读的那份定义,是同一个文件。 diff --git a/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.zh-Hant.mdx b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.zh-Hant.mdx new file mode 100644 index 0000000..5bfa952 --- /dev/null +++ b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/index.zh-Hant.mdx @@ -0,0 +1,125 @@ +--- +# @generated zh-Hant from zh-Hans (s2twp) — edit the zh-Hans file; delete this line to hand-maintain. +title: 檢視、儀表盤和應用是本體的投影:這對審閱意味著什麼 +description: 給 ObjectStack 自帶示例加一個欄位並逐面實測:資料表、REST、MCP 與控制台表單自己跟上,釘死欄位名的檢視、資料集和翻譯沒動。命名測試告訴審閱者哪個是哪個。 +author: ObjectStack Team +date: 2026-11-10 +status: published +topic: app-building +audience: developer +solutions: [] +industries: [] +cover: ./cover.svg +tags: + - 本體 + - 可執行業務本體 + - 可審閱的 diff + - 後設資料審閱 +--- + +下面就是整份 diff。五行,一個欄位,加在 ObjectStack 倉庫自帶的 Todo 示例的 `todo_task` 物件上: + +```diff + actual_hours: Field.number({ + label: 'Actual Hours', + scale: 2, + min: 0, + }), ++ ++ budget: Field.currency({ ++ label: 'Budget', ++ min: 0, ++ }), +``` + +在 ObjectStack 執行時上啟動之後,這五行改動了資料庫表、REST 的讀與寫、後設資料介面、MCP 的 `describe_object` 與 `query_records` 應答,以及控制台的新建表單——七個面,沒有一個被編輯過。它們沒有改動三個列表檢視、儀表盤的資料集和中文翻譯,因為這三樣東西都逐個寫明瞭欄位名。前一組是從定義推匯出來的,後一組是釘在定義上的。**命名測試**在你開啟任何一個投影之前就能把它歸到兩組之一:它寫明瞭這個欄位,你就審它;它只寫明瞭物件,執行時已經替你審過了。本文餘下的部分,是這句話背後的測量——在 ObjectStack `18.0.0-next.2` 上、示例原始碼取自提交 `43567a8f`——以及它對讀一份 diff 這件事改變了什麼。 + +## 投影的定義住在哪裡 + +投影是什麼,已經有三處定義,本文不重複。[定義那篇](/zh-Hant/blog/what-does-the-ontology-is-the-software-mean/)點名了四種投影,並給出區分核心與投影的刪除測試。[本體頁](/zh-Hant/ontology/)用一句話說明規則:改動核心,投影跟著變;刪掉一個投影,它所展示的業務不變。[術語表條目](/zh-Hant/glossary/ontology-projection/)補上了對審閱者的推論:只碰投影的 diff 改的是呈現,碰到核心的 diff 改的是權限與事實。 + +它們都沒說的是,投影是*怎麼*跟著變的。「跟著變」底下藏著兩種不同的機制,審閱者得知道眼前的是哪一種。下面這次執行回答的就是這個問題。 + +## 這次執行:一個欄位,十一個讀數 + +物件是自帶的 Todo 示例(`examples/app-todo`),從倉庫提交 `43567a8f` 複製出來,用已釋出的 `18.0.0-next.2` 包以 `os dev` 啟動。資料庫是預設的 SQLite 檔案;API key 用種子化的開發管理員通過 REST 簽發。每個讀數取兩次:一次在提交原樣的定義上,一次在上面那五行 diff 之後、帶 `--compile` 重啟之後。「之前」和「之後」就是這兩次的答案。 + +| 面 | 怎麼讀的 | 之前 | 之後 | 跟著變? | +| :--- | :--- | :--- | :--- | :--- | +| 資料庫表 `todo_task` | 對 SQLite 檔案執行 `PRAGMA table_info` | 27 列 | 28 列;`budget float` 加到了已有的表上 | 推導 | +| REST 讀 | `GET /api/v1/data/todo_task` | 每條記錄 27 個鍵 | 28 個鍵 | 推導 | +| REST 寫 | `POST /api/v1/data/todo_task`,帶 `budget: 1200` | `400`——`Unknown field 'budget' on object 'todo_task'`,錯誤碼 `INVALID_FIELD` | `201`,記錄已建立 | 推導 | +| 後設資料介面 | `GET /api/v1/meta/object/todo_task` | 27 個欄位 | 28 個;`{ label: 'Budget', type: 'currency', min: 0, … }` | 推導 | +| MCP `describe_object` | 對 `/api/v1/mcp` 發 `tools/call` | 19 個欄位 | 20 個 | 推導 | +| MCP `query_records` | `where: { budget: { $gte: 1000 } }, fields: ['subject', 'budget']` | — | `[["Renew the domain", 1200]]` | 推導 | +| 控制台新建表單 | `/_console/apps/todo_app/todo_task/new` | 16 個輸入項 | 17 個;*Budget* 按宣告順序出現在 *Actual Hours* 和 *Notes* 之間 | 推導 | +| 控制台記錄詳情 | 新記錄的頁面 | — | *Details* 區顯示 `BUDGET 1,200.00` | 推導 | +| 控制台列表檢視 *All Tasks* | `GET /api/v1/meta/view` → `todo_task.default` | 6 列 | 6 列,沒有變化 | 釘死 | +| 翻譯,`zh-CN` | 帶 `Accept-Language: zh-CN` 的 `GET /api/v1/meta/object/todo_task` | `subject` → 主題 | `budget` → `Budget`(回落到英文) | 釘死 | +| 儀表盤資料集 `task_metrics` | 資料集定義本身 | 6 個維度、3 個度量,每個都點名一個欄位 | 沒有變化;在某個度量點名 `budget` 之前,沒有任何圖表能畫它 | 釘死 | + +另有兩個讀數沒有變化,而它們也不是投影:OpenAPI 文件仍然為 `todo_task` 列出 14 條路徑,沒有逐欄位的 schema;MCP 的 `tools/list` 仍然應答同樣的 12 個工具(`list_objects`、`describe_object`、`validate_expression`、`query_records`、`aggregate_records`、`get_record`、`create_record`、`update_record`、`delete_record`、`list_actions`、`run_action`、`resume_run`)。兩者描述的都是物件,不是欄位;欄位出現在它們的應答裡,而不是它們的形狀裡。 + +![命名測試:中間是那份五行 diff;左邊是隻寫明物件、未經編輯就跟上了定義的面,附前後讀數;右邊是寫明瞭欄位名、原樣未動的投影;底部是構建門禁自己打出的警告——新欄位沒有任何消費者](./naming-test.svg) + +還有一個讀數是白送的。`os dev --compile` 在寫出工件之前會跑一遍編寫期規則,下面是門禁對這個新欄位列印的原話: + +```text +⚠ object "todo_task" · field "budget": declared, but nothing in this stack displays or reads it (inert) + fix: add it to a view column or a form section, or remove the declaration + rule: field-no-consumers at objects[0].fields.budget +``` + +`os explain field-no-consumers` 把「消費者」定義為「a view column, an inline grid column, a form section, a page binding, a flow node, a dataset or cube member, a dashboard widget, a formula, a validation, a hook or an action that names it」,並把它和「載體」區分開——載體「names a field without reading it」,例如「a translation label, a seed value, an import-mapping target, a permission grant」。門禁的這份清單,正是上表的右半欄。這是執行時在啟動之前就告訴你:哪些投影是釘在欄位上的,而其中還沒有任何一個接住這個新欄位。 + +## 命名測試 + +為什麼七個面跟著變、四個面原地不動?不是因為有的更重要。是因為它們各自寫明瞭什麼。 + +資料表、REST 路由、後設資料介面和 MCP 工具都是從物件生成的。SQL 驅動的結構同步遍歷 `obj.fields`,為每一項建一列;資料路由按物件註冊,讀物件當下有的全部欄位;`describe_object` 把 `Object.entries(def.fields)` 對映成它的應答;控制台發現 `todo_task` 沒有宣告表單檢視,就按物件自己的欄位排版。這些地方沒有一處持有一份欄位名清單。它們持有的是物件名,每次呼叫時向定義要其餘的一切——所以欄位不經編輯就到達了它們全部,而且是按宣告順序到達的。 + +列表檢視、資料集和翻譯包同樣是編寫出來的後設資料,而且每一個都逐字寫明欄位名:`columns: [{ field: 'subject' }, { field: 'status' }, …]`、`measures: [{ name: 'est_hours', field: 'estimated_hours', … }]`、`fields: { subject: { label: '主題' }, … }`。寫明瞭欄位的投影是在對這個欄位做一個選擇——這一列、這個標籤、這個聚合——執行時會原封不動地兌現這個選擇。你沒要的,它不會替你加。 + +於是,把測試說給審閱者聽: + +> **命名測試。** 對這份 diff 可能波及的每一個投影,問一句:它寫明的是這個欄位,還是隻有物件?只寫明物件的,是推匯出來的:你正在讀的 diff 就是全部改動,執行時已經把它傳播到位。寫明瞭欄位的,是釘死的:它沒有動,該不該動,由你判斷。 + +它和定義那篇的刪除測試是配套的。刪除測試把一份定義歸為核心或投影;命名測試把一次核心改動能波及的投影歸為推導或釘死。兩條合在一起,審閱者光憑 diff 就知道執行時做了什麼、還有什麼在等一個決定。 + +## 這對讀 diff 改變了什麼 + +實際後果是一套工作順序,而且比多數團隊現在遵循的那套更短。 + +**第一,定義那幾行,就是對所有推導面的審閱。** 沒有要陪著讀的建表遷移,沒有序列化器,沒有控制器,沒有工具清單。在上面的執行裡,`budget` 列存在是因為欄位存在;REST 寫入接受 `budget` 是因為欄位存在;Agent 的 `describe_object` 顯示它是因為欄位存在。欄位對,它們就對;欄位錯,它們就以和欄位一模一樣的方式錯。審五行審掉了七個面——這正是標題承諾的那句話,也正是術語表條目所謂「碰到核心的 diff 改的是權限與事實」的意思。 + +**第二,在釘死的投影裡搜這個欄位的名字——並且預期什麼都搜不到。** 新加的欄位不會出現在任何檢視、資料集或翻譯裡,因為它們一個都沒被編輯過。這不是執行時藏起來的遺漏,而是門禁打印出來的那條警告。審閱者要問的,正是門禁 `fix:` 那一行提出的問題:這個欄位該不該有一個消費者,是哪一個?對 `budget` 來說,大機率是 *All Tasks* 上的一列和一個 `zh-CN` 標籤;大機率暫時不是一個儀表盤度量。那是對三個具名檔案的三處小而有意的編輯,每一處都和最初那五行一樣可審。 + +**第三,把刪除當成另一種改動。** 上面說的全是加欄位。驅動的結構同步只做加法:它建缺失的表、加缺失的列,從不修改或刪除已有的列,所以一個被刪掉或改名的欄位會把它的列留在原地,成為 `os migrate` 報告出來、由人來對賬的漂移。推導面會立刻跟上刪除——REST 記錄丟掉那個鍵,`describe_object` 丟掉那個欄位——資料表卻不會。而每一個仍然寫明被刪欄位的釘死投影現在都錯了,同一道門禁會把它當作懸空繫結報出來。命名測試照樣適用;只是要看的清單更長,而且從遷移開始。 + +這套順序也補上了[四遍審閱清單](/en/blog/how-to-review-ai-generated-app-changes/)缺的那一半——它的第四遍是「欄位和檢視,放最後」。命名測試說的是:哪些檢視。寫明瞭這個欄位的那些。其餘的,在審欄位的時候已經審過了。 + +## 替釘死說句話 + +把上表的右半欄讀成一種侷限很容易,而誠實的讀法恰恰相反。 + +一張會自動把所有新列加進來的網格,會在每次開發者為了讓某條流程有地方寫而加個欄位時,把整個頁面重排一遍。一張會把所有數值欄位都畫出來的儀表盤,會把 `recurrence_interval` 畫出來。一份會自己發明標籤的翻譯,是一份沒人寫過的翻譯。釘死的投影之所以釘死,是因為有人做過一個呈現上的決定;一個悄悄推翻這個決定的執行時,正是這整套格式要防止的那件事——沒有任何人宣告過的行為。控制台自己的回退——沒宣告表單檢視時就把所有欄位排出來——恰好是證明這條規則的例外:它只在還沒有任何人做過決定的地方生效。 + +這套格式給審閱者的,不是釘死的投影會跟著變,而是它們*找得到*。它們是同一個倉庫裡的後設資料,它們寫明瞭欄位名,門禁能把它們枚舉出來。另一種情況——一份手寫的程式碼庫——是某個 React 元件裡的一份列清單、另一個目錄裡的一個序列化器、一個 JSON 檔案裡的一個標籤、第三套系統裡的一份圖表配置,而沒有任何東西知道它們說的是同一個欄位。 + +## 本文不覆蓋的 + +在一個示例上的測量,就是在一個示例上的測量。有四件事留在它之外。 + +**手工調過的檢視仍然是手工的。** Todo 示例裡每個列表檢視都聲明瞭列,CRM 示例裡每個檢視也是。新欄位到達一張釘死的網格,只能靠有人把它加進去——而這個「有人」常常就是加這個欄位的那個 Agent,在同一個 PR 裡。命名測試告訴你往那裡看;它不替你看。 + +**翻譯必須有人寫。** 執行時會回落到宣告的標籤,所以缺一條 `zh-CN` 會渲染成 `Budget`,而不是空白。這是對的預設值,但仍然是中文使用者看得見的缺口。翻譯是最嚴格意義上的釘死投影:它裡面的每一個字串都是一個決定。 + +**外部消費者在棧之外。** 門禁自己就這麼說:「a consumer can legitimately live outside the stack — an API client, a hook or package this stack does not carry, a Studio-authored view」。另一個工具裡按名字選取 `estimated_hours` 的儀表盤,釘在那個欄位上,而倉庫裡沒有任何東西知道它。命名測試對它適用;列舉對它不適用。 + +**語義改動不是結構改動。** 加欄位是最簡單的情形,所以才選它來做一次乾淨的前後對照。換成改一個欄位的*含義*——比如給 `status` 加一個 `cancelled` 選項——推導面會同樣忠實地跟上,但 Todo 示例的 *Overdue Tasks* 檢視和它的 *Overdue Tasks* 指標都把「逾期」編碼成 `status != completed`,於是一條截止日期已過的已取消任務,在兩處都會被算作逾期。這是對檢視和資料集定義的一次閱讀,不是一次測量,而它正是命名測試會把你指過去——兩個投影都寫明瞭 `status`——卻不替你拍板的那類後果。 + +## 把你的 Agent 指向它 + +上面這次執行可以從倉庫復現,包裝好之後大約一分鐘,這意味著它也是你的 Agent 可以替你做的事。把一個編碼 Agent 指向一個 ObjectStack 專案——腳手架會寫出 `AGENTS.md` 並安裝技能包,執行中的應用在 `/api/v1/mcp` 提供 MCP,[如何配置它](/en/blog/how-to-point-your-agent-at-objectstack/)覆蓋了規則檔案、技能和工具伺服器——當它開出一個改欄位的變更時,向它要它自己這份 diff 的命名測試表:哪些面跟上了,哪些投影寫明瞭這個欄位,它打算對每一個怎麼辦。門禁那行 `field-no-consumers` 是這張表的第一行,而 Agent 可以比你先讀到它。 + +本文的一切都跑在開源棧上。「想把同一個迴圈託管起來、在瀏覽器裡用、什麼都不用安裝?那就是 ObjectOS——建立在 ObjectStack 之上的商業執行環境。」ObjectOS 既可託管在瀏覽器中、無需安裝(ObjectOS Cloud),也可自管部署在你自己的基礎設施上(ObjectOS Enterprise);無論哪個版本,你都可以匯出本體,拿到開源的 ObjectStack 執行時上執行。命名測試不隨宿主而變;它讀的那份定義,是同一個檔案。 diff --git a/content/blog/views-dashboards-apps-are-projections-of-the-ontology/naming-test-en.svg b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/naming-test-en.svg new file mode 100644 index 0000000..8066cb8 --- /dev/null +++ b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/naming-test-en.svg @@ -0,0 +1,124 @@ + + + + + + + + + + The naming test: does the projection name the field, or only the object? + + + + + THE DIFF · src/objects/task.object.ts + + budget: Field.currency({ + + label: 'Budget', + + min: 0, + + }), + + + + + + + + + + NAMES ONLY THE OBJECT + Derived: it followed + Generated from the object on every + call. The diff is the whole review. + 7 surfaces · 0 edits + + + + + + NAMES THE FIELD + Pinned: it stayed + An authored choice per field. Still + as written; whether it should move + is the reviewer's call · 4 projections + + + + + + BEFORE → AFTER, MEASURED + + + SQLite table todo_task (PRAGMA table_info) + 27 → 28 columns + budget float, added to the existing table by the additive sync + + GET /api/v1/data/todo_task + 27 → 28 keys + + POST /api/v1/data/todo_task with budget: 1200 + 400 → 201 + before: Unknown field 'budget' on object 'todo_task' (INVALID_FIELD) + + GET /api/v1/meta/object/todo_task + 27 → 28 fields + + MCP describe_object + 19 → 20 fields + + MCP query_records, where budget ≥ 1000 + 1 record, 1200 + + Console create form (no form view declared) + 16 → 17 inputs + Budget placed between Actual Hours and Notes, in declaration order + + Unchanged by design, not projections: OpenAPI (14 paths), MCP tools/list (12 tools) + + + + + + AS AUTHORED, UNCHANGED + + + todo_task.default — All Tasks + 6 columns + columns: [{ field: 'subject' }, { field: 'status' }, …] + + todo_task.overdue · todo_task.due_today + 5 + 5 columns + + Dataset task_metrics (feeds the dashboard) + 3 measures + measures: [{ name: 'est_hours', field: 'estimated_hours' }, …] + + Translation bundle, zh-CN + 19 labels + budget → "Budget" (declared label falls through) + + Each one spells the field name, so each one is a + decision the runtime honours exactly. The reviewer + asks the gate's question: should this field have a + consumer, and which one? + + Removals differ: the sync never drops a column, so the table lags as drift + + + + + + THE GATE SAID SO BEFORE BOOT · os dev --compile, 49 author-time rules + ⚠ object "todo_task" · field "budget": declared, but nothing in this stack displays or reads it (inert) — rule: field-no-consumers + + diff --git a/content/blog/views-dashboards-apps-are-projections-of-the-ontology/naming-test.svg b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/naming-test.svg new file mode 100644 index 0000000..c350e05 --- /dev/null +++ b/content/blog/views-dashboards-apps-are-projections-of-the-ontology/naming-test.svg @@ -0,0 +1,124 @@ + + + + + + + + + + 命名测试:这个投影写明的是字段,还是只有对象? + + + + + 这份 DIFF · src/objects/task.object.ts + + budget: Field.currency({ + + label: 'Budget', + + min: 0, + + }), + + + + + + + + + + 只写明对象 + 推导:它跟上了 + 每次调用都从对象生成。 + 这份 diff 就是全部审阅。 + 7 个面 · 0 处编辑 + + + + + + 写明了字段 + 钉死:它没动 + 逐字段的一次编写决定。 + 原样未动;该不该动, + 由审阅者判断 · 4 个投影 + + + + + + 之前 → 之后,实测 + + + SQLite 表 todo_task(PRAGMA table_info) + 27 → 28 列 + budget float,由只做加法的同步加到已有的表上 + + GET /api/v1/data/todo_task + 27 → 28 个键 + + POST /api/v1/data/todo_task,带 budget: 1200 + 400 → 201 + 之前:Unknown field 'budget' on object 'todo_task'(INVALID_FIELD) + + GET /api/v1/meta/object/todo_task + 27 → 28 个字段 + + MCP describe_object + 19 → 20 个字段 + + MCP query_records,where budget ≥ 1000 + 1 条记录,1200 + + 控制台新建表单(未声明表单视图) + 16 → 17 个输入项 + Budget 按声明顺序排在 Actual Hours 和 Notes 之间 + + 按设计不变、也不是投影:OpenAPI(14 条路径)、MCP tools/list(12 个工具) + + + + + + 原样,没有变化 + + + todo_task.default — All Tasks + 6 列 + columns: [{ field: 'subject' }, { field: 'status' }, …] + + todo_task.overdue · todo_task.due_today + 5 + 5 列 + + 数据集 task_metrics(供给仪表盘) + 3 个度量 + measures: [{ name: 'est_hours', field: 'estimated_hours' }, …] + + 翻译包,zh-CN + 19 个标签 + budget → "Budget"(回落到声明的标签) + + 每一个都写明了字段名,所以每一个都是 + 运行时原样兑现的一个决定。审阅者问的是 + 门禁的那个问题:这个字段该不该有消费者, + 是哪一个? + + 删除不同:同步从不删列,所以数据表会以漂移的形式滞后 + + + + + + 门禁在启动前就说了 · os dev --compile,49 条编写期规则 + ⚠ object "todo_task" · field "budget": declared, but nothing in this stack displays or reads it (inert) — rule: field-no-consumers + +