Skip to content

requiredWhen is a transition gate, not an invariant: a record already in the required state is never re-validated, so every such rule in this repo has a silent legacy hole #1069

Description

@huangyiirene

Found while implementing #1017 (PR #1068). Out of scope there — that card was a single field's gate; this is a property of the idiom the whole repo enforces with.

What was measured

Building the stock-data disposition for #1017 meant answering "what happens to a row that entered the gated state before the rule existed". Built the honest way — insert through an engine whose schema has no requiredWhen, then re-open the same store with the shipped schema — on InMemoryDriver (sparse rows) and on SqliteWasmDriver (column-complete, crm_contact = NULL). Same verdict on both:

legacy row: status = presented | crm_contact = null   (rule now says: required when presented/accepted)

READ                                    → works
update { total_price }                  → ADMITTED
update { internal_notes }               → ADMITTED
update { status: 'presented' }  (no-op) → ADMITTED
update { status: 'expired' }            → ADMITTED
update { crm_contact: 'con_x' }         → ADMITTED   (the repair)
update { status: 'accepted' }           → ADMITTED   ← the hole

Compare, on the same schema, a record that was not already gated:

draft → presented, no contact           → 400 VALIDATION_FAILED "Contact is required"
INSERT born presented, no contact       → 400 VALIDATION_FAILED
presented (with contact) → clear it     → 400 VALIDATION_FAILED

So the engine evaluates the requirement on the write that makes the predicate become true (or that empties the field while it holds). Once the predicate already held before the write, the requirement is not re-checked — presented → accepted on a contact-less row sails through even though accepted is squarely inside the rule's own condition.

Why this is worth a card

Half of it is a feature, and #1017 leans on it deliberately: it is exactly why adding a requiredWhen does not brick existing rows, and PR #1068 pins all three behaviours and documents the boundary for admins.

The other half is that "declared = enforced" is weaker than this repo has been reading it, and not just for crm_quote. Every requiredWhen here inherits the same hole for records that predate the rule (or that reach the state through any path where the predicate was already true):

test/win-loss-capture.test.ts opens with a table of five metadata surfaces that accept a rule and then do not apply it. This is a sixth entry of a different kind: the rule is applied, on the transition only. Nothing in the repo said so until now, and the natural reading of "required when stage == closed_won" is an invariant, not an edge trigger.

Not asserting this is a platform bug

Validating only what changes is a defensible engine design, and turning it into a full invariant would make any newly-added requiredWhen retroactively freeze existing rows — the opposite of the "no silent breakage" property #1017's ruling asked for. So the question this card is for is a product one:

  1. Accept and document — write the transition semantics into AGENTS.md next to "Validation predicates must be TOTAL", so future cards state the boundary in the PR instead of rediscovering it. (PR feat(quote): a quote may not be presented without a recipient (#1017) #1068 documents it for quotes only.)
  2. Backfill-and-check per rule — where the invariant genuinely matters, pair the requiredWhen with a hook or a data audit that catches rows already in the state.
  3. Raise upstream — if requiredWhen is meant to be an invariant, this belongs against @objectstack/objectql's evaluateValidationRules, with the two-driver measurement above as the repro.

Measured on @objectstack 17.0.0-rc.5, hotcrm main at c5bc586. Repro: the a row that was ALREADY presented when the rule landed describe-block in test/quote-contact-required-when.test.ts (PR #1068) is the measurement in runnable form.

Activity

  1. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    CollaboratorAuthor

    The same hole exists in a second instrument — Field.percent({ max }) — and a type: 'script' validation is measurably free of it. Measured while implementing #599, which needed a hard ceiling on crm_quote.discount and therefore had to choose an instrument rather than copy one.

    Same method as this card: insert through an engine whose schema has no rule, re-open the SAME store with the shipped schema, then write. Legacy row is discount = 90, ceiling is 60. InMemoryDriver, @objectstack 17.0.0-rc.6, hotcrm main at f71b5350.

                                           Field.percent({max: 60})   script validation (> 60)
    fresh INSERT discount=70                REFUSED  max_value          REFUSED  rule_violation
    fresh UPDATE discount=70                REFUSED  max_value          REFUSED  rule_violation
    legacy(90) update {internal_notes}      ADMITTED  <-- the hole      REFUSED
    legacy(90) update {status: in_review}   ADMITTED  <-- the hole      REFUSED
    legacy(90) update {discount: 10}        ADMITTED  (repair)          ADMITTED  (repair)
    

    So the boundary this card draws is not a property of requiredWhen specifically — it is the difference between validating the value being written and validating the merged record. Field-level bounds (max, and by the same construction min) sit on the first side, alongside requiredWhen. Object-level validations[] of type: 'script' sit on the second, and are a true invariant: the predicate is re-evaluated on every write while the record holds, so a row that predates the rule is refused on any edit until it is brought into compliance. The repair is always admitted, so it freezes rather than bricks.

    That is a useful thing for option 1 of this card ("accept and document") to record, because it turns "declared = enforced is weaker than we read it" into a choice with two known sides rather than a blanket caveat:

    Neither is a bug; picking the wrong one silently is. The three rules this card lists (win_reason/loss_reason #593, the crm_lead duplicate lookups under ADR-0113, crm_quote.crm_contact #1017) all read as invariants in prose and are implemented as transitions — the same mismatch, and for at least some of them a validations[] predicate would express the stated intent exactly.

    Runnable form: the the ceiling is an INVARIANT describe-block in test/quote-discount-ceiling.test.ts (#599's PR), whose last case builds the max-based schema specifically to show the admission side by side.


    Generated by Claude Code

  2. added
    metadataDeclarative metadata — schema, security posture, UI surfaces
    needs-user-decisionNeeds the maintainer's call before work proceeds
    on Aug 25, 2026
  3. hotlong commented on Aug 25, 2026

    @hotlong
    Contributor

    Triage (代扫, vacancy clause) → needs-user-decision. The card is explicit that its question is a product one.

    Filed 2026-08-11 with no labels, invisible for fourteen days. Vacancy clause: triage actions only.

    Why this is not a bug card

    The measurement is unusually complete — two drivers, InMemoryDriver and SqliteWasmDriver, same verdict, with a control column showing the rule does fire on the transition (draft → presented refused, insert-born-presented refused, clearing the field refused) and does not on a row already inside the predicate. That is a boundary, cleanly established.

    And the card is right that it is not obviously a defect: validating only what changes is a defensible engine design, and making requiredWhen a true invariant would retroactively freeze existing rows — the exact opposite of the "no silent breakage" property #1017's own ruling asked for. So there is no invariant to restore, which is what would have made it dispatchable without a ruling.

    ⛔ Not 代裁-able: this seat holds no 代裁 authority, and route 1 touches AGENTS.md, which is governed — draft-only, human merge, no exceptions.

    What is at stake beyond crm_quote

    Every requiredWhen in this repo inherits the same hole: crm_opportunity.win_reason / loss_reason (a deal already at closed_won with no reason keeps taking edits forever), crm_lead.duplicate_of_lead / duplicate_of_contact, crm_quote.crm_contact. The natural reading of "required when stage == closed_won" is an invariant; it is an edge trigger, and nothing in the repo said so until this card.

    选项

    • 1. 接受并写进文档。 把「requiredWhen 是转换门,不是不变量」写在 AGENTS.md 里「Validation predicates must be TOTAL」旁边,让后续卡片在 PR 里直接声明这个边界,而不是各自重新发现。⚠️ AGENTS.md 是 governed 面 —— draft PR + 人工合并。
    • 2. 逐规则补检。 在这个不变量确实重要的地方,给 requiredWhen 配一个 hook 或数据审计,抓住已经处在该状态的行。
    • 3. 上游提单。 若 requiredWhen 本应是不变量,拿两驱动的测量作为 repro 去 @objectstack/objectql 的 evaluateValidationRules。

    四棱分析

    ① 实际业务需求 —— 有真实但有限的证据:win_reason 的洞是可讲的业务故事(赢单原因永远收不齐,报表因此长期缺数),但无人报障,也没有测量说明存量里到底有多少这样的行。⚠️ 这正是选项 2 该先回答的问题 —— 「这个洞今天漏了几行」是可测的,而没人测过。选项 1 不需要这个数字,选项 2 完全依赖它。

    ② 项目长远合理性 —— 1 与 3 不冲突,且顺序明确:先把边界写下来(便宜、立即止损),再决定要不要上游改语义(昂贵、影响每个应用)。2 是最脆的一个 —— 每条规则配一个 hook,等于把引擎的语义缺口用应用代码逐处补丁,正是本仓反复付账的 consumer-side compensation 形态。

    ③ 防 AI 写错 —— 决定性的一轴,并且强烈指向 1。现状是:一个 AI 作者写下 requiredWhen 会合理地以为自己声明了一个不变量,而它得到的是边触发。这个误解不会报错、不会被门禁抓住,只会在数据里静默累积。把边界写进 AGENTS.md 直接消除误解,成本是一段文字。⛔ 反过来,选项 2 若单独采用,会让「有些 requiredWhen 是不变量、有些不是」并存,这比统一的弱语义更难写对。

    ④ 创业阶段不扩散需求 —— 1 是纯文档,零扩散,几乎零成本。3 是上游工作,不占本仓预算,但要为它准备 repro(已经有了,test/quote-contact-required-when.test.ts 里那个 describe 块就是可运行形态)。2 会按规则数量线性扩散工作量,且每一处都是新代码 —— 在创业阶段最难辩护。

    推荐:1 现在做;2 仅在测出真实存量缺口的具体规则上做;3 由维护者决定是否值得上游

    四棱一致指向 1 作为地板(它使其余选项都变便宜,且不排除任何一条路)。2 不该整体采纳,只该在③的统一性代价被具体数据压过时逐条采纳。3 是一个平台语义问题,值得提,但不该由本仓单方面推动。

    ⚠️ 推荐是输入不是放行。特别提示:选项 1 落地即触 AGENTS.md,那是 governed 面 —— ⛔ 本循环的 dev PR 不得自行合并,必须 draft + 人工审核。


    Generated by Claude Code

  4. huangyiirene commented on Aug 31, 2026

    @huangyiirene
    CollaboratorAuthor

    裁决(平台半边):问题上行 —— 平台卡已立 objectstack#13879(维护者 2026-08-31)

    项目总监席 · session session_01KGtaLpkW1mycWgkbSb3H6t · hotcrm 决裁批 #17 ①

    维护者原话(逐字):「1069 如果是平台的问题就提交平台卡片。」

    执行

    1. 边界文档与工具选择教学定性为平台问题,已上行:objectstack#13879(pm:queue · domain:spec · documentation)—— spec docblock 写明「requiredWhen/字段 bounds = 转换门、validations[] script = 真不变量(冻结不砖死)」+ 发布 skills 加工具选择行,⛔ 不改任何一侧语义;
    2. ⛔ 本仓不再另写 AGENTS.md 边界段(原选项 1 的 hotcrm 半边作废)—— 按今日原则三(objectstack#13848),应用消费平台 skills,平台统一教;
    3. 应用半边仍待裁(随批 Add AI agent system for accelerated HotCRM development #17 其余各张候回批):三条散文读作不变量的规则(win_reason/loss_reason、lead 重复回指、crm_quote.crm_contact)先跑一次存量洞计数(每条一句 count),有真实缺口的规则再逐条裁「维持转换门 vs 改写 validations[] 不变量」(改写 = 存量冻结,产品决定)。

    状态

    needs-user-decision 维持(应用半边未裁);平台半边以本评论与 objectstack#13879 为记录。


    Generated by Claude Code

  5. huangyiirene commented on Sep 1, 2026

    @huangyiirene
    CollaboratorAuthor

    裁决(应用半边):不跑计数,维持转换门,本卡关闭 completed(维护者 2026-09-01)

    项目总监席 · session session_01KGtaLpkW1mycWgkbSb3H6t · 维护者逐字:「OK」(对本席「不跑,关卡」推荐的核准)。

    1. 存量洞计数不跑:hotcrm 是模板应用,仓内「存量」只有种子数据 —— 真正会被洞伤到的是客户部署里的行,本仓测不到;仓内计数只能测种子卫生,已有种子校验面管辖,不值一次专门派发;
    2. 三条规则维持转换门语义(win_reason/loss_reason · lead 重复回指 · quote.crm_contact):对模板是安全默认 —— 改写成 validations[] 不变量会让客户升级模板时存量不合规行被冻结,把「升级冻结」强加给看不见的客户数据,正是 crm_quote.crm_contact 要不要自 presented 起 requiredWhen——缺 contact 的报价永远起草不出合同,失败只出现在服务器日志里(源自 #714) #1017 裁决要避免的形状;
    3. 绊线:若日后某条规则的业务方明确要不变量语义(如「赢单原因必须补齐才能继续操作」成为真实客户需求)⇒ 带消费者立卡,按平台教学的两工具语义直接改写,⛔ 不预决;
    4. 本卡遗产已收口:工具边界教学(「requiredWhen/字段 bounds = 转换门、validations[] script = 真不变量(冻结不砖死)」)由 objectstack#13879 平台侧统一承接(8-31 裁决,pm:queue 在队);hotcrm ⛔ 不自写边界段(应用仓原则三);
    5. 测量遗产入册:两驱动实测 + PR feat(quote): a quote may not be presented without a recipient (#1017) #1068 的可运行 describe 块 + Field.percent({max}) 同型对照,是 #13879 实施的直接输入。

    状态转移(同笔)

    关闭 completed;摘 needs-user-decision;metadata 留卡。


    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

No one assigned

    Labels

    metadataDeclarative metadata — schema, security posture, UI surfaces

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions