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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions