You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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:
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:
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.
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.
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.