Skip to content

decision: should the losslessly mappable subset of the record-form filter get a real D2 conversion, or is "stored rows keep loading, re-saving is refused" the final answer? #17321

Description

@os-bill

Filed by the domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T06:40Z, out of the at-ACCEPT residue of PR #17257 (#15442 / #15449). ⛔ Unclaimed. No domain:* label and no pm:* state: ⛔ both are the triage seat's to produce. This is a decision card by shape — the four-facet block below is filled in at filing time as the protocol requires; ⛔ the seat does not adjudicate it.

Measured on origin/main 501959b72.

One sentence

A customer who saved a page last month with a filter written the old way will find it still renders exactly as before — right up until someone opens that page in the builder and hits Save, at which point the platform refuses the save and hands back a message that does not say how to fix it, and there is no tool that will fix it for them.

Background

Seven filter doors converged on the rule array [{ field, operator, value }, ...] (objectui#6206, maintainer batch adjudication 2026-08-25, verbatim 「同意」, Option B; family-wide convergence, decision batch #55, 2026-09-06, verbatim 「同意」, option A). The shipped disposition is deliberate and is written into the element-data-source-and-object-block-filter-rule-array entry in its own words:

⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 conversion — a SemanticMigration converts nothing by its own type, and os migrate meta --stored (the pass over a deployment's sys_metadata rows) replays D2 conversions only, so it has nothing to rewrite for this shape. The read path does not re-validate stored rows (applyConversionsToStoredItem replays the full chain without validating, by its own contract), so a stored page or block carrying the record form keeps loading unchanged and is still rendered by objectui at the pinned .objectui-sha; what changes is that RE-SAVING it is refused at the filter door, on its next save and not before.

So today: read = fine, re-save = refused, no conversion, no rewriter.

The question the convergence left open

The three entries' own replacement text already spells out a total, mechanical mapping for the common shapes:

  • flat record — { status: 'active' }[{ field: 'status', operator: 'equals', value: 'active' }]
  • operator object — { amount: { $gt: 100 } }[{ field: 'amount', operator: 'greater_than', value: 100 }]
  • several keys → several rules (they AND)
  • ObjectQL AST tuple array — [['owner_id', '=', '{current_user_id}']][{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]
  • legacy operator shorthands (eq, ne, gt, notIn, …) accepted and normalized on parse

That mapping is losslessly executable by a machine for exactly that subset. What it is not total over is $and / $or / $not — the record's combinator keys — because the rule array is a flat AND-list with no nesting.

⚠️ The one measured fact that decides how much is at stake: the record's $and / $or / $not keys "were misread by every gate block anyway (objectui#6948)" — that is the shipped entry's own words, and objectui#6948 is closed. So the nesting the rule array cannot express is nesting the consumer never honoured. That makes the un-mappable remainder small, but it is exactly the part where a mechanical conversion would have to either refuse or change behaviour.

Governing text

  • ADR-0087 §D2 (a real conversion, replayed by os migrate meta --stored) vs §D3 (SemanticMigration — prose, converts nothing; packages/spec/src/migrations/types.ts).
  • ADR-0087 at its own not-required (no-migration-prescription) clause refuses that disposition when the entry body carries a prescription. These three entries carry a full prescription, so D3 is the floor, not not-required.
  • packages/spec/src/conversions/stored.tsapplyConversionsToStoredItem replays the chain without validating, which is why stored rows keep loading.

Restart-touch: packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts

Re-check before acting: git log --oneline -20 origin/main -- packages/spec/src/migrations/entries/semantic/ and git grep -n "'element-data-source-and-object-block-filter-rule-array'" origin/main -- packages/spec/src/migrations/registry.ts — if a D2 has been added since, this card is already answered.

Options and what each actually costs

what gets built what the customer sees
A — leave it (the shipped disposition) nothing Old pages keep rendering. The first person to re-save one gets a refusal. Whoever that is has to hand-rewrite the filter, with the current bare invalid_type as their only guidance (that message gap is a separate card). Nothing tells them how many such pages a tenant has.
B — partial D2: convert the mappable subset, leave combinators alone a real D2 conversion covering flat records, {k: {$op}} objects and single-level AST tuples; a combinator-carrying record is passed through untouched os migrate meta --stored cleans up the great majority of stored rows in one operator-run pass; those pages re-save cleanly afterwards. A tenant with a $and/$or filter is left exactly where option A leaves everyone — but that is a much smaller population, and (measured) that nesting was never honoured downstream anyway. Cost: a conversion that is deliberately partial is a shape this ledger has not shipped before, so "did it convert my row or not" becomes a question an operator can ask and today cannot answer.
C — full D2, converting combinators by flattening them as B, plus $and flattened into the AND-list and $or / $not handled by some chosen rule Every row converts; nothing is left behind. But $or has no representation in a flat AND-list, so this option necessarily changes what some pages select — silently, during an operator-run migration, on data. That is the failure mode this platform's rules exist to prevent.

Business translation

  • A = "we changed the lock and did not issue new keys; whoever tries the door next finds out."
  • B = "we re-key every door we can re-key mechanically, and put a note on the handful we cannot."
  • C = "we re-key every door, and accept that a few of them now open onto a different room."

os-decision-facets

  • ① 项目长远合理性:B 缩小特例 —— 它把「旧写法怎么办」从每个客户各自的意外,收敛成一次可运维的批处理加一份点名清单;A 把这个特例永久留在客户侧,每张老页面都是一次将来的意外;C 表面上最干净,但它引入的是「迁移会悄悄改变筛选结果」这条永久债务,比它清掉的特例贵得多。
  • ② 实际业务拉动:今天撞上的人 = 任何一个打开旧页面按保存的人。⚠️ 拉动的真实大小本席测不到 —— 那是各部署 sys_metadata 里的存量,不在仓里;仓内作者面已在同一个改动里全部改写(实测:绑定处 4 个 spec fixture、block 门 5 个 showcase 作者、3 个 lint fixture,仓外为零)。⇒ 若维护者判断线上存量接近零,零拉动的默认动作是 A。
  • ③ 防 AI 犯错:A 的失败是响亮的(保存被拒),这是它最强的一面;它的弱点是拒绝话术不说怎么改(另卡)。B 同样响亮,且把响亮的次数降到最低。⚠️ C 的失败是静默的 —— 迁移跑完不报错,页面照常打开,只是选出来的行变了。⇒ 单凭这一轴,C 应当出局。
  • ④ 创业阶段不扩散:A 不新增任何长期义务;B 新增一个必须长期正确的转换器,但它转换的是一个已经冻结的旧形状(接受集已经关了),所以这份义务是有终点的、可删除的;C 新增的是一份永远说不清的义务。⇒ 不扩散这一轴上 A > B ≫ C。

推荐:B(部分 D2),回退项 A。理由:它是唯一一个既降低客户侧意外、又不把静默行为改变引进迁移路径的选项;而它新增的义务有明确终点。⚠️ 置信缺口 —— 本分析看不见的东西:线上部署 sys_metadata 里究竟有多少行携带旧记录形筛选,以及其中多少携带 $and/$or/$not。这个数一旦接近零,推荐立刻翻成 A;它只能由维护者或一次面向部署的读数回答,⛔ 仓内测不出来。

维护者速读

老页面里用旧写法存下来的筛选条件,现在读没问题、存会被拒,而且平台不提供任何自动修复。绝大部分旧写法可以被机器一对一改写成新写法;只有带「或者 / 并且 / 非」这种嵌套组合的那一小撮不行 —— 而实测表明,那种嵌套前端从来就没真正执行过。

  • A:什么都不做,谁碰到谁自己改。
  • B:写一个只改能改的那部分的转换器,运维跑一次批处理清掉绝大多数,剩下那一小撮点名列出来。(推荐)
  • C:全都自动改,包括嵌套那一撮 —— 代价是有些页面迁移后筛出来的数据会悄悄变

⚠️ 决定这件事划不划算的唯一数字,是线上到底有多少张老页面带旧写法的筛选 —— 这个数只有你看得到。若接近零,选 A。

请回一个字母:A / B / C

Related

#15442 · #15449 · PR #17257 · #15807 · PR #17267 · #11509 (pm:on-hold, whether these doors should exist at all in v18) · #15829 (the dashboard widget.filter, the eighth door, not converged) · objectui#6206 (the ruling, closed) · objectui#6948 (the combinator finding, closed)


Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions