You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(spec): refuse a declared def rename that would collapse two baseline keys onto one (#17920)
Fixes#17383
Clause-②: no
`checkRenameTable` validates `RENAMED_DEFS` against the defs a build
**emits** — that
is all its call site can see. A single rename `A -> B` where the
recorded **baseline**
already holds a property name under both `A` and `B` is invisible to it:
it is not two
sources onto one target, the source is unemitted and the target is
emitted, so all of
the existing rules pass. Every carry in `build-schemas.ts` is a plain
`Map.set` keyed
by the carried key, so the two entries collapse and the later write
wins.
## The premise this PR did not inherit
The card places `checkRenameTable` in
`packages/spec/scripts/lib/authorable-defaults.ts`.
It is not there. Re-derived by symbol on `origin/main`:
- the guard is `packages/spec/scripts/lib/renamed-defs.ts`, consumed
once in
`build-schemas.ts`;
- there are **five** clobbering carry sites, not two — four in
`build-schemas.ts`
(the surface gate's `prev`, the deletion gate's `baseSnapshot`, and both
`baselineKeys` builders of the defaults gate) plus
`carryDefaultsThroughRenames`;
- a sixth carry site, `registeredRetiredKeys()`, is **not** affected: it
merges
deliberately (earliest major wins) instead of clobbering.
A second correction: the card and the dispatch both say
`checkRenameTable` refuses
"exactly four shapes". It refuses **five** — the chained-rename rule (`A
-> B -> C`)
is a separate pass at the end of the function.
## The gap, reproduced with a lit control
Against the real carry code, one rename `integration/Old ->
integration/New`, with the
baseline holding `mode` under both defs:
| leg | reading |
| --- | --- |
| the guard today | `checkRenameTable` returns `[]` — no diagnostic |
| **lit control** | the *same* collapse reached by two sources returns 1
problem — `the TARGET def is already claimed by …` |
| key side | `prev` collapses 2 baseline entries to 1; the surviving
retired flag is whichever sorted last |
| defaults side | `carryDefaultsThroughRenames` collapses 2 fingerprints
to 1 |
| **acceptance bar** | target default `"b"`, this build emits `"a"` — a
genuine change — and `diffAuthorableDefaults` reports `[]`.
**Swallowed.** |
| mirror | target default `"b"`, this build emits `"b"` — no change at
all — and the differ reports `changed: "a" -> "b"`. **Manufactured.** |
The corruption is bidirectional, which is what decides the disposition
below.
## The choice: REFUSE — and refuse the intersection, not the populated
target
The card offers two shapes and specifies neither. This PR refuses, for
three reasons,
and narrows *what* is refused for a fourth.
1. **The damage is identical to the two-sources rule's**, not merely
similar: the same
maps, the same `Map.set`, the same lost retired state and lost default.
Where the
damage is identical, the disposition should be too.
2. **Report-and-allow cannot work here**, because the clobber happens
*inside the
carry, before any comparison runs*. The rows a reviewer would read are
themselves
computed against corrupted input — and per the table above the gate both
swallows
real changes and manufactures false ones, so a reviewer holding an
advisory
"collision" line beside a `changed` row cannot tell which of the two is
real.
Refusal is the only disposition that keeps the reported diff honest.
3. The remedy is available and is the one the two-sources rule already
prescribes:
keep the rename, and retire the losing side explicitly with
`retiredKey()` plus its
registered ADR-0087 conversion.
4. ⚠️ **But the card's literal predicate is falsified by measurement.**
"Refuse a rename
whose target already carries keys in the baseline" would redden `main`
immediately:
- **24 of the 39** committed entries have a target that already holds
baseline keys
in the committed `authorable-surface/` (that snapshot is the
*post*-rename one, so
the keys sit under the new name and the source has none);
- the in-tree `cloud/Sha256Digest -> system/Sha256Digest` entry is
documented as
"a rename onto a def that already existed", so
rename-into-an-existing-target is
an already-sanctioned shape.
`Map.set` can only collapse entries that are the **same key**, so a
merge whose two
defs share no property **name** writes every key exactly once and loses
nothing. The
refusal is therefore the **intersection** — the property names the
baseline records
under both defs — which is exactly the damage and nothing more. Measured
on
`origin/main`: **0 collisions** in the committed `authorable-surface/`
and **0** in
`authorable-surface.base.json`.
Both baselines are guarded, because they are different documents: the
upstream anchor
is the *pre*-rename snapshot (source populated, target empty) and the
in-tree snapshot
is the *post*-rename one. A real merge-into-populated-target shows up in
the anchor
first, so guarding only the in-tree snapshot would have missed the
card's own case.
## Ablation — both directions, on-disk proof, hash restore
Each leg: unique-anchor check, occurrence counts before and after, `git
hash-object`
against the `HEAD` blob, `trap … EXIT INT TERM`, and restore verified
byte-identical
plus an empty `git diff HEAD`.
**Leg A — disable the guard.** Blob `f32305e7` -> `addc4481`.
- 4 unit pins RED: *refuses a rename whose target already holds the same
property
name*; *is silent where checkRenameTable is loud*; *reports EVERY
colliding
property*; *splits on the FIRST separator*
- 1 wiring pin RED: *refuses a rename whose baseline records the same
property under
BOTH defs, and writes nothing*
- 25 unit pins stayed green — the mutation is targeted, not a blanket
break
- restored: `f32305e7`, byte-identical, `git diff HEAD` empty
**Leg B — make the guard over-fire** (refuse when BOTH sides are
populated, ignoring the
intersection). Blob `f32305e7` -> `4f87b2b5`. This is the cost this
change can impose.
- the over-refusal pins RED, exactly as designed: *ACCEPTS a merge into
a populated
target whose property names are disjoint*, and the wiring pin *does NOT
refuse a
rename into a populated target when no property name is shared*
- one extra, honestly reported: *reports EVERY colliding property,
sorted* also went
RED, because the over-firing rule reports 3 properties where the real
rule reports 2
- 27 unit pins stayed green; restored `f32305e7`, byte-identical, `git
diff HEAD` empty
⚠️ Note what Leg B means: under that mutation the **real build stays
green**, because
every committed entry has an empty source side in the in-tree snapshot.
The over-refusal
is caught *only* by these pins. That is why they exist.
⭐ **Correction, from the at-tier contract review of this PR.** An
earlier revision of this
section said Leg B "is the leg the card's literal predicate would have
shipped" while also
saying the real build stays green under it. **Those cannot both be
true**, and the review
separated them by measurement:
- **Leg B (above, blob `4f87b2b5`)** — refuse when both sides are
populated. Real
`check:authorable-surface` **exit 0**; caught by the pins alone.
- **The card's literal predicate** — refuse ANY populated target — is a
*different*
mutation (the review's B2, blob `4a3848b9`). Wired into the real build
it exits **1**
with **24 problem lines**: `❌ 24 declared def rename(s) would COLLAPSE
keys of the
committed authorable-surface/`.
⇒ The card's predicate is caught by the real build, loudly. The narrower
over-fire is the
one only the pins catch. Both readings stand; only the sentence
conflating them was wrong.
## One fixture corrected, and why it is in scope
`build-schemas-check-mode.test.ts` seeded its carry fixture by
**injecting** the old key
into the base while leaving the carried key in place — so the anchor
recorded `source`
under both defs at once. That is the collision, and the new guard
refuses it: measured,
the run now exits 1 *before* the check the fixture was written for is
reached, so it
would have been asserting about a build that never got there. No real
landing produces
that anchor (a pre-rename anchor holds the property under the old def
only), so the
fixture now removes the carried key as well. It is a faithfulness fix,
not an
accommodation — and it doubles as the over-refusal pin, since the target
def still holds
six other keys in that base.
## Verification
| run | exit | result |
| --- | ---: | --- |
| `vitest run --project local scripts/renamed-defs.test.ts` | 0 | 29
passed |
| `vitest run --project repo build-schemas-check-mode.test.ts -t
'deleted baseline lines must prove themselves'` | 0 | 11 passed, 63
skipped |
| `pnpm --filter @objectstack/spec typecheck` | 0 | `tsc` +
`check:scripts-typecheck` + `check:test-typecheck` all green |
| `pnpm --filter @objectstack/spec run check:authorable-surface` | 0 |
the real `build-schemas.ts --check` accepts the committed table |
| derived gate families (`scripts/pm/dispatch-gates.mjs --commands`) | 0
| see the report; 3 of them read `dist/` and were re-run after a build |
## No changeset — `skip-changeset`
Measured rather than assumed. `packages/spec`'s `files[]` ships `dist`,
`json-schema`,
`liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`,
`CHANGELOG.md`,
`api-surface`, `spec-changes.json`. This diff touches only
`packages/spec/scripts/**`.
- positive control — `ConnectorSchema`, a published symbol: **4**
published paths hit
- `checkRenameBaselineCollisions` (new): **0**
- `checkRenameTable` and `carryAuthorableKey`, siblings in the same
build-script module
that have shipped through many releases: **0**
The sibling control is the load-bearing half: build-script symbols have
never reached a
published path, so this is a measurement across releases rather than an
argument from
construction. Nothing published moves. ⚠️ The dispatch's declared file
surface listed
"a changeset"; this deviates from it deliberately and is flagged in the
round report so
the seat can reverse it in one step.
## 验收备注
范围外发现,均未立卡:
- 卡片与派发词都称 `checkRenameTable` "refuses exactly four shapes",实为**五条** ——
链式改名(`A -> B -> C`)是函数末尾独立的一遍。属于卡片表述,不是代码缺陷。
- 两来源规则(guard four)即使在两个来源的属性名**完全不相交**时也拒绝,比它自己
陈述的损害更宽;本次新规则只拒交集。这是一处不对称,但方向偏严、不违反任何契约,
因此记录不立卡。承接者:下一个向 `RENAMED_DEFS` 添加合并型条目的 PR。
- `registeredRetiredKeys()`(`build-schemas.ts`)是第六个 carry 站点,它**刻意合并**
(取最早的 major)而非覆盖,不受本次影响。记录以免下次读成遗漏。
## 维护者速读(草稿)
**改了什么** —— `packages/spec` 的构建期闸门新增一条规则:声明的 def 改名,如果基线
快照在**源 def 和目标 def 下记录了同一个属性名**,构建直接失败。新增
`checkRenameBaselineCollisions`,在 `build-schemas.ts` 的两处基线(in-tree 快照与
upstream 锚点)各调用一次。只动构建脚本,不动任何已发布内容。
**为什么改** —— 现有的 `checkRenameTable` 只能看到"这次构建发出了哪些 def",看不到
基线。所以单条改名 `A -> B` 撞上一个基线里已有 keys 的 `B` 时,五条现有规则全部放行,
而所有 carry 都是 `Map.set`,两条记录塌成一条、后写的赢。丢掉的是属性的 retired 状态
与 default 指纹,而且塌陷发生在**任何比较之前**,因此下游每个 ratchet 都在已被污染的
输入上判案 —— 双向出错:真实的 default 变更会被吞掉,没变的会被报成 `changed`。
⚠️ 今天树里没有这种改名,这是**潜在**而非现行缺陷:修它的理由是闸门的保证比它读起来
更窄,不是现在坏了。
**风险与代价(含回滚)** —— 代价是闸门可能**误拒**一次合法改名。卡片建议的字面判据
("目标在基线里已有 keys 就拒绝")实测会当场让 `main` 变红:39 条已提交条目里有 24 条
的目标在 in-tree 快照下已有 keys,而且 `cloud/Sha256Digest -> system/Sha256Digest`
这条在树里就是"改名到一个已存在的 def"。所以本次只拒**交集**(两边同名的属性),
不相交的合并逐字节无损、照常放行,并由 Leg B 消融钉死。实测 `main` 两份基线的交集
均为 0,`check:authorable-surface` 绿。回滚成本极低:整个改动是构建脚本加一条规则,
`git revert` 即可,不涉及任何已发布载荷、导出符号或生成产物。
**席位意见** —— (留空,待席位定稿)
**你要做的** —— 两件。① 裁一下方向:卡片把"拒绝"与"带碰撞报告放行"并列且不指定,
本 PR 选了**拒绝**并收窄到交集,理由与证据在正文;若你要"报告放行",需要先回答
guard four 为什么对同一损害判了拒绝。② 确认 `skip-changeset` 这一路:实测没有任何
已发布内容移动(带正控),但派发词的文件面写了"a changeset",两者冲突,由你定。
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
0 commit comments