Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/spec-artifacts-sharded-by-category.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/spec": patch
---

refactor(spec): 三个热点生成物按 category / entrypoint 分片,合并队列的串行税消失 (#5837)

`merge=os-regen`(#4675)只在**本地** git 生效。合并队列在服务端重建 PR 时不跑自定义
merge driver,所以两个都动过 `authorable-surface.json`(310KB、7941 行排序数组,每个
spec PR 都会重写)的 PR 在队列里是纯文本冲突,第二个必然被踢——spec 车道一次只能放行
一单。driver 自己的注释里记着这笔税的形状:一个下午 4 次合并 9 处冲突,**零**真语义
冲突,全部是集合并集。付的是「单体文件」这个形状的钱,不是分歧的钱。

**布局变化(发布产物路径,见下方档位说明)**

| 之前 | 现在 | 分片键 |
|:---|:---|:---|
| `packages/spec/authorable-surface.json` | `packages/spec/authorable-surface/< category >.json` | def key 的 category 段 |
| `packages/spec/json-schema.manifest.json` | `packages/spec/json-schema.manifest/< category >.json` | 同上 |
| `packages/spec/api-surface.json` | `packages/spec/api-surface/< entry >.json` | 已发布入口(`.` → `root.json`) |

有意维持单体:`spec-changes.json`(按版本键控)、`api-surface-signatures.json`(1.3KB)、
`authorable-surface.base.json`(只有显式 `--update-base` 会写,从不在 churn 路径上,且
它的 `baseRev` 是整个 surface 的**一个** commit——分片会让不同分片镜像不同 revision,
那是任何上游 commit 都没有过的状态)。

**ratchet 语义逐条不变。** 所有门禁读**整个目录**当作一个集合,而不是「这次构建会写的
那些分片」:删掉一整个分片文件 = 删掉它的 key,checks (a)/(c) 看到的缺失 key 与从前删
单体文件里的行时一模一样;没人重生成的分片报**陈旧**而不是被跳过;#4662 的逐字节规范
形式比对现在是逐分片做的,手改因此还能被**指名到文件**。#5976 的 def key 撞名守卫不受
影响——它按剥后缀的 schema 名判定(`shared/HttpMethod`),与输出路径无关,且仍在两个
ratchet 之前运行。

**对消费者的可见影响,以及为什么是 patch。** `@objectstack/spec` 的 `files` 里
`api-surface.json` 改成了 `api-surface`,所以 npm 包内该快照的路径变了(它不在
`exports` 里,不是可 import 的子路径,是给工具读的文件)。导出面本身**零变化**
(`check:api-surface` 实测 0 breaking / 0 added),运行时行为、类型、schema 一律未动,
因此不是 major;仓内唯一的读点(release 工作流的 surface diff)随之更新,并且会按上游
tarball 实际携带的形状读取——`api-surface/` 目录(本次起)、`api-surface.json` 单文件
(protocol 15 起至本次)、两者皆无(protocol 15 之前)。外部若有直接读
`node_modules/@objectstack/spec/api-surface.json` 的工具,改读 `api-surface/` 目录并把
各分片的 `exports` 按 `entry` 合并即可,内容逐条相同。
9 changes: 5 additions & 4 deletions .claude/skills/pm-dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1385,9 +1385,10 @@ file-disjoint;它管不到**先后两单都碰 `packages/spec` 生成物**的情

```
packages/spec/spec-changes.json
packages/spec/authorable-surface.json
packages/spec/json-schema.manifest.json
packages/spec/api-surface.json
packages/spec/authorable-surface/**
packages/spec/authorable-surface.base.json
packages/spec/json-schema.manifest/**
packages/spec/api-surface/**
packages/spec/api-surface-signatures.json
docs/protocol-upgrade-guide.md
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Expand Down Expand Up @@ -1423,7 +1424,7 @@ content/docs/references/**

> 断言 `pnpm --filter @objectstack/spec check:authorable-surface` **绿**即可。锚点
> authenticity 的定义是两件事:`baseRev` 是 `origin/main` 的**祖先**,且它记录的 keys
> 与**该 commit** 的 `authorable-surface.json` 逐行一致(`verifyCommittedSurfaceBase`
> 与**该 commit** 的 authorable surface 逐行一致(`verifyCommittedSurfaceBase`
> 就查这两条)。`baseRev` **允许滞后** —— `gen:schema` 只在 keys 真的漂移时才重写它
> (在 `main` 上 merge base 就是 HEAD,该文件**必然**落后自己的 surface 一个 PR),
> 滞后只打一行 `ℹ️`,不是错误。⛔ 禁止为了凑「相等」手改锚点文件 —— 那正是 #4650
Expand Down
16 changes: 10 additions & 6 deletions .claude/skills/spec-property-retirement/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ Note template for a tombstone entry (verbatim house style, e.g.
- 把「整 def 删除」的零变化**判成正常** → 放过一个**根本没真正删掉**的 def。

所以验收顺序是:**先确定路线,再决定该期待什么读数**,不要反过来用读数去猜路线。
整 def 删除还有一条自证信号:`json-schema.manifest.json` 的 ratchet(#2978)会先开火,
整 def 删除还有一条自证信号:`json-schema.manifest/`(#5837 起按 category 分片)的
ratchet(#2978)会先开火,
要求你**有意删除**对应的 manifest key;删完重跑,per-key ratchet 会自行判定为 #4650
路径 3(`def no longer emitted by this build`)。这串输出本身就是路线的证据,留在 PR 里。

Expand Down Expand Up @@ -190,7 +191,7 @@ follows. Write both.
reference untouched when nothing matched.
- [ ] **A `RETIRED_KEYS_BY_MAJOR` entry** in
`packages/spec/src/migrations/registry.ts` — the literal
`'<defKey>:<name>'` as `authorable-surface.json` spells it, minus the
`'<defKey>:<name>'` as `authorable-surface/<category>.json` spells it, minus the
`[RETIRED]` mark, under this major. This is the string gate (b) reads, by
exact set membership; nothing is inferred and nothing radiates from a
neighbouring key. The gate's failure prints the line to paste. ⚠ Do **not**
Expand Down Expand Up @@ -257,7 +258,7 @@ Work top to bottom; each line has a gate behind it.
consumer goes with it (`PerformanceConfigSchema`, `AIKnowledgeSchema`,
`ToolCategorySchema`). An exported schema with no consumer is read as a
capability by whoever finds it (#3950 precedent). This — and *only* this —
moves `api-surface.json`: that snapshot prints type *references*, not
moves `api-surface/`: that snapshot prints type *references*, not
expanded shapes, so it is blind to key-level narrowing (#3883 removed three
keys from `defineAction`'s input and the snapshot did not change). Its gate
also lives in a different workflow (`TypeScript Type Check`, not
Expand All @@ -268,9 +269,12 @@ Work top to bottom; each line has a gate behind it.
once; regenerate the counts with the python snippet in the README rather
than hand-editing).
- [ ] **Generated baselines** — `pnpm --filter @objectstack/spec gen:schema`
moves `authorable-surface.json` (tombstone → a new `… [RETIRED]` line;
strict removal → the line **vanishes**, which is gate (a)'s trip wire, so
delete it in the same PR deliberately) and `json-schema.manifest.json`.
moves `authorable-surface/<category>.json` (tombstone → a new
`… [RETIRED]` line; strict removal → the line **vanishes**, which is gate
(a)'s trip wire, so delete it in the same PR deliberately) and
`json-schema.manifest/<category>.json`. Both are sharded by category since
#5837 — the gates read the whole directory as one set, so the retirement
procedure is unchanged; only which file the line lives in moved.
Then `gen:spec-changes`, `gen:upgrade-guide`, `gen:api-surface`,
`gen:docs`. See AGENTS.md for the you-changed-X → regenerate-Y table.
- [ ] **Forms** — prune the `{ field: '<key>' }` input from
Expand Down
18 changes: 13 additions & 5 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@
# two PRs each add or drop a few lines the result is a set union — fully
# composable — but a three-way TEXT merge reports it as a conflict a human must
# resolve by hand. The correct resolution is always the same: discard both sides
# and re-run the generator. `authorable-surface.json` alone is a 8k-line sorted
# array, so any two PRs landing near each other collide.
# and re-run the generator.
#
# ⚠️ The driver is a LOCAL git facility, and that bounds what it can fix (#5837).
# The GitHub merge queue rebuilds each PR server-side, where no custom merge
# driver runs — so for the three hottest artifacts a driver could not stop the
# queue from evicting the second PR that touched them. Those three are now
# SHARDED, one file per category / entry point, so PRs touching different
# categories touch disjoint files and the queue has nothing to conflict on. They
# stay routed here as directories: the driver still owns a same-category
# collision, which is the residue sharding cannot remove.
#
# `merge=os-regen` hands those paths to `scripts/git-merge-regen.mjs`, which does
# NOT text-merge them. See that file for why it also does not regenerate them
Expand Down Expand Up @@ -36,10 +44,10 @@
# ledger's prose is hand-written and must never be resolved by regenerating.

packages/spec/spec-changes.json merge=os-regen
packages/spec/authorable-surface.json merge=os-regen
packages/spec/authorable-surface/** merge=os-regen
packages/spec/authorable-surface.base.json merge=os-regen
packages/spec/json-schema.manifest.json merge=os-regen
packages/spec/api-surface.json merge=os-regen
packages/spec/json-schema.manifest/** merge=os-regen
packages/spec/api-surface/** merge=os-regen
packages/spec/api-surface-signatures.json merge=os-regen
docs/protocol-upgrade-guide.md merge=os-regen
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md merge=os-regen
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ jobs:
# generated page and the filter kept watching the old path, so hand-edits
# went unchecked for months; #3855 listed specific spec paths but no schema
# dirs, so `check:authorable-surface` went dormant on exactly the PRs that
# remove an authorable key; and `json-schema.manifest.json` — the #2978
# remove an authorable key; and the json-schema manifest — the #2978
# ratchet that is the ONLY durable record of every emitted schema, since
# json-schema/ is gitignored — was never watched at all, so a PR retiring a
# key from it skipped its own verifier. Six gates had already escaped to
Expand Down Expand Up @@ -579,7 +579,7 @@ jobs:
run: pnpm --filter @objectstack/spec check:upgrade-guide

# The authorable KEY surface — what a metadata author may write, which for
# this platform is the third-party API. `api-surface.json` records exported
# this platform is the third-party API. `api-surface/` records exported
# names and `api-surface-signatures.json` hashes factory types as TypeScript
# PRINTS them (a reference, never structurally expanded), so neither sees a
# key added to or removed from a schema. #3883 removed three authorable keys
Expand Down Expand Up @@ -712,7 +712,7 @@ jobs:
- name: Check @objectstack/spec public API surface
run: pnpm --filter @objectstack/spec run check:api-surface

# Same surface, the other axis: api-surface.json records that an export
# Same surface, the other axis: api-surface/ records that an export
# EXISTS, never what it resolves to — so four exported types sat at `any`
# across a whole major with every gate green (#4171). #4115 tells consumers
# to replace a local declaration with the spec import, which for those four
Expand All @@ -725,7 +725,7 @@ jobs:
- name: Check no exported spec type resolves to `any`
run: pnpm --filter @objectstack/spec run check:exported-any

# Third axis on the same surface: api-surface.json shows a name on two
# Third axis on the same surface: api-surface/ shows a name on two
# entries but not whether the two are ONE declaration re-exported (fine)
# or TWO declarations sharing a name — the #4411 trap, where which type a
# consumer gets depends on nothing but the import path and the copy that
Expand Down
30 changes: 23 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,28 @@ Even inside your own worktree, operate defensively:
11. **Generated artifacts don't text-merge — a driver defers them and
`pre-commit` collects the debt.** §10's "never trust git's textual merge of a
generated file" is now mechanical (#4675). `.gitattributes` routes the
generator-owned artifacts (`spec-changes.json`, `authorable-surface.json`,
`api-surface*.json`, `json-schema.manifest.json`,
generator-owned artifacts (`spec-changes.json`, `authorable-surface/**`,
`authorable-surface.base.json`, `api-surface/**`,
`api-surface-signatures.json`, `json-schema.manifest/**`,
`docs/protocol-upgrade-guide.md`, `content/docs/references/**`) to
`merge=os-regen`, so a merge that used to stop on conflicts across all of
them now stops only on the hand-written files that actually need you.

**The driver is a LOCAL facility, and #5837 is where that bound showed.** The
GitHub merge queue rebuilds each PR server-side, where no custom merge driver
runs — so two PRs that both touched `authorable-surface.json` (a 310KB sorted
array every spec PR rewrites) were a plain textual conflict there and the
second was evicted, capping the spec lane at one PR at a time. The three
hottest artifacts are therefore **sharded**: `authorable-surface/<category>.json`,
`json-schema.manifest/<category>.json`, `api-surface/<entry>.json`. PRs
touching different categories now touch disjoint files, and the driver keeps
the residue (two PRs in the same category). Every gate reads the whole
directory as one set, so the ratchet semantics are unchanged — see
`packages/spec/scripts/lib/sharded-artifacts.ts`. Deliberately still single
files: `spec-changes.json` (keyed by version), `api-surface-signatures.json`
(1.3KB) and `authorable-surface.base.json` (written only by an explicit
`--update-base`, so never on the churn path).

The driver does **not** regenerate. Git runs merge drivers *while* it merges,
in index order, so the worktree still holds pre-merge sources — a generator
run there would describe a half-merged tree and write a confidently wrong
Expand Down Expand Up @@ -382,7 +398,7 @@ regenerate up front:
| The react-blocks contract | `check:react-blocks` | `gen:react-blocks` |

A `.describe()` string counts — it is not "just a comment", it lands in
`content/docs/references/`. Adding one export counts — it lands in `api-surface.json`.
`content/docs/references/`. Adding one export counts — it lands in `api-surface/`.
Both were learned the hard way in #4040: two separate red builds, neither a logic error.

Don't match by hand — one command runs **every** gate and reports **all** stale
Expand All @@ -401,7 +417,7 @@ regenerate those.

**No `check:` script regenerates anything — that is the point of the split, not an
oversight.** `check:docs` used to begin with `pnpm gen:schema`, which rewrites two
*tracked* files (`json-schema.manifest.json`, `authorable-surface.json`) whenever they
*tracked* files (`json-schema.manifest/`, `authorable-surface/`) whenever they
are behind: running the gate edited your working tree and reported nothing, so a
`check:generated` run on a stale manifest printed a red `check:authorable-surface`
over a file the gate two lines below had already quietly fixed (#4711, #4723). The
Expand Down Expand Up @@ -434,7 +450,7 @@ inline when that gate is the one failing.)
pure checks with no generator — a failure there is a real finding to fix, not an artifact
to regenerate. `check:generated` names them as deliberately not run, so its "all up to
date" never reads as "everything passed". The last one asks the third question about the
export surface (#4446): `api-surface.json` shows a name on two entries but not whether
export surface (#4446): `api-surface/` shows a name on two entries but not whether
that is one declaration re-exported (fine) or two declarations sharing a name — the #4411
trap, judged by symbol identity against the built dist, with the accepted cases in the
shrink-only `dual-source-exports.baseline.json` (hand-edited under review, never
Expand Down Expand Up @@ -468,7 +484,7 @@ re-adding a skip.

`check:exported-any` is the one of those that also reads the built `dist/*.d.ts`, so the
stale-`dist` caveat above applies to it too. It asks the other half of the
`api-surface.json` question: that snapshot records an export *exists*, never what it
`api-surface/` question: that snapshot records an export *exists*, never what it
*resolves to*, which is how five exported symbols sat at `any` for a whole major with
every gate green (#4171). A recursive Zod schema needs an annotation to break its
circular inference, and `z.ZodType<any>` compiles, validates correctly, and silently
Expand Down Expand Up @@ -746,7 +762,7 @@ it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
working tree.
3. **Add a changeset for feature work.** When the change is a feature or functional improvement, run `pnpm changeset` (or add a `.changeset/*.md` entry) describing it before committing. Pure bug fixes do **not** require a changeset.
**Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix — this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on a non-strict schema, or an entry in the relevant `UNKNOWN_KEY_GUIDANCE` / `*_RETIRED_KEY_GUIDANCE` map (see `object.zod.ts`, `ai/tool.zod.ts`) when the schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the `spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes imply **opposite** liveness-ledger dispositions.
4. **Added or removed a `packages/spec` export? Run `pnpm --filter @objectstack/spec gen:api-surface` and commit the result.** The `TypeScript Type Check` job diffs spec's built export surface against `api-surface.json`; a new export makes the snapshot stale and turns the job red. It reads the **built `dist` declarations**, so `OS_SKIP_DTS=1` — the flag you reach for to make local builds fast — skips exactly the artifact the gate inspects, and the check passes locally while failing in CI. Same shape for the other generated-artifact gates in that job (`check:docs`, `check:skill-refs`, `check:react-blocks`), which read `src/` and so do reproduce locally.
4. **Added or removed a `packages/spec` export? Run `pnpm --filter @objectstack/spec gen:api-surface` and commit the result.** The `TypeScript Type Check` job diffs spec's built export surface against `api-surface/` (one shard per entry point since #5837); a new export makes the snapshot stale and turns the job red. It reads the **built `dist` declarations**, so `OS_SKIP_DTS=1` — the flag you reach for to make local builds fast — skips exactly the artifact the gate inspects, and the check passes locally while failing in CI. Same shape for the other generated-artifact gates in that job (`check:docs`, `check:skill-refs`, `check:react-blocks`), which read `src/` and so do reproduce locally.
5. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
6. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes.

Expand Down
Loading
Loading