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
Activity
huangyiirene commented
on Aug 11, 2026 CollaboratorAuthorMore actionsThe 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.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
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:
- an invariant ("X may never exceed Y") is expressible today, with
validations[]— at the cost that adding one retroactively freezes non-conforming rows until they are repaired; - a transition condition ("X is required by the time it reaches state S") is what
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
- an invariant ("X may never exceed Y") is expressible today, with
- addedmetadataDeclarative metadata — schema, security posture, UI surfacesDeclarative metadata — schema, security posture, UI surfacesneeds-user-decisionNeeds the maintainer's call before work proceedsNeeds the maintainer's call before work proceeds
on 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.选项
- 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
- 1. 接受并写进文档。 把「
huangyiirene commented
on Aug 31, 2026 CollaboratorAuthorMore actions裁决(平台半边):问题上行 —— 平台卡已立 objectstack#13879(维护者 2026-08-31)
项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· hotcrm 决裁批 #17 ①维护者原话(逐字):「1069 如果是平台的问题就提交平台卡片。」
执行
- 边界文档与工具选择教学定性为平台问题,已上行:objectstack#13879(
pm:queue· domain:spec · documentation)—— spec docblock 写明「requiredWhen/字段 bounds = 转换门、validations[]script = 真不变量(冻结不砖死)」+ 发布 skills 加工具选择行,⛔ 不改任何一侧语义; - ⛔ 本仓不再另写 AGENTS.md 边界段(原选项 1 的 hotcrm 半边作废)—— 按今日原则三(objectstack#13848),应用消费平台 skills,平台统一教;
- 应用半边仍待裁(随批 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
- 边界文档与工具选择教学定性为平台问题,已上行:objectstack#13879(
裁决(应用半边):不跑计数,维持转换门,本卡关闭 completed(维护者 2026-09-01)
项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· 维护者逐字:「OK」(对本席「不跑,关卡」推荐的核准)。- 存量洞计数不跑:hotcrm 是模板应用,仓内「存量」只有种子数据 —— 真正会被洞伤到的是客户部署里的行,本仓测不到;仓内计数只能测种子卫生,已有种子校验面管辖,不值一次专门派发;
- 三条规则维持转换门语义(
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 ⛔ 不自写边界段(应用仓原则三); - 测量遗产入册:两驱动实测 + 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
- removedneeds-user-decisionNeeds the maintainer's call before work proceedsNeeds the maintainer's call before work proceeds
on Sep 1, 2026
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 — onInMemoryDriver(sparse rows) and onSqliteWasmDriver(column-complete,crm_contact = NULL). Same verdict on both:Compare, on the same schema, a record that was not already gated:
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 → acceptedon a contact-less row sails through even thoughacceptedis 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
requiredWhendoes 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. EveryrequiredWhenhere 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.tsopens 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
requiredWhenretroactively 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:requiredWhenwith a hook or a data audit that catches rows already in the state.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 atc5bc586. Repro: thea row that was ALREADY presented when the rule landeddescribe-block intest/quote-contact-required-when.test.ts(PR #1068) is the measurement in runnable form.