Skip to content

fix(spec): refuse a declared def rename that would collapse two baseline keys onto one - #17920

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-17383-rename-merge-into-populated-target
Sep 13, 2026
Merged

fix(spec): refuse a declared def rename that would collapse two baseline keys onto one#17920
os-bill merged 2 commits into
mainfrom
claude/issue-17383-rename-merge-into-populated-target

Conversation

@os-bill

@os-bill os-bill commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

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

…ine keys onto one

`checkRenameTable` validates `RENAMED_DEFS` against the defs a build EMITS,
which 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
there: it is not two sources onto one target, the source is unemitted and the
target is emitted, so all four 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 loss is the recorded RETIRED state and the recorded DEFAULT of the losing
side, and it happens inside the carry, before any comparison runs -- so each
ratchet downstream adjudicates against input this script already corrupted, in
both directions: a genuine default change on the merged key can read as no
change at all, and a key whose default never moved can read as `changed`.

`checkRenameBaselineCollisions` refuses exactly the intersection: the property
names the baseline records under BOTH defs. A merge whose two defs share no
property name writes every key exactly once and is deliberately left green --
refusing a populated target as such would redden 24 of the 39 committed entries
and forbid the in-tree `cloud/Sha256Digest -> system/Sha256Digest` shape. It
runs once per baseline this script carries, because the in-tree snapshot and the
upstream anchor are different documents and a collision can exist in either.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
… that modelled an impossible baseline

Three pins, two of them about the cost this guard can impose.

The existing carry fixture INJECTED the old key into the base while leaving the
carried one in place, so the anchor recorded `source` under both defs at once —
a shape no real landing produces (an anchor from before the rename holds the
property under the OLD def only) and the one the new guard refuses outright.
Measured, it now exits 1 before the check it was written for is reached, so the
fixture removes the carried key as well and models the real pre-rename anchor.

The new pins: the colliding baseline is refused, names both defs and the
property, and writes nothing; and a rename into a target that is still POPULATED
in the base stays green when no property name is shared, because `Map.set` can
only collapse entries that are the same key.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@os-bill os-bill added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 13, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5741ff10c3068a84e9099d3a3eb3b533054bbc50packageMentionDocs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec ratchet: checkRenameTable does not guard a rename that MERGES into a baseline target that already has keys — the carry silently clobbers

2 participants