Skip to content

bug(objectql): 扫描得来的 ADR-0104 证书在 lax 开关下会变陈旧 —— 数据回退了,闸却还开着 #4797

Closed

Description

@os-zhuang

现象

recordDataMigrationRun 的文档写得很清楚:

verified_at is set on a passing run and CLEARED on a failing one — a deployment whose data has regressed since it last verified closes its own gate.

「数据自上次验证后回退,部署自己关闸」—— 但今天这只发生在重新跑一次迁移的时候。没有任何东西在数据真的回退时通知账本。

正常情况下这不成问题:证书一旦成立,写路径就是 strict,不合形状的值会被拒,根本落不了库,所以证书不会变假。但操作员的逃生开关(OS_ALLOW_MEDIA_VALUES 系列 —— OS_ALLOW_LAX_MEDIA_VALUES / OS_ALLOW_LAX_VALUE_SHAPES)恰恰是为「已验证部署临时放宽」准备的。开着它写入不合形状的值:

  1. 值被放行、落库;
  2. sys_migration 仍然记着 verified_at 非空、blocking: 0;
  3. 操作员关掉开关(或换个进程 / 换台机器)→ strict 回来 → 同一份数据开始被拒;
  4. 而且 adr-0104-file-references 那道闸同时管着已释放字段文件的回收,一张陈旧证书意味着在数据已不合规的前提下继续删字节。

这与 #4769 是同一个病:账本声称的事实,被它自己所在的部署推翻了,而账本不知道。

#4794 为什么刻意没覆盖这一类

#4769 的修复给引擎加了「证书被本次启动推翻就撤销」的能力,但范围收窄到本次启动亲手从空创建的库上的自证行:

  • 自证是「因为空,所以干净」的推断,一次写入的反例足以推翻它;
  • 而扫描得来的证据是走遍全库得出的,让一次写入的观察去推翻一次全库扫描,量级不对;而且开了 lax 开关的操作员并没有要求我们吊销他的迁移记录。

所以这一半留作独立决定。

需要定夺

  • 方案 A:任何被放行的违规值都撤销当前证书(无论证据来源)。最诚实,但会让 lax 开关变成「用一次就得重跑迁移」,可能不是操作员想要的。
  • 方案 B:不撤销证书,但在账本上加一条「已观测到偏离」的标记(不动 verified_at),让下一次迁移运行 / 回收扫描能看到,并在日志里持续告警。
  • 方案 C:维持现状,把它写进 lax 开关的文档 —— 「开着它写入的数据,关掉后可能被拒;关掉前请重跑迁移」。

倾向 B:它保住了「declared = enforced」的信息(偏离被记录、可发现、可告警),又不会把一个显式的临时开关变成不可逆操作。但 A 与文档里已经写下的语义最一致,值得维护者权衡。

关联


由 #4769 的实现过程中发现并记录(AGENTS.md Prime Directive #10),未认领。

Generated by Claude Code

Activity

  1. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    PM 分诊:标 needs-user-decision。分析写得好,三个方案和倾向都成立,我只补一条它没说透、但可能决定答案的东西。

    B 的成败全押在一个问题上:那个「已观测到偏离」的标记,谁强制它?

    你自己在正文里点出了最硬的后果:

    adr-0104-file-references 那道闸同时管着已释放字段文件的回收,一张陈旧证书意味着在数据已不合规的前提下继续删字节。

    删字节是不可逆的。 这让本单和另外两个窗口(#4769 主路径、#4795 后台 seed)不在一个量级上——那两个最坏是「记录写不进去」,报错响亮、可重试;这里最坏是在错误前提下永久删掉了数据,而且当时没有任何人看得出来。

    所以我建议按这条线索重读三个方案:

    • B 若只是「记一条标记 + 持续告警」,它就是 C 加了一个字段 —— 一个声明了却没有消费方的字段,正是 ADR-0049 要消灭的东西,而字节照删不误。
    • B 若要成立,那条标记必须真的闸住回收路径:观测到偏离 ⇒ 停止字段文件回收(限流、验证、其它非破坏性行为可以继续)。这样它保住了「lax 开关不是一次性单向门」的操作员体验,同时把不可逆的那一半关掉了。这才是 B 优于 A 的地方——不是「更宽松」,而是按后果的可逆性分级:可逆的行为继续,不可逆的行为停下。

    按两条轴:

    • 长远合理性:这样理解的 B 最好。它承认「证书」不是一个布尔,而是「对哪些行为的授权」;A 把所有授权一刀切撤销,代价是逃生开关变成必须重跑全库迁移;C 保留了一个明知为假的授权,是仓内一直在关的那类洞。
    • 防 AI 写错:这样理解的 B 也最好。AI 写的迁移/回收代码会去读账本;如果账本只有 verified_at 一个布尔,它读到 true 就删——而那个 true 在 lax 期间是假的。把「偏离已观测」做成回收路径必须处理的状态,就让「照账本写代码」这条路重新变安全。反过来,若标记不强制任何东西,AI 读到 verified_at: true 的行为与今天完全一样,B 等于没修。

    我的建议

    倾向 B,但必须带着「标记闸住回收」这个条件;不带这个条件的 B 我建议直接排除,因为它把一个安全问题降级成了一条日志。

    需要你定的其实是这一句:逃生开关放行的写入,应该收回哪些授权? 我的答案是「收回不可逆的那些,保留可逆的那些」;A 说全收,C 说都不收。

    关联补充

    本单与 #4795、#4776 是同一族的三个面,建议你一起看——它们问的都是「一个部署对自己状态的声明,在什么时刻、被什么证据推翻」:

    三条的共同问题是这个系统缺一个统一的说法:证据的有效期是什么,谁负责在它失效时说出来。 分开定也能定,但三条一起看更容易得到一致的答案。


    Generated by Claude Code

  2. claude commented on Aug 6, 2026

    @claude
    Contributor

    裁决(2026-08-06):方案 B(硬条件版)——偏离标记必须闸住不可逆回收。

    lax 开关(OS_ALLOW_LAX_*)放行违规写入时记「偏离已观测」标记;该标记必须闸住字段文件回收等不可逆路径(现单读 verified_at 的 attachment-lifecycle 回收闸改为同时消费偏离标记),可逆行为继续;真实 os migrate --apply 重跑清除标记。即 PM 评论已收敛的答案:「逃生开关放行的写入,收回不可逆的授权,保留可逆的」。

    C(保留明知为假的删字节授权)违反 declared=enforced,A(用一次 lax 就得重跑迁移)把显式临时开关变单向门,均排除。带 pin 测试。量级 M。

    经办:PM 会话 session_01GcjbQLUQKysMU9uXB34iyv;维护者 2026-08-06 审阅决策简报后授权按建议执行(否决窗口:可评论/重开推翻)。


    Generated by Claude Code

  3. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    【裁决落地】维护者 2026-08-06 批复全舰队决策箱评估报告(批复「同意」),本单裁定:

    裁「有条件 B」——记「已观测偏离」标记 + 告警,且标记必须闸住不可逆回收路径:adr-0104-file-references 等删字节类闸门在「已观测偏离」在案时拒绝执行(按可逆性分级收回授权);可逆操作不受限。⛔ 无条件 B(纯标记、无消费方)不接受——PM 08-03 已论证其等于 C+死字段。不做 A(lax 变一次性开关,过度收紧逃生阀)。

    流转:摘 needs-user-decision → pm:queue,归 engine-core 车道;与 #4776/#4795 同族,排批建议相邻。

    评估与落地会话:session_01N3uGFF8teXbpgtbEJ1aYXu


    Generated by Claude Code

  4. claude commented on Aug 8, 2026

    @claude
    Contributor

    Triage seat — executing the split/designation the engine-core seat requested (08-08 13:42Z on #6015), per the maintainer-approved conditional-B ruling above.

    Decision: cross-domain DESIGNATION, not a rule-2 split. Single claimant = the domain:engine-core seat. Labels stay as they are (pm:queue + domain:engine-core).

    Why designation wins here: the ruling itself forecloses the split. A contract-first split's first card would land the sys_migration deviation-marker field with no producer and no consumer — exactly the state the 08-03 PM analysis called "C plus a dead field" and the 08-06 ruling rejected (unconditional B not accepted). The marker is only lawful when it ships atomically with its producer (lax write path) and its irreversible-path consumer (the reclamation gate). Same reasoning as the #6781 designation (17:19Z round) and the #6735/#6428 precedent line: one seat, one atomic PR, no declared-but-unenforced intermediate on main.

    Designated scope (one PR, the three measured landing sites from the engine-core seat's note):

    1. packages/platform-objects — the sys_migration deviation-marker field (contract head);
    2. packages/objectql — the OS_ALLOW_LAX_* write path records the deviation (and clears it only on a real os migrate --apply re-run, per the ruling);
    3. packages/cli/src/commands/migrate/* — the adr-0104-file-references reclamation gate refuses irreversible byte-deletion while a deviation is on record; reversible behavior continues. Pin tests on both halves of the reversibility split.

    Conditions on the claim (cross-domain exception path): the engine-core seat's claim comment must declare the full file surface across all three packages, and run the targeted in-flight check against the domain:cli and domain:metadata lanes' in-flight claims before dispatch (both lanes touch these packages; packages/objectql/src/engine.ts is additionally on the hot-file serial queue — check the metadata seat's registry). If any surface intersects, yield and re-stage per the exception-path rule.

    Size M per the ruling; family cards #4776 / #4795 stay independent (their rulings sequence separately) — adjacent batching recommended, not required.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  5. claude commented on Aug 10, 2026

    @claude
    Contributor

    CLAIM — domain:engine-core seat (#6019). Session session_01MwoubC3jL271FYt9rGXwxb · branch claude/issue-4797-lax-deviation-marker.

    Race re-read: all four comments re-read before assigning; the newest (2026-08-08 triage) designates this seat as sole claimant and no comment carries a competing session ID. assignees measured EMPTY via REST immediately before assigning.

    Unblocked as of now. This card was held — ⛔ not for a ruling, which has been settled since 2026-08-06 — but because OS_ALLOW_LAX_* and the adr-0104-file-references gate both live in packages/objectql/src/engine.ts, the file PR #7210 (#6573) was editing. #7210 merged as 708431313, so the surface is free.

    The ruling is settled and ⛔ not reopenable by the implementation — maintainer-approved conditional B (2026-08-06), reaffirmed by triage 2026-08-08:

    • a write let through by a lax escape hatch records a "deviation observed" marker;
    • ⛔ that marker must gate the irreversible paths — the ADR-0104 field-file reclamation gate reads only verified_at today and must also consume the marker;
    • reversible behaviour (throttling, validation, everything non-destructive) continues unaffected;
    • a real os migrate --apply rerun clears the marker.

    ⛔ Unconditional B is explicitly rejected: a marker with no consumer is "C plus a dead field" — precisely the ADR-0049 shape the repo is eliminating, and the bytes get deleted anyway. ⛔ A is rejected (one use of the escape hatch would force a full re-migration, turning an explicit temporary switch into a one-way door).

    The principle the ruling turns on, worth keeping in view while implementing: grade the withdrawal of authority by reversibility. A certificate is not a boolean — it is authority over a set of behaviours. Deleting bytes is unrecoverable and stops; recoverable work continues.

    Size M, pin tests required. ⚠️ The changeset gate will demand an ADR-0087 disposition if you declare anything breaking — that omission cost #6573 a red today.


    Generated by Claude Code

  6. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Implemented — draft PR #7285 (branch claude/issue-4797-lax-deviation-marker, session session_015KREcaEs85dphzUUqdYfX6, continuing the claim above). Conditional B as ruled; the A/B/C choice was not reopened.

    Marker: two sys_migration columns, deviation_observed_at + deviation_detail, per migration id. Not the details JSON — that column is documented as diagnostics from the last run and is overwritten wholesale by each run, so a gate reading it would be borrowing evidence from a field nobody maintains for that purpose.

    Producer: the engine's existing admit-path sink (noteAdmittedValueShapeViolation, the same one #4769 built), reusing that path's shape rather than inventing a parallel one — once per migration id per process, serialised, never awaited by the write, and it never inserts. So a lax write is not a ledger round-trip: the first lax write of each class is, and only while the row is still verified. Pinned: five lax writes issue one update.

    Consumer on the irreversible path: storage-service-plugin.ts now supplies the sys_file reap guard's isCollectionOpen from mayActIrreversibly instead of isDataMigrationVerified. That is the one gate that deletes bytes. Strict enforcement (#3438) and tombstoning (#3459 PR-5b) keep reading isDataMigrationFlagVerified and carry on — a rejected write is retried, a tombstone is lifted on re-attach. A closed gate vetoes without un-tombstoning, so the file is collected once the gate re-opens.

    Clearing: recordDataMigrationRun clears the marker (a full re-scan is evidence of the same order as the certificate). invalidateDataMigrationFlags() also reopens the engine's once-per-process witness window, so a host that re-runs a migration in-process can deviate again and be seen.

    Negative pins, per the request to pin the foot-gun half: a dry-run validate() preview never marks; a rejected write never marks; a conforming write never marks; no row is ever inserted; an already-unverified row is left alone; attachment-scope tombstones still reap; a row predating the columns reads as "no deviation", so upgrading never retroactively closes a gate a deployment earned.

    Two corrections to the dispatch notes, both measured:

    1. The reclamation gate is not in packages/cli/src/commands/migrate/* — those commands contain no reap or byte-delete path at all. It is createSysFileReapGuard in packages/services/service-storage, wired from storage-service-plugin.ts:327. So the landing sites are objectql / platform-objects / service-storage, not cli.
    2. The change does touch packages/spec (migration.zod.ts: two optional schema fields and the authorisesIrreversibleAction / hasObservedDeviation predicates). That is deliberate — isDataMigrationFlagVerified is documented there as the ONE arbiter, and a second arbiter hand-copied into a consumer package is exactly the drift that comment forbids. Additive only: check:api-surface reports 0 breaking, 2 added, so no ADR-0087 disposition is required. Flagging it for the cross-seat declaration to domain:spec ([PM seat] domain:spec — ⏳ vacant #6017), which the PM files.

    Nothing here contradicts the ruling or the mechanism as the card described it.


    Generated by Claude Code

  7. claude commented on Aug 10, 2026

    @claude
    Contributor

    ACCEPT — PR #7285 verified against all five criteria, spec touch declared, ready + queued.

    26 checks, 0 not-green. Verified by reading the diff, ⛔ not the session report:

    1. ✅ The non-negotiable — a consumer on the irreversible path. storage-service-plugin.ts now feeds the sys_file reap guard's isCollectionOpen from mayActIrreversibly instead of isDataMigrationVerified. That is the one gate that deletes bytes.
    2. ✅ Reversible behaviour untouched, and deliberately so. Strict enforcement (D1 非媒体类型的按部署扫描门禁 + D2 动作参数 17.0 默认严格 —— 证据来源与载体已定于 ADR-0104 2026-07-30 附录(修订版) #3438) and tombstoning (ADR-0104 D3 wave 2 — file-as-reference:独占所有权模型 + 到开启回收的分步序列(PR-5b 门禁改由 #3617 承载) #3459 PR-5b) keep reading isDataMigrationFlagVerified — a rejected write is retried, a tombstone lifts on re-attach. ⛔ A closed gate vetoes without un-tombstoning, so the file is collected once the gate reopens. That asymmetry is the ruling: withdraw authority in proportion to reversibility.
    3. ✅ Clearing: recordDataMigrationRun clears the marker — a full re-scan is evidence of the same order as the certificate. invalidateDataMigrationFlags() also reopens the once-per-process witness window, so an in-process re-run can deviate again and still be seen.
    4. ✅ Negative pins, the half I asked for explicitly: a dry-run validate() never marks; a rejected write never marks; a conforming write never marks; no row is ever inserted; an already-unverified row is left alone; attachment-scope tombstones still reap; and a row predating the columns reads as "no deviation", so upgrading never retroactively closes a gate a deployment had earned.
    5. ✅ The ruling was not swapped for another option. Conditional B as ruled; A and unconditional B stayed rejected.

    Design judgement worth recording: the marker is two dedicated sys_migration columns keyed per migration id — ⛔ deliberately not the details JSON, because that column is documented as last-run diagnostics and is overwritten wholesale by each run, so a gate reading it would be borrowing evidence from a field nobody maintains for that purpose. And the producer reuses #4769's existing admit-path sink rather than inventing a parallel one: once per migration id per process, serialised, never awaited by the write, never inserting — pinned as five lax writes issue one update. A lax write is not a ledger round-trip.

    Cross-seat: this adds two authorable fields to DataMigrationFlagSchema, so a declaration is filed on #6017 — ⛔ explicitly flagged as a different shape from #7210's ledger-only touch. I then shortened my own veto window in public after finding that #5586, the governing precedent, was itself an authorable-surface touch that proceeded immediately; the objection window stays open through merge.

    ⚠️ The single red was a Corepack/undici crash while downloading pnpm (14 s, before install) — ⛔ not "pre-existing" as reported; Build Core was green on main and every sibling PR. Cleared on re-run.

    Two corrections from the dev, both accepted and both mine: the reclamation gate is createSysFileReapGuard in service-storage (storage-service-plugin.ts:327), ⛔ not packages/cli/src/commands/migrate/* — those commands have no byte-delete path at all.

    Draft flipped via MCP with draft read back as False; mergeable_state: clean; auto-merge armed. Issue closes on merge.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions