@@ -452,6 +452,195 @@ describe('hasParentScopedRequiredWhen (#4977 gate)', () => {
452452 } ) ;
453453} ) ;
454454
455+ // #4953 — the record a `readonlyWhen` predicate sees is TOTAL over the object's
456+ // DECLARED fields, like the two seams that were materialised in #4649/#4770.
457+ // Before this, `stripReadonlyWhenFields` merged `{...previous, ...data}` raw, so
458+ // a predicate reading a declared column the DRIVER did not echo back faulted —
459+ // and a faulting `readonlyWhen` fails open, i.e. WROTE the field the author
460+ // declared frozen. Which columns come back is a storage property no author can
461+ // see, so the same declaration was enforced or not depending on the driver.
462+ const sparseLockFields = {
463+ fields : {
464+ notes : { type : 'text' } ,
465+ approved_at : { type : 'datetime' } ,
466+ // "while nothing has been approved, the amount is frozen" — the `== null`
467+ // spelling #4649 made the supported one (and the null-guard gate prescribes).
468+ amount : { type : 'currency' , readonlyWhen : 'record.approved_at == null' } ,
469+ } ,
470+ } ;
471+
472+ /** A prior row from a driver that stores only the columns a write touched. */
473+ const sparsePrior = ( ) => ( { id : 'r1' , amount : 100 } ) ;
474+ /** The same row from a driver that returns every declared column. */
475+ const totalPrior = ( approvedAt : unknown ) => ( { id : 'r1' , amount : 100 , notes : null , approved_at : approvedAt } ) ;
476+
477+ describe ( 'readonlyWhen binds a TOTAL record (#4953)' , ( ) => {
478+ it ( 'evaluates `record.<declared> == null` on a SPARSE prior instead of faulting through' , ( ) => {
479+ // THE bug. Pre-#4953: `No such key: approved_at` ⇒ fail-open ⇒ amount written.
480+ const warnings : string [ ] = [ ] ;
481+ const out = stripReadonlyWhenFields ( sparseLockFields , { amount : 999 } , sparsePrior ( ) , {
482+ warn : ( m : string ) => warnings . push ( m ) ,
483+ } as never ) ;
484+ expect ( out ) . toEqual ( { } ) ;
485+ expect ( warnings . some ( ( w ) => w . includes ( 'failed to evaluate' ) ) ) . toBe ( false ) ;
486+ expect ( warnings . some ( ( w ) => w . includes ( 'is read-only (readonlyWhen)' ) ) ) . toBe ( true ) ;
487+ } ) ;
488+
489+ it ( 'still KEEPS the change when the materialised value makes the predicate FALSE' , ( ) => {
490+ // Materialising is not "lock everything": the row HAS an approval date, so
491+ // the lock is off and the legitimate edit lands.
492+ expect (
493+ stripReadonlyWhenFields ( sparseLockFields , { amount : 999 } , { id : 'r1' , amount : 100 , approved_at : '2026-01-01' } ) ,
494+ ) . toEqual ( { amount : 999 } ) ;
495+ } ) ;
496+
497+ it ( 'reads the same verdict on a sparse prior as on a total one (the point)' , ( ) => {
498+ // One declaration, two drivers, one answer. This equality is the guarantee;
499+ // before #4953 the left side kept the change and the right side stripped it.
500+ const sparse = stripReadonlyWhenFields ( sparseLockFields , { amount : 999 } , sparsePrior ( ) ) ;
501+ const total = stripReadonlyWhenFields ( sparseLockFields , { amount : 999 } , totalPrior ( null ) ) ;
502+ expect ( sparse ) . toEqual ( total ) ;
503+ expect ( sparse ) . toEqual ( { } ) ;
504+ } ) ;
505+
506+ it ( 'materialises the `previous` root too, not just `record`' , ( ) => {
507+ const schema = { fields : { ...sparseLockFields . fields , amount : { type : 'currency' , readonlyWhen : 'previous.approved_at == null' } } } ;
508+ expect ( stripReadonlyWhenFields ( schema , { amount : 999 } , sparsePrior ( ) ) ) . toEqual ( { } ) ;
509+ expect ( stripReadonlyWhenFields ( schema , { amount : 999 } , { id : 'r1' , amount : 100 , approved_at : '2026-01-01' } ) ) . toEqual ( { amount : 999 } ) ;
510+ } ) ;
511+
512+ it ( 'applies on the BULK path identically — one payload, N sparse rows' , ( ) => {
513+ // A bulk write must not judge the same predicate by a different record
514+ // shape than a single-id write does.
515+ expect ( stripReadonlyWhenFieldsMulti ( sparseLockFields , { amount : 999 } , [ sparsePrior ( ) ] ) ) . toEqual ( { } ) ;
516+ // ≥1 locked row still drops it for the batch; no locked row still writes.
517+ expect ( stripReadonlyWhenFieldsMulti ( sparseLockFields , { amount : 999 } , [
518+ { id : 'r1' , amount : 1 , approved_at : '2026-01-01' } ,
519+ sparsePrior ( ) ,
520+ ] ) ) . toEqual ( { } ) ;
521+ expect ( stripReadonlyWhenFieldsMulti ( sparseLockFields , { amount : 999 } , [
522+ { id : 'r1' , amount : 1 , approved_at : '2026-01-01' } ,
523+ { id : 'r2' , amount : 2 , approved_at : '2026-02-02' } ,
524+ ] ) ) . toEqual ( { amount : 999 } ) ;
525+ } ) ;
526+
527+ it ( 'does NOT materialise when the prior row is not in hand (no fabrication)' , ( ) => {
528+ // `declared-fields.ts`'s standing rule: without the persisted state,
529+ // defaulting a declared field to null would FABRICATE a value that
530+ // contradicts the stored row. So this case keeps the historical fault →
531+ // fail-open exit, and the engine avoids it by fetching the prior row
532+ // whenever the object declares a readonlyWhen field (`needsPriorRecord`).
533+ const warnings : string [ ] = [ ] ;
534+ const out = stripReadonlyWhenFields ( sparseLockFields , { amount : 999 } , null , {
535+ warn : ( m : string ) => warnings . push ( m ) ,
536+ } as never ) ;
537+ expect ( out ) . toEqual ( { amount : 999 } ) ;
538+ expect ( warnings . some ( ( w ) => w . includes ( 'failed to evaluate — change allowed through' ) ) ) . toBe ( true ) ;
539+ } ) ;
540+
541+ it ( 'never mutates the caller\'s prior record (it is the engine\'s hookContext.previous)' , ( ) => {
542+ const prior = sparsePrior ( ) ;
543+ stripReadonlyWhenFields ( sparseLockFields , { amount : 999 } , prior ) ;
544+ expect ( 'approved_at' in prior ) . toBe ( false ) ;
545+ expect ( 'notes' in prior ) . toBe ( false ) ;
546+ const rows = [ sparsePrior ( ) ] ;
547+ stripReadonlyWhenFieldsMulti ( sparseLockFields , { amount : 999 } , rows ) ;
548+ expect ( 'approved_at' in rows [ 0 ] ! ) . toBe ( false ) ;
549+ } ) ;
550+
551+ it ( 'leaves the fail-open branch ALIVE — an ordering comparison still faults over a total record' , ( ) => {
552+ // `null < null` is `no such overload`, so materialising does not make every
553+ // predicate evaluable. This is exactly why the null-guard gate exists.
554+ const warnings : string [ ] = [ ] ;
555+ const out = stripReadonlyWhenFields (
556+ { fields : { ...sparseLockFields . fields , amount : { type : 'currency' , readonlyWhen : 'record.notes < record.approved_at' } } } ,
557+ { amount : 999 } ,
558+ sparsePrior ( ) ,
559+ { warn : ( m : string ) => warnings . push ( m ) } as never ,
560+ ) ;
561+ expect ( out ) . toEqual ( { amount : 999 } ) ;
562+ expect ( warnings . some ( ( w ) => w . includes ( 'failed to evaluate — change allowed through' ) ) ) . toBe ( true ) ;
563+ } ) ;
564+
565+ it ( 'keeps fail-OPEN for an UNDECLARED key — materialising covers declared fields only' , ( ) => {
566+ // The #4649 line, unmoved: a typo must stay unevaluable so it is reported,
567+ // not silently read as null.
568+ const warnings : string [ ] = [ ] ;
569+ expect ( stripReadonlyWhenFields (
570+ { fields : { amount : { type : 'currency' , readonlyWhen : 'record.stauts == null' } } } ,
571+ { amount : 999 } ,
572+ { id : 'r1' , amount : 100 } ,
573+ { warn : ( m : string ) => warnings . push ( m ) } as never ,
574+ ) ) . toEqual ( { amount : 999 } ) ;
575+ expect ( warnings . some ( ( w ) => w . includes ( 'failed to evaluate — change allowed through' ) ) ) . toBe ( true ) ;
576+ } ) ;
577+
578+ // ── the consequence that moves the OTHER way, pinned rather than discovered ──
579+ it ( '`has(record.<declared>)` is uniformly TRUE — so it locks even on a sparse prior' , ( ) => {
580+ // CEL's own rule: a materialised `null` is a PRESENT key holding null.
581+ // `has()` therefore guards against an UNDECLARED key, not an empty value.
582+ expect ( stripReadonlyWhenFields (
583+ { fields : { ...sparseLockFields . fields , amount : { type : 'currency' , readonlyWhen : 'has(record.approved_at)' } } } ,
584+ { amount : 999 } ,
585+ sparsePrior ( ) ,
586+ ) ) . toEqual ( { } ) ;
587+ } ) ;
588+
589+ it ( '`!has(record.<declared>)` is uniformly FALSE — a lock spelled that way STOPS locking' , ( ) => {
590+ // The one verdict this change moves toward "allowed": pre-#4953 the sparse
591+ // binding made `!has(...)` true and the field was stripped. It was never a
592+ // guarantee — on a driver returning all columns the same declaration never
593+ // locked anything — so the flip replaces a storage-dependent verdict with a
594+ // deterministic one, and the deterministic answer is FALSE. An author who
595+ // means "while the field is empty" writes `== null` (the spelling
596+ // @objectstack /lint's null-guard gate prescribes).
597+ expect ( stripReadonlyWhenFields (
598+ { fields : { ...sparseLockFields . fields , amount : { type : 'currency' , readonlyWhen : '!has(record.approved_at)' } } } ,
599+ { amount : 999 } ,
600+ sparsePrior ( ) ,
601+ ) ) . toEqual ( { amount : 999 } ) ;
602+ } ) ;
603+
604+ // ── blast radius: nothing else at this write gate moves ─────────────────
605+ it ( 'does not touch the object-level rules — `script` / `cross_field` stay fail-CLOSED (#4649)' , ( ) => {
606+ const withRules = {
607+ fields : { ...sparseLockFields . fields } ,
608+ validations : [
609+ { type : 'script' , name : 'typo_rule' , message : 'nope' , condition : 'record.stauts == null' } ,
610+ ] ,
611+ } ;
612+ expect ( ( ) => evaluateValidationRules ( withRules as never , { amount : 1 } , 'update' , {
613+ previous : { id : 'r1' , amount : 100 } ,
614+ } as never ) ) . toThrow ( / c o u l d n o t b e e v a l u a t e d / ) ;
615+ const crossField = {
616+ fields : { ...sparseLockFields . fields } ,
617+ validations : [
618+ { type : 'cross_field' , name : 'typo_cross' , message : 'nope' , condition : 'record.stauts == null' } ,
619+ ] ,
620+ } ;
621+ expect ( ( ) => evaluateValidationRules ( crossField as never , { amount : 1 } , 'update' , {
622+ previous : { id : 'r1' , amount : 100 } ,
623+ } as never ) ) . toThrow ( / c o u l d n o t b e e v a l u a t e d / ) ;
624+ } ) ;
625+
626+ it ( 'does not disturb the #4889 parent binding: unbound root still LOCKS, parent stays unmaterialised' , ( ) => {
627+ // `parent` is a row of ANOTHER object — this function has no declared-field
628+ // list for it — and an ABSENT parent is the signal #4889 depends on.
629+ const warnings : string [ ] = [ ] ;
630+ expect ( stripReadonlyWhenFields ( invoiceLineFields , { quantity : 9999 } , { id : 'l1' , invoice : 'inv1' } , {
631+ warn : ( m : string ) => warnings . push ( m ) ,
632+ } as never ) ) . toEqual ( { } ) ;
633+ expect ( warnings . some ( ( w ) => w . includes ( "reads 'parent'" ) && w . includes ( 'LOCKED' ) ) ) . toBe ( true ) ;
634+ // A parent that IS bound but does not carry the key stays a fault (no
635+ // materialisation of the header): fail-open, the change goes through.
636+ const warnings2 : string [ ] = [ ] ;
637+ expect ( stripReadonlyWhenFields ( invoiceLineFields , { quantity : 9999 } , { id : 'l1' , invoice : 'inv1' } , {
638+ warn : ( m : string ) => warnings2 . push ( m ) ,
639+ } as never , { id : 'inv1' } ) ) . toEqual ( { quantity : 9999 } ) ;
640+ expect ( warnings2 . some ( ( w ) => w . includes ( 'failed to evaluate — change allowed through' ) ) ) . toBe ( true ) ;
641+ } ) ;
642+ } ) ;
643+
455644// #2948 — static `readonly:true` write enforcement (caller-supplied only).
456645const stampedFields = {
457646 fields : {
0 commit comments