Removing a key from an exact object shape silently makes every stored record carrying that key undecodable. This is not hypothetical — it shipped in #4879 and was caught in review: dropping partialOutputRetained from TURN_STATE_MESSAGE_SHAPE made 67 of 67 turn_state rows in a real user store fail decodeMessage, which would have left those Sessions unopenable after upgrade. Fixed in 3a4371a74 by adding a retired list to defineObjectShape.
The invariant, stated once: an exact shape carries two contracts whose variance is opposite — what it emits may shrink freely, what it accepts may only grow. allowed expressed both as one set, so a type-level field deletion was silently translated into a persisted-data-level deletion.
Why the type system made it worse rather than better: Covers<Expected, Actual> requires the optional list to exactly cover the type's optional keys, so once the field left TurnStateMessage, keeping the key in optional was a compile error. The compiler pushed toward the breaking change.
What is still unguarded: retired names the rule but nothing enforces it. There are 242 defineObjectShape call sites across 24 files; session.ts alone holds 26 shapes and exports none of them, so no test can enumerate what any shape accepts. durable-tool-result-projection.ts builds 7 ExactObjectShape values structurally, bypassing the Covers<> check entirely. The epoch does not cover this either — it rejects an incompatible peer, not a row already on local disk.
Wanted: a check that fails when a shape's accepted key set (allowed ∪ retired) shrinks, and passes when it grows. The obstacle is enumeration, and it is a design decision rather than an implementation detail:
- export every shape and enumerate them from a test (churns 24 files, makes internals public);
- register shapes into a module-level registry inside
defineObjectShape (no export churn, but a side effect on import and no help for the 7 hand-built shapes);
- extract the sets from source at build time (covers hand-built shapes too, adds a build step).
A committed snapshot of the accepted sets, compared per shape, is what turns the rule from a comment into a failing test.
Not a good first issue: the fix touches a core schema authority and needs the enumeration decision made first.
简体中文
从一个精确对象形状里移除一个键,会静默地让每一条携带该键的已存记录无法解码。 这不是假设 —— 它在 #4879 里真的发生过,并在 review 中被抓到:把 partialOutputRetained 从 TURN_STATE_MESSAGE_SHAPE 移除后,某个真实用户存储里 67 条 turn_state 全部 decodeMessage 失败,那会让这些 Session 在升级后打不开。已在 3a4371a74 通过给 defineObjectShape 增加 retired 列表修复。
不变量,只说一遍:一个精确形状承载着两个变型方向相反的契约 —— 它发出什么可以自由收缩,它接受什么只能增长。allowed 把两者表达成了同一个集合,于是一次类型层面的字段删除,被静默翻译成了一次持久化数据层面的删除。
类型系统为什么帮了倒忙:Covers<Expected, Actual> 要求 optional 列表恰好覆盖该类型的可选键;所以一旦字段离开 TurnStateMessage,把这个键留在 optional 里反而是编译错误。编译器把人推向了那个破坏性改动。
目前仍然没有守卫:retired 给规则起了名字,但没有任何机制强制它。仓库里有 242 处 defineObjectShape 调用、分布在 24 个文件;仅 session.ts 就有 26 个形状,一个都没有导出,所以没有任何测试能枚举出某个形状接受什么。durable-tool-result-projection.ts 还以结构体方式手工构造了 7 个 ExactObjectShape,完全绕过 Covers<> 检查。epoch 也顶不上 —— 它拒绝的是不兼容的对端,不是一条已经躺在本机磁盘上的行。
想要的:一个检查 —— 当某个形状的接受键集合(allowed ∪ retired)收缩时失败,增长时通过。障碍在枚举,而这是一个设计决策而非实现细节:
- 导出每个形状、由测试枚举(改动 24 个文件,把内部实现变成公开面);
- 在
defineObjectShape 内部把形状注册进一个模块级注册表(无需导出、但引入了 import 副作用,且救不了那 7 个手工形状);
- 构建期从源码中提取这些集合(连手工形状一起覆盖,但多一个构建步骤)。
把接受集合提交为快照、逐形状比对,才是让这条规则从注释变成会失败的测试的那一步。
不是 good first issue:改动触及核心 schema 权威,且需要先把枚举方案拍板。
Removing a key from an exact object shape silently makes every stored record carrying that key undecodable. This is not hypothetical — it shipped in #4879 and was caught in review: dropping
partialOutputRetainedfromTURN_STATE_MESSAGE_SHAPEmade 67 of 67turn_staterows in a real user store faildecodeMessage, which would have left those Sessions unopenable after upgrade. Fixed in3a4371a74by adding aretiredlist todefineObjectShape.The invariant, stated once: an exact shape carries two contracts whose variance is opposite — what it emits may shrink freely, what it accepts may only grow.
allowedexpressed both as one set, so a type-level field deletion was silently translated into a persisted-data-level deletion.Why the type system made it worse rather than better:
Covers<Expected, Actual>requires the optional list to exactly cover the type's optional keys, so once the field leftTurnStateMessage, keeping the key inoptionalwas a compile error. The compiler pushed toward the breaking change.What is still unguarded:
retirednames the rule but nothing enforces it. There are 242defineObjectShapecall sites across 24 files;session.tsalone holds 26 shapes and exports none of them, so no test can enumerate what any shape accepts.durable-tool-result-projection.tsbuilds 7ExactObjectShapevalues structurally, bypassing theCovers<>check entirely. The epoch does not cover this either — it rejects an incompatible peer, not a row already on local disk.Wanted: a check that fails when a shape's accepted key set (
allowed ∪ retired) shrinks, and passes when it grows. The obstacle is enumeration, and it is a design decision rather than an implementation detail:defineObjectShape(no export churn, but a side effect on import and no help for the 7 hand-built shapes);A committed snapshot of the accepted sets, compared per shape, is what turns the rule from a comment into a failing test.
Not a good first issue: the fix touches a core schema authority and needs the enumeration decision made first.
简体中文
从一个精确对象形状里移除一个键,会静默地让每一条携带该键的已存记录无法解码。 这不是假设 —— 它在 #4879 里真的发生过,并在 review 中被抓到:把
partialOutputRetained从TURN_STATE_MESSAGE_SHAPE移除后,某个真实用户存储里 67 条turn_state全部decodeMessage失败,那会让这些 Session 在升级后打不开。已在3a4371a74通过给defineObjectShape增加retired列表修复。不变量,只说一遍:一个精确形状承载着两个变型方向相反的契约 —— 它发出什么可以自由收缩,它接受什么只能增长。
allowed把两者表达成了同一个集合,于是一次类型层面的字段删除,被静默翻译成了一次持久化数据层面的删除。类型系统为什么帮了倒忙:
Covers<Expected, Actual>要求 optional 列表恰好覆盖该类型的可选键;所以一旦字段离开TurnStateMessage,把这个键留在optional里反而是编译错误。编译器把人推向了那个破坏性改动。目前仍然没有守卫:
retired给规则起了名字,但没有任何机制强制它。仓库里有 242 处defineObjectShape调用、分布在 24 个文件;仅session.ts就有 26 个形状,一个都没有导出,所以没有任何测试能枚举出某个形状接受什么。durable-tool-result-projection.ts还以结构体方式手工构造了 7 个ExactObjectShape,完全绕过Covers<>检查。epoch 也顶不上 —— 它拒绝的是不兼容的对端,不是一条已经躺在本机磁盘上的行。想要的:一个检查 —— 当某个形状的接受键集合(
allowed ∪ retired)收缩时失败,增长时通过。障碍在枚举,而这是一个设计决策而非实现细节:defineObjectShape内部把形状注册进一个模块级注册表(无需导出、但引入了 import 副作用,且救不了那 7 个手工形状);把接受集合提交为快照、逐形状比对,才是让这条规则从注释变成会失败的测试的那一步。
不是 good first issue:改动触及核心 schema 权威,且需要先把枚举方案拍板。