Repository navigation
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
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):
crm_opportunity.win_reason/loss_reason(Require win/loss reasons on close and surface win/loss analytics #593) — a deal already atclosed_wonwith nowin_reasonkeeps taking edits, and can be re-saved indefinitely, without ever supplying one;crm_lead.duplicate_of_lead/duplicate_of_contact(ADR-0113) — same shape;crm_quote.crm_contact(crm_quote.crm_contact 要不要自 presented 起 requiredWhen——缺 contact 的报价永远起草不出合同,失败只出现在服务器日志里(源自 #714) #1017) — measured above.
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:
- 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.)
- Backfill-and-check per rule — where the invariant genuinely matters, pair the
requiredWhenwith a hook or a data audit that catches rows already in the state. - Raise upstream — if
requiredWhenis meant to be an invariant, this belongs against@objectstack/objectql'sevaluateValidationRules, 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
huangyiirene commentedon Aug 11, 2026
The same hole exists in a second instrument —
Field.percent({ max })— and atype: 'script'validation is measurably free of it. Measured while implementing #599, which needed a hard ceiling oncrm_quote.discountand 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 atf71b5350.So the boundary this card draws is not a property of
requiredWhenspecifically — it is the difference between validating the value being written and validating the merged record. Field-level bounds (max, and by the same constructionmin) sit on the first side, alongsiderequiredWhen. Object-levelvalidations[]oftype: '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:
validations[]— at the cost that adding one retroactively freezes non-conforming rows until they are repaired;requiredWhenand field bounds give you, and its legacy hole is the flip side of the "no silent breakage" property crm_quote.crm_contact 要不要自 presented 起 requiredWhen——缺 contact 的报价永远起草不出合同,失败只出现在服务器日志里(源自 #714) #1017's ruling asked for.Neither is a bug; picking the wrong one silently is. The three rules this card lists (
win_reason/loss_reason#593, thecrm_leadduplicate 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 avalidations[]predicate would express the stated intent exactly.Runnable form: the
the ceiling is an INVARIANTdescribe-block intest/quote-discount-ceiling.test.ts(#599's PR), whose last case builds themax-based schema specifically to show the admission side by side.Generated by Claude Code
hotlong commentedon Aug 25, 2026
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,
InMemoryDriverandSqliteWasmDriver, same verdict, with a control column showing the rule does fire on the transition (draft → presentedrefused, 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
requiredWhena 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_quoteEvery
requiredWhenin this repo inherits the same hole:crm_opportunity.win_reason/loss_reason(a deal already atclosed_wonwith 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.选项
requiredWhen是转换门,不是不变量」写在AGENTS.md里「Validation predicates must be TOTAL」旁边,让后续卡片在 PR 里直接声明这个边界,而不是各自重新发现。AGENTS.md是 governed 面 —— draft PR + 人工合并。requiredWhen配一个 hook 或数据审计,抓住已经处在该状态的行。requiredWhen本应是不变量,拿两驱动的测量作为 repro 去@objectstack/objectql的evaluateValidationRules。四棱分析
① 实际业务需求 —— 有真实但有限的证据:⚠️ 这正是选项 2 该先回答的问题 —— 「这个洞今天漏了几行」是可测的,而没人测过。选项 1 不需要这个数字,选项 2 完全依赖它。
win_reason的洞是可讲的业务故事(赢单原因永远收不齐,报表因此长期缺数),但无人报障,也没有测量说明存量里到底有多少这样的行。② 项目长远合理性 —— 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 是一个平台语义问题,值得提,但不该由本仓单方面推动。
AGENTS.md,那是 governed 面 —— ⛔ 本循环的 dev PR 不得自行合并,必须 draft + 人工审核。Generated by Claude Code
huangyiirene commentedon Aug 31, 2026
裁决(平台半边):问题上行 —— 平台卡已立 objectstack#13879(维护者 2026-08-31)
项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· hotcrm 决裁批 #17 ①维护者原话(逐字):「1069 如果是平台的问题就提交平台卡片。」
执行
pm:queue· domain:spec · documentation)—— spec docblock 写明「requiredWhen/字段 bounds = 转换门、validations[]script = 真不变量(冻结不砖死)」+ 发布 skills 加工具选择行,⛔ 不改任何一侧语义;win_reason/loss_reason、lead 重复回指、crm_quote.crm_contact)先跑一次存量洞计数(每条一句 count),有真实缺口的规则再逐条裁「维持转换门 vs 改写validations[]不变量」(改写 = 存量冻结,产品决定)。状态
needs-user-decision维持(应用半边未裁);平台半边以本评论与 objectstack#13879 为记录。Generated by Claude Code
huangyiirene commentedon Sep 1, 2026
裁决(应用半边):不跑计数,维持转换门,本卡关闭 completed(维护者 2026-09-01)
项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· 维护者逐字:「OK」(对本席「不跑,关卡」推荐的核准)。win_reason/loss_reason· lead 重复回指 ·quote.crm_contact):对模板是安全默认 —— 改写成validations[]不变量会让客户升级模板时存量不合规行被冻结,把「升级冻结」强加给看不见的客户数据,正是 crm_quote.crm_contact 要不要自 presented 起 requiredWhen——缺 contact 的报价永远起草不出合同,失败只出现在服务器日志里(源自 #714) #1017 裁决要避免的形状;requiredWhen/字段 bounds = 转换门、validations[]script = 真不变量(冻结不砖死)」)由 objectstack#13879 平台侧统一承接(8-31 裁决,pm:queue在队);hotcrm ⛔ 不自写边界段(应用仓原则三);Field.percent({max})同型对照,是 #13879 实施的直接输入。状态转移(同笔)
关闭 completed;摘
needs-user-decision;metadata留卡。Generated by Claude Code