Repository navigation
Commit 493a6c4
feat(spec,metadata-protocol): a seed dataset column can be written only when its row is created (
<!-- claude-projects-attribution: {"github_login":"marchtian"} -->
_Requested by **田騏** · [project
thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiVXmt4GnURpCsmf91ag9xzG)_
Fixes #25761
Clause-②: yes
Metadata-protocol: added
Ruling-ref: hotclm#41 comment 6395830052 (letter A); triage 6396236242
## Ruling
Verbatim, letter A: "job-written columns are written on first seed only
— via the platform".
Triage 6396236242: "Same name and semantics as the import door's
`applyOn` (#25577 / PR #25664): spec key first, then the seed loader's
upsert path. Read beside #8740: the driver's empty-merge-set fallback
must not re-admit a `'create'` column."
## What changed
**Spec (`packages/spec/src/data`).**
- `ColumnApplyOnSchema = z.enum(['create', 'always'])` is the one enum,
in `data/column-apply-on.zod.ts`. The import door's
`FieldMappingEntry.applyOn` (`api/export.zod.ts`) now reads it instead
of spelling its own, and `SeedColumn.applyOn` reads the same one. Each
keeps its own `.default('always')`. The import door's emitted JSON
schema is byte-unchanged.
- `SeedColumnSchema` is a closed per-column shape (`{ applyOn }`,
default `always`). `SeedSchema` gains an optional `columns` map from a
field name to its `SeedColumn`. A map makes two policies for one field
unrepresentable.
- `SeedSchema` refuses an unknown key on a column (`unrecognized_keys`).
A dataset-level `applyOn` is refused with a pointer to `columns`
(`guidance`).
- `defineSeed` types the `columns` keys against the object's fields plus
its injected system columns (the `SeedColumns` type). It also refuses an
undeclared key when it runs, in the same refusal as an undeclared record
key, with a near miss.
**Seed loader (`packages/metadata-protocol/src/seed-loader.ts`).**
- `createOnlySeedColumns` is the set a dataset declares `applyOn:
'create'`, minus the fields of its `externalId`. A key field is what a
matched row was found by, so it stays in that row's update, as an
import's match key does.
- On a row the load MATCHED (`existingRecords` has its natural key, the
same lookup `decideWriteAction` makes), those columns are deleted from
the record before the no-op check and before the write. The batched path
and the sequential self-referencing path both read that same record.
- A create-only reference on a matched row is skipped in the resolution
loop: not resolved, not dropped for failing to resolve, and not
deferred. Pass 2's back-fill would otherwise write it after all.
Create-only references resolve last, so a reference column of a
composite `externalId` has resolved by the time the loader asks whether
the row matched.
- `warnOnUndeclaredSeedColumns` logs at `error`, once per dataset, when
a loaded seed's `columns` names a column the object does not have. The
level follows AGENTS.md "Degradation log levels": the load looks normal
while a job's write to the intended column is overwritten on every
replay. The rows still load. The finding is log-only, because
`result.errors` holds per-record `ReferenceResolutionError`s
(`recordIndex` is `.int().min(0)`) and has no shape for a dataset-level
finding.
- A row the seed creates, a dry run, and the `insert` / `ignore` /
`replace` modes are unchanged.
**Docs, ledger, generated.**
- A new section in `content/docs/data-modeling/seed-data.mdx`, and the
`defineSeed` API reference.
- A `seed/columns` liveness row (live, with `children.applyOn`) and
regenerated state counts.
- `ColumnApplyOn` names the author state (ADR-0122) and is pinned
isomorphic in `type-alias-convention.pin.test.ts` (790 → 791).
- The hand-kept schema counts move with the new module:
`packages/spec/llms.txt` 206 → 207 (data 30 → 31), and
`content/docs/getting-started/quick-reference.mdx` data 29 → 30.
- Regenerated: the reference docs; the api-surface, export-origins,
declaration-map, authorable-surface and json-schema manifest shards; and
the two skills reference indexes.
## PM assumptions, measured
1. "Runtime: `packages/runtime/src/seed-loader.ts`". Refuted: that file
is a re-export shim. The loader body is
`packages/metadata-protocol/src/seed-loader.ts`, so the change lives
there (file-surface deviation below).
2. "The import door's enum lives at
`packages/spec/src/api/export.zod.ts` (~:173)". Held, but it was spelled
inline. It is extracted to `data/column-apply-on.zod.ts` because `data/`
never imports from `api/`, and both doors read it. A test pins one-enum
parity in `seed.test.ts`: the options and default of
`FieldMappingEntrySchema`'s `applyOn` equal `SeedColumnSchema`'s.
3. "The seed loader has no per-column policy today". Held: at `5f5f0b42`
there was no per-column key on `SeedSchema`, and nothing in the loader
read one.
## Read beside #8740
The driver's merge-ALL fallback for an empty merge set lives on
`driver.upsert`. The seed path never calls `driver.upsert`: there is no
`upsert(` call in `seed-loader.ts`, and a matched row is written with
`engine.update(object, record, { where: { id } })`.
With every non-key column create-only, the patch is left holding only
the key columns, which equal the stored row. `isNoOpReplay` therefore
skips the row, and no write is issued. A test pins it ("every non-key
column create-only → no update call").
## Tests
- `packages/metadata-protocol/src/seed-loader-replay.test.ts` has a new
describe, "a column declared applyOn: create is written on first seed
only (#25761)". It runs on the faithful engine double, over both the
batched path and the sequential self-referencing path, and covers:
- a fresh insert writes both kinds of column;
- a re-seed after a "job" changed a create-only column keeps it while an
always column re-asserts, for `upsert` and `update`, with the update's
keys pinned exactly;
- a control dataset without the policy re-asserts;
- a no-op replay;
- every non-key column create-only → no update;
- a create-only reference on a matched row is neither resolved, deferred
nor dropped;
- a control: an unresolvable reference on a new row is still deferred;
- a composite `externalId` with a create-only reference declared before
a key reference;
- an `externalId` column declared create-only stays in the update (both
rows updated);
- a dry run;
- the `error` report on a misspelt column, and no report on an injected
system column (`owner_id`).
- `packages/spec/src/data/seed.test.ts` "column policies":
- accepts both values and defaults to `always`;
- refuses `'insert'` with `code: invalid_value` at
`['columns','status','applyOn']`;
- refuses an unknown column key with `code: unrecognized_keys` at
`['columns','status']`;
- points a dataset-level `applyOn` at `columns`;
- pins one-enum parity.
- `packages/spec/src/data/define-seed-record-keys.test.ts`: four
`columns` key cases, including the compile-time refusal
(`@ts-expect-error`).
At the final head `0a22461b`, under the shared verify lock (`VERDICT
command-exit 0`):
- `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter
@objectstack/metadata-protocol typecheck` are green.
- `pnpm --filter @objectstack/spec test`: Test Files 721 passed (721),
Tests 21844 passed, 1 todo.
- `pnpm --filter @objectstack/metadata-protocol test`: Test Files 263
passed, 3 skipped (266), Tests 21511 passed, 19 skipped.
Ablations: each was red, and each was restored byte-identical to `HEAD`
(blob hash compared, `git diff HEAD` empty):
| mutation | result |
|---|---|
| strip of create-only columns on a matched row removed | 9 failed / 45
|
| in-loop skip of a create-only reference removed | 2 failed / 45 |
| create-only references resolved in declared order | 1 failed / 46 |
| `warnOnUndeclaredSeedColumns` call removed | 1 failed / 46 |
| `defineSeed` columns-key check removed | 3 failed / 13 |
| undeclared-column report at `warn` instead of `error` | 1 failed / 46
|
## Review
An independent full review of the whole diff by an isolated `fable`
reviewer ran in four rounds. Every round returned no must-fix. Round 1's
should-fix notes were applied in `986fb436`. Round 2's dataset-level
`result.errors` note is answered in `d2319759`, and the reviewer
accepted that answer. The verdicts and each note's disposition are
posted as a comment on this PR.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `0a22461b` derives 120 commands. All 120
ran on that head, each exit code captured before any pipe, and all 120
exited 0. The `--ran` reconciliation over those codes reads: "120
derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED (a DERIVED
zero — all 120 recorded an exit code and none of them is 3)". The
local-only `check:dev-prereqs` and `check:skill-frame-freshness`, and
`check:nul-bytes`, also exit 0.
What the first pass (before the merge and the last fixes) found:
- Red, and fixed here: `check:llms-txt` and
`check:quick-reference-counts` (the hand-kept counts), and
`check:spec-parsed-alias` (the ADR-0122 alias).
- Red only on main's `translation/flows.screens` row: `check:liveness`
and `check:generated`. They are green after the merge.
- Exit 3 until the environment was fixed: `check:plugin-teardown-shape
--self-test`, on the shallow clone until its pinned fixture commit was
fetched; `check:skill-examples`, `check:dual-build-cjs-loads` and
`check:lean-entry-closure`, until the packages were built.
The PR head `aea8b0d3` adds one commit after that run, the changeset
title, text only (`FIELD` for an angle-bracket placeholder). The derived
list is unchanged on it, and the 7 changeset families rerun there exit
0.
The dogfood battery is not owed: there is no `!` in the title, and
`Clause-②: yes` has no narrowing arm. The families CI runs are reported
by this PR's check runs.
`main` moved three commits (#25748, #25789, #25804) after the merge.
`git merge-tree` against it is clean, none of their files is in this
diff, and none of them lands a sibling card.
## Deviations
- File surface: the claim names `packages/runtime/src/seed-loader.ts`,
but the loader body is in
`packages/metadata-protocol/src/seed-loader.ts` (assumption 1). The
extracted enum is a new file in `packages/spec/src/data`, and
`packages/spec/src/api/export.zod.ts` changes one import. Hand-kept
counts in `packages/spec/llms.txt` and
`content/docs/getting-started/quick-reference.mdx`, plus the alias pin
file, moved with the new module.
- `skills/**` is touched only by generator-owned rows
(`gen:skill-refs`). The readings:
- `skills/objectstack-data/references/_index.md`: 72 → 73 lines;
- `skills/objectstack-platform/references/_index.md`: 41 → 41 lines;
- all `SKILL.md` together: 4383 → 4383 lines;
- the whole `skills/` tree: 13350 → 13351 lines.
The order carried no skills net-add budget.
- Line budget: 36 files, +877 / −62. Hand-written: 10 files, +352 / −25.
Tests: 4 files, +411 / −4. Generated: 22 files, +114 / −33.
## Acceptance notes
- `packages/rest/src/import-prepare.ts:620` still spells `'always'` /
`'create'` by hand where it validates a mapping entry's `applyOn`,
instead of reading `ColumnApplyOnSchema`. The schema itself has one
spelling now, so the remaining copy is unexercised drift in another
package. Carrier: none.
- A `columns` key that names no column is refused by `defineSeed`, and
only logged at `error` for a seed that arrives stored (the publish door
accepts it, since `SeedSchema` cannot know the object's fields).
Carrying a dataset-level finding in the load result, or refusing it at
the stored-seed publish door, would change the seed loader's result
contract or the publish path. Carrier: none.
- `check:liveness` was red on `main` `5f5f0b42`
(`translation/flows.screens` → `engine.ts`), unrelated to this diff.
`main`'s #25796 fixed it, and it is green here after the merge.
## 维护者速读(草稿)
**改了什么**:种子数据(seed)现在可以给单个字段声明写入策略,写法是 `columns: { status: { applyOn:
'create' } }`。声明为 `create`
的字段只在种子第一次创建这条记录时写入。之后每次重放(开发服务器启动、包重新发布),只要匹配到已有记录,这个字段就不再被覆盖,其余字段照旧刷新。取值和含义与导入映射的
`applyOn` 完全一致,两边共用同一个枚举。
**为什么改**:应用自己的作业会在首次播种后改写某些字段,比如付款作业推进的状态、续约作业设置的标记。以前每次重放种子都会把这些字段改回初始值,悄悄撤销作业的结果,日志里也没有痕迹。这是维护者裁决
A:"作业写入的字段只在首次播种时写入,由平台负责"。
**风险与代价(含回滚)**:新增的是可选键,默认值 `always` 就是改动前的行为,不声明就没有任何变化。影响只限于 `upsert` 和
`update` 两种模式下已经匹配到的行。如果 `columns` 写错了字段名,`defineSeed`
会直接拒绝;存储的种子则在加载时记一条 error 日志,数据照常加载。回滚只需撤销本 PR,已经写入的数据不受影响。`skills/**`
的变化只有两份生成的索引,一共多一行。
**席位意见**:
**你要做的**:审阅并批准。本 PR 触及受管路径 `skills/**`,需要维护者批准后才能合并。
---
_Generated by [Claude
Code](https://claude.ai/code/session_018HE4VY52U3L5Bfzz51dsFv)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_018HE4VY52U3L5Bfzz51dsFv
---------
Co-authored-by: Claude <noreply@anthropic.com>columns.FIELD.applyOn: 'create') (#25813)1 parent 17c5e68 commit 493a6c4
35 files changed
Lines changed: 875 additions & 60 deletions
File tree
- .changeset
- content/docs
- data-modeling
- getting-started
- references
- api
- data
- kernel
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- metadata-protocol/src
- spec
- api-surface
- authorable-defaults
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- liveness
- state-counts
- src
- api
- data
- skills
- objectstack-data/references
- objectstack-platform/references
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
147 | 147 | | |
148 | 148 | | |
149 | 149 | | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
150 | 196 | | |
151 | 197 | | |
152 | 198 | | |
| |||
587 | 633 | | |
588 | 634 | | |
589 | 635 | | |
| 636 | + | |
590 | 637 | | |
591 | 638 | | |
592 | 639 | | |
| |||
596 | 643 | | |
597 | 644 | | |
598 | 645 | | |
599 | | - | |
| 646 | + | |
| 647 | + | |
600 | 648 | | |
601 | 649 | | |
602 | 650 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
| 25 | + | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
| 102 | + | |
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
| |||
281 | 281 | | |
282 | 282 | | |
283 | 283 | | |
284 | | - | |
| 284 | + | |
285 | 285 | | |
286 | 286 | | |
287 | 287 | | |
| |||
345 | 345 | | |
346 | 346 | | |
347 | 347 | | |
348 | | - | |
| 348 | + | |
349 | 349 | | |
350 | 350 | | |
351 | 351 | | |
| |||
0 commit comments