Repository navigation
Expand file tree
/
Copy pathvalidate-expressions.ts
More file actions
3703 lines (3609 loc) · 206 KB
/
Copy pathvalidate-expressions.ts
File metadata and controls
3703 lines (3609 loc) · 206 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Build-time expression validation (ADR-0032 §Decision 1a + 1b).
*
* Runs at `objectstack compile`, where the whole normalized stack is in hand —
* so flow conditions can be checked against the *resolved* object schema
* (field existence) in addition to CEL syntax. Uses the one shared validator
* from `@objectstack/formula`, so the verdict matches `registerFlow` and the
* agent `validate_expression` tool exactly.
*
* Scope: flow predicates (start/decision `config.condition` + edge `condition`),
* every **descriptor-declared** expression slot named by
* `FLOW_NODE_EXPRESSION_PATHS` (#4027 — e.g. a screen field's `visibleWhen`),
* object validation-rule / formula predicates, and UI action `visible` /
* `disabled` predicates. Each error is located (flow/object/action +
* node/edge/field) with a corrective message.
*
* Since #4763 it also carries the **null-guard** verdict: an ordering /
* arithmetic operator applied to a nullable declared field that no `!= null`
* test dominates is rejected here, so the `has(a) && has(b) && a < b` trap
* (which reads as a guard and is not one) never reaches a production write.
* See `validate-null-guards.ts` for the decision procedure and its scope.
*
* Since #24677 it also refuses the one declaration the #24316 hop refusal
* presupposes: a `lookup` / `master_detail` field whose `reference` is a
* stored-metadata-body table, located at that field's `reference`. It is not a
* predicate, but it is judged here so that `os validate`, `os build` and the
* object save door, which all run this rule's object passes, refuse it alike.
*
* ## Scope — the keys this rule reads, and the ones it deliberately does not
*
* This rule is registered `input: 'parsed'` (`authoring-rules.ts`), so what it
* sees on the compile path is what `ObjectStackSchema` returned. Every key it
* reads is one `@objectstack/spec` DECLARES. That is a contract, not a style
* preference: the strict sub-schemas reject an undeclared key by NAME, so a
* branch keyed on one is inert for every stack an author can ship (#4984,
* #5009, #5017).
*
* | Read | Declared by |
* |----------------------------------------|-----------------------------------|
* | `objects[].validations[]` | `ObjectSchema` |
* | `objects[].attachedOnRead` | `ObjectSchema` |
* | `validations[].condition` / `.when` / `.then` / `.otherwise` | the six `*ValidationSchema` variants |
* | `objects[].fields[].reference` | `FieldSchema` |
* | `objects[].fields[].expression` | `FieldSchema` |
* | `objects[].fields[].options` | `FieldSchema` |
* | `options[].value` / `.visibleWhen` | `SelectOptionSchema` |
* | `objects[].fieldGroups` | `ObjectSchema` |
* | `fieldGroups[].key` / `.visibleWhen` | `ObjectFieldGroupSchema` |
* | `actions[].objectName` | the action schema |
* | `sharingRules[].condition` / `.object` | `SharingRuleSchema` |
*
* NOT read, and each for a reason that is a schema fact (all verified against
* the live `.shape` in `validate-expressions.test.ts`):
*
* - `objects[].validationRules` — `ObjectSchema` declares `validations` and is
* strict; the alias is refused with "Did you mean `validationRules` →
* `validations`?".
* - `validations[].expression` / `.predicate` / `.formula` / `.rule` — the four
* names `validation.zod.ts` lists in `aliases: { … : 'condition' }`, i.e. the
* ones it rejects by name. This one was the worst of the family: the chain
* read them BEFORE `condition`, so for a rule carrying both spellings the
* canonical predicate was short-circuited away and the rejected alias
* validated in its place. Producer and consumer gave two different accounts
* of the same metadata (#5017).
* - `sharingRules[].criteria` / `.predicate` — `criteria` is the runtime's own
* spelling of the COMPILED predicate (`criteria_json`), mapped back to the
* authored `condition` in the schema's rejection (#3896); `predicate` is
* refused outright. #4984 removed the same pair from the org-axis rule.
* - `objects[].fields[].referenceTo` — `field.zod.ts:331` maps it (with
* `relatedTo` / `target` / `targetObject` / `lookupObject`) to `reference`.
* - `actions[].object` — the action schema's own rejection says "Did you mean
* `object` → `objectName`?".
* - `objects[].fields[].formula` / `.calculation` / `.compute` — the three names
* `field.zod.ts:333` aliases back to `expression`. The field-formula pass read
* `formula` until #5026, so it had never run against a stack that parses;
* converging it onto `expression` ACTIVATED the check rather than deleting a
* dead branch, which is why it moved on its own PR with a real-metadata sweep
* attached rather than riding along with #5017's five removals.
*
* Alias tolerance belongs at the schema's refusal, not in a consumer (Prime
* Directive #12) — in a consumer it also converts a loud, named rejection into
* a silently-inert (or, above, silently-WRONG) gate.
*
* Every read in this file is now a key the spec declares; the meta-guard in
* `validate-expressions.test.ts` pins that with no tracked exceptions left.
*/
import {
validateExpression,
collectCelRootIdentifiers,
parseCelToAst,
SCOPE_ROOTS,
analyzeRelationshipTraversals,
buildScope,
} from '@objectstack/formula';
import type { RelationshipTraversalAnalysis } from '@objectstack/formula';
import {
collectFlowGraphs,
flowNodeConfigRefusals,
predicateSlotRefusal,
resolveFlowNodeExpressions,
flowNodeValueTemplateRefusals,
flowNodeTextSlotSources,
textSlotTemplateRefusal,
structuralConditionRefusal,
closedScopeTemplateRefusal,
templateSlotSource,
} from '@objectstack/spec/automation';
// [#24798] A bulk action's `confirmText` is a `template` slot over the closed
// scope the spec declares for it — judged below by the same template judge.
import { bulkConfirmTemplateRoots } from '@objectstack/spec/ui';
// [#24814] An action's copy — `confirmText`, `successMessage`, `errorMessage` —
// is a `template` slot too, judged in its own module by the same judge.
import { actionCopyTemplateIssues } from './validate-action-copy-templates.js';
// [#15137] The `value`-role half. Same two published primitives the engine
// composes at `registerFlow` (`AutomationEngine.valueEnvelopeRefusals`), in the
// same order: the SHAPE rule lives in the spec's `FlowValueSlotSchema` (the
// slot-neutral name since #19938 — it refuses a non-`cel` dialect and the
// source-less `{ dialect: 'cel' }` that `validateExpression` reads as "not
// authored"), the CEL rule in `validateExpression('value', …)`. Neither
// refusal string is spelled here.
import { FlowValueSlotSchema, VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation';
import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation';
// [#17495] The blank-source half of the structural-condition refusal, imported
// rather than restated: this is the EDGE door's own rule
// (`FlowEdgeSchema.condition` composes it since #15807) and the very schema
// `AutomationEngine.registerFlow` asks since #17322, so the author-time answer
// and the registration answer are one rule with one sentence. ⛔ Never a second
// hand-written notion of "blank" here — that drift is what #15662 built the
// shared refusal to prevent.
import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
import { referenceCarrierOf, referenceTargetOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data';
import { isStoredMetadataBodyObject, STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel';
// [#22394] The acting user's declared members — the contract half of the option
// verdict's member allowlist for `current_user` and its ADR-0068 aliases. Read
// off the schema at first use, never restated here.
import { EvalUserSchema } from '@objectstack/spec/identity';
import { collectFlowVariableNames, shadowedFieldReads, shadowedFieldMessage } from './flow-variable-scope.js';
import {
flowCelEntrances,
flowCelRootScope,
perWriteSnapshotEntrance,
unboundFlowCelRoots,
unboundFlowCelRootMessage,
} from './flow-cel-root-scope.js';
import type { FlowCelEntrance } from './flow-cel-root-scope.js';
import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js';
import { findUnguardedNullableOperands, nullGuardMessage } from './validate-null-guards.js';
import type { NullGuardOutcome } from './validate-null-guards.js';
import { recordsOf } from './object-graph.js';
export interface ExprIssue {
where: string;
message: string;
source: string;
/**
* `error` fails the build (e.g. a bare ref in a record-scoped formula). `warning`
* is advisory and never fails it (e.g. a possible field typo in a flattened flow
* condition, which might be a flow variable). Absent ⇒ treat as `error`.
*/
severity?: 'error' | 'warning';
/**
* [#26858] The judged slot's key path in the stack, in the spelling the other
* rules' findings carry (`flows[0].edges[1].condition`), beside the human
* `where`. Set only where the slot has one: a top-level flow edge's
* `condition`, and (#27643) an object validation rule's `condition` / `when`
* (`objects[0].validations[1].condition`), a nested branch's included
* (`objects[0].validations[1].then.condition`). Absent, the finding is
* located by `where` alone.
*/
path?: string;
}
type AnyRec = Record<string, unknown>;
/**
* object name → set of its field names, for schema-aware field checks.
*
* Authored fields UNION the system columns the platform injects on that object
* (#5378). Both halves are needed because the runtime resolves both: a predicate
* saying `has(record.owner_id)` on an object that never declares `owner_id`
* evaluates fine, because the registry provisioned the column — so rejecting it
* here was the platform's own linter denying the platform's own contract, and it
* pushed apps into re-declaring system columns purely to satisfy this pass
* (hotcrm#548 declared `owner_id` on all 12 business objects for that reason).
*
* The injected half is CONDITIONAL, per object, via the spec derivation the
* registry itself consumes — not a blanket allowance of every system name. That
* distinction is the whole value: on `ownership: 'none'` the platform injects no
* `owner_id`, so `record.owner_id` there stays the error it should be. A blanket
* union would have traded a false positive for a false negative and called it
* fixed.
*/
function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
const idx = new Map<string, string[]>();
for (const obj of objects) {
const name = typeof obj.name === 'string' ? obj.name : undefined;
if (!name) continue;
const fields = obj.fields;
let names: string[] = [];
// The LIST shape is read through `recordsOf` (#15742). `Array.isArray`
// proves the list, never its members: an empty item in a YAML `fields:`
// list deserialises to `null`, and the cast this replaced dereferenced it
// before the `.filter` two calls later could drop it. The two sibling
// readers below already guard (`buildFieldTypeIndex` reads `(f)?.name`,
// `fieldEntries` filters before mapping) and both drop such a member in
// SILENCE — it carries no author-written name, so there is nothing to
// report about it — which is what `recordsOf` does for the array shape too.
// The MAP shape keeps `Object.keys`: there the author's KEY is the field
// name, which is exactly what this "did you mean?" index needs.
if (Array.isArray(fields)) names = recordsOf(fields).map(f => f.name).filter((n): n is string => typeof n === 'string');
else if (fields && typeof fields === 'object') names = Object.keys(fields as AnyRec);
// Injected columns come second, de-duplicated by insertion order: a DECLARED
// `owner_id` is the author's field (the registry lets it win), so the
// authored spelling keeps its position in the "did you mean?" candidates.
// A declared read attachment is NOT here: it is not a column, so a stored
// row never carries it, and this index is what every stored-row site
// reads. The sites that bind a served row add the blocks themselves — see
// {@link SERVED_ROW_SITES} and {@link withAttachedBlocks}.
idx.set(name, [...new Set([...names, ...injectedColumnsFor(obj)])]);
}
return idx;
}
/**
* [#22211 ruling A] The object's declared read-attachment blocks —
* `ObjectSchema.attachedOnRead`, block name → (leaf key → value type) — as
* block name → its leaf keys, in declaration order. Empty for an object that
* declares none, which is every object that does not write the key: such an
* object hands the shared validator no `attachedOnRead` and keeps exactly the
* verdicts it had.
*
* Own keys only, read off the parsed value; the spec's strict record has
* already refused a malformed block or leaf at parse.
*/
function attachedOnReadOf(obj: AnyRec): Record<string, string[]> {
const declared = obj.attachedOnRead;
const out: Record<string, string[]> = {};
if (!declared || typeof declared !== 'object' || Array.isArray(declared)) return out;
for (const [block, leaves] of Object.entries(declared as AnyRec)) {
if (!leaves || typeof leaves !== 'object' || Array.isArray(leaves)) continue;
out[block] = Object.keys(leaves as AnyRec);
}
return out;
}
/**
* object name → its declared read attachments (block name → leaf keys), for
* the served-row sites ({@link SERVED_ROW_SITES}): there each block name joins
* the field-existence set, and the shared validator judges the second segment
* of `record.<block>.<leaf>` against the block's leaves
* (`ExprSchemaHint.attachedOnRead`). Only objects that declare at least one
* block get an entry, so `index.get(name)` is `undefined` — the hint absent —
* for every other object.
*/
function buildAttachedOnReadIndex(objects: AnyRec[]): Map<string, Record<string, string[]>> {
const idx = new Map<string, Record<string, string[]>>();
for (const obj of objects) {
const name = typeof obj.name === 'string' ? obj.name : undefined;
if (!name) continue;
const blocks = attachedOnReadOf(obj);
if (Object.keys(blocks).length > 0) idx.set(name, blocks);
}
return idx;
}
/**
* The expression sites whose `record` is a row a service SERVES — the only
* sites where a declared read attachment resolves.
*
* `ObjectSchema.attachedOnRead` declares the blocks a service attaches to the
* rows it serves, computed per caller and never stored. A block is therefore
* on `record` only where `record` is such a served row, and this list names
* those sites:
*
* - an action's `visible` and `disabled` predicates. The client's action
* runtime evaluates them against the row the surface fetched, and a surface
* that reads the declaring service's own routes holds the row that service
* served — the console reads `sys_approval_request` through the approvals
* routes, whose rows carry the `viewer` block. A surface that fetched the
* row elsewhere has no block, which is why such a predicate guards the
* block with `has()`.
*
* Every other site binds the STORED row, and judges field existence against
* the object's columns alone ({@link buildFieldIndex}):
*
* - a flow's node and edge conditions: the record-change trigger seeds
* `record` from the write's payload over the stored row, and a flow's
* subject load reads the data engine;
* - validation rules, a field's `requiredWhen` / `readonlyWhen` and an
* option's `visibleWhen`: evaluated on the write, against the stored row
* merged with the payload (`rule-validator.ts`);
* - field formulas: computed on the stored row;
* - sharing-rule and hook conditions: judged against stored rows and the
* write's record.
*
* A block named at one of those sites is refused as an unknown field, because
* at run time it is one: the expression faults on the missing key on every
* row. A field's `visibleWhen` is not on the list either — it renders against
* whatever row the surface holds, the stored row on an edit form — nor is a
* field's cell formatting rule (#22228), for the same reason; no field
* predicate reads a block.
*
* `check` adds the blocks only for a call site that names an entry here, so a
* new site is fields-only until its binding is shown to be a served row and it
* is listed.
*/
const SERVED_ROW_SITES = ['action visible', 'action disabled'] as const;
type ServedRowSite = (typeof SERVED_ROW_SITES)[number];
/**
* The field-existence set at a served-row site: the object's columns, then
* each block it declares — last, so a column keeps its place in the
* "did you mean?" candidates. `fields` unchanged when the object declares no
* block.
*/
function withAttachedBlocks(
fields: string[] | undefined,
blocks: Record<string, string[]> | undefined,
): string[] | undefined {
if (!blocks) return fields;
return [...new Set([...(fields ?? []), ...Object.keys(blocks)])];
}
/**
* [#8116] The roots this file's unprovisioned-anchor warning resolves against
* the bound object — the same pair the #4763 null-guard gate resolves, for the
* same reason: both bind the object's record shape, so a single-segment member
* read off either names one of the object's columns.
*/
const BOUND_RECORD_ROOTS = ['record', 'previous'] as const;
/** Minimal structural view of a `parseCelToAst` node (`{ op, args }`). */
type CelNodeLike = { op?: string; args?: unknown };
function isCelNode(v: unknown): v is CelNodeLike {
return !!v && typeof v === 'object' && typeof (v as CelNodeLike).op === 'string';
}
/**
* [#8116] Every single-segment `record.<f>` / `previous.<f>` member read in
* the source, as `field → operand-as-written` (first spelling wins). Empty
* when the source does not parse — the syntax pass owns that defect, and one
* broken predicate must produce one finding, not two.
*
* Deliberately NEVER a bare identifier: in a flattened flow scope a bare name
* may be a flow variable, and a false finding here is the trust-killer
* ADR-0072 D1 names. A `has(record.x)` guard is covered for free — `record.x`
* is an ordinary member-read node inside the call's argument list. Nested
* traversal (`record.owner_id.name`) contributes its FIRST segment, which is
* the column the query engine must resolve on this object.
*/
function collectBoundRecordReads(source: string): Map<string, string> {
const out = new Map<string, string>();
const ast = parseCelToAst(source);
if (!ast) return out;
// NB: local names are chosen to stay out of the #5017 meta-guard's receiver
// scan (`node` is a METADATA receiver there — flow `nodes[]` — and this walk
// reads AST internals, not metadata keys).
const pending: unknown[] = [ast];
while (pending.length > 0) {
const celNode = pending.pop();
if (!isCelNode(celNode)) continue;
if (
(celNode.op === '.' || celNode.op === '.?') &&
Array.isArray(celNode.args) &&
celNode.args.length >= 2
) {
const [celRecv, seg] = celNode.args as [unknown, unknown];
if (
typeof seg === 'string' &&
isCelNode(celRecv) &&
celRecv.op === 'id' &&
typeof celRecv.args === 'string' &&
(BOUND_RECORD_ROOTS as readonly string[]).includes(celRecv.args)
) {
if (!out.has(seg)) out.set(seg, `${celRecv.args}.${seg}`);
}
}
const celArgs = celNode.args;
if (isCelNode(celArgs)) pending.push(celArgs);
else if (Array.isArray(celArgs)) {
for (const a of celArgs) {
if (isCelNode(a)) pending.push(a);
else if (Array.isArray(a)) for (const b of a) if (isCelNode(b)) pending.push(b);
}
}
}
return out;
}
/**
* object name → (field name → field type), for the #1928 tier-4 type-soundness
* check. Handles both `fields` shapes (array of `{name, type}` and name-keyed
* map). Fields with a non-string `type` are simply omitted (treated as `dyn`).
*
* DECLARED fields only — deliberately NOT widened with the injected columns
* {@link buildFieldIndex} resolves (#5378). Their TYPES are the registry's
* (`AUDIT_FIELD_DEFS` and its siblings own what each column looks like; the spec
* derivation owns only which ones exist), so typing them here would be the
* hand-copied second table this change exists to avoid. Omitted ⇒ `dyn` ⇒ no
* type-soundness verdict on an injected column, which is the safe direction: the
* pass gains no false finding, it simply does not cover them yet.
*/
function buildFieldTypeIndex(objects: AnyRec[]): Map<string, Record<string, string>> {
const idx = new Map<string, Record<string, string>>();
for (const obj of objects) {
const name = typeof obj.name === 'string' ? obj.name : undefined;
if (!name) continue;
const fields = obj.fields;
const types: Record<string, string> = {};
if (Array.isArray(fields)) {
for (const f of fields as AnyRec[]) {
const fn = (f as AnyRec)?.name;
const ft = (f as AnyRec)?.type;
if (typeof fn === 'string' && typeof ft === 'string') types[fn] = ft;
}
} else if (fields && typeof fields === 'object') {
for (const [fn, def] of Object.entries(fields as AnyRec)) {
const ft = (def as AnyRec)?.type;
if (typeof ft === 'string') types[fn] = ft;
}
}
idx.set(name, types);
}
return idx;
}
/** The field list of an object, whichever of the two `fields` shapes it uses. */
function fieldEntries(obj: AnyRec): Array<[string, AnyRec]> {
const fields = obj.fields;
if (Array.isArray(fields)) {
return (fields as AnyRec[])
.filter((f) => f && typeof f === 'object' && typeof f.name === 'string')
.map((f) => [f.name as string, f] as [string, AnyRec]);
}
if (fields && typeof fields === 'object') {
return Object.entries(fields as AnyRec)
.filter(([, def]) => !!def && typeof def === 'object')
.map(([n, def]) => [n, def as AnyRec] as [string, AnyRec]);
}
return [];
}
/**
* Can this declared field hold `null` when a predicate reads it? (#4763)
*
* Deliberately conservative — this feeds a **build-breaking** verdict, so every
* uncertainty resolves to "not nullable" (no finding). A field is treated as
* always-valued when it is `required`, carries a `defaultValue`, declares a
* default option (`options: [{ …, default: true }]` — the select idiom), or is
* an autonumber the platform populates.
*
* The select-idiom branch is grounded, not assumed (#7246). It was the only
* consumer of `SelectOption.default` anywhere in the repo while the insert path
* ignored the key entirely, so it concluded "always valued" about a column that
* really did store `null` — a build-breaking verdict resting on a declaration
* nothing honoured, and a predicate reading such a field could be silenced by
* it. `ObjectQL.applyFieldDefaults` now falls back to the marked option when
* the field declares no `defaultValue`, so the premise this branch always
* stated is true on the write path. The two sides are kept honest BY
* CONSTRUCTION: the engine reads the canonical `default` spelling, without a
* `type` test, exactly as this does — so there is no field this calls
* always-valued that the engine leaves empty.
*/
function isNullableField(def: AnyRec): boolean {
if (def.required === true) return false;
if (def.defaultValue !== undefined && def.defaultValue !== null) return false;
if (def.type === 'autonumber') return false;
const options = def.options;
if (Array.isArray(options) && options.some((o) => !!o && typeof o === 'object' && (o as AnyRec).default === true)) {
return false;
}
return true;
}
/**
* object name → set of field names that may hold `null` (#4763).
*
* DECLARED fields only, like {@link buildFieldTypeIndex} and for a sharper
* reason: this index feeds a BUILD-BREAKING verdict, so adding the injected
* columns {@link buildFieldIndex} now resolves would turn `record.created_at`
* comparisons into errors on stacks that ship today. Nullability there is also
* genuinely per-column and not derivable from the existence plan — the driver
* always populates `created_at`, while `owner_id` really can be NULL — so it
* needs the registry's column definitions, not this module's guesses. Leaving
* them out costs coverage, never a false error.
*/
function buildNullableFieldIndex(objects: AnyRec[]): Map<string, Set<string>> {
const idx = new Map<string, Set<string>>();
for (const obj of objects) {
const name = typeof obj.name === 'string' ? obj.name : undefined;
if (!name) continue;
const nullable = new Set<string>();
for (const [fname, def] of fieldEntries(obj)) {
if (isNullableField(def)) nullable.add(fname);
}
idx.set(name, nullable);
}
return idx;
}
/**
* [#4889] Does this CEL source read the `parent` root — the master-detail
* header the write path binds for a detail object's field predicates?
*
* Decided from the parsed AST (the same `collectCelRootIdentifiers` the runtime
* gate uses, so build and runtime can never disagree about what "reads
* `parent`" means), never a substring scan: a field named `parent_id`, or the
* string literal `'parent'`, is not a reference to the binding. A source that
* does not parse answers `false` — the ordinary syntax pass already reports it,
* and this gate must not report the same defect twice under a worse name.
*/
function readsParentRoot(source: string): boolean {
const roots = collectCelRootIdentifiers(source);
return roots.ok && roots.roots.includes('parent');
}
/**
* The number of `master_detail` relationships an object declares — what decides
* whether `parent` is a fact the metadata states (#4889). Exactly one ⇒ the
* write path binds that master as `parent`. Zero ⇒ nothing to bind. Two ⇒ no
* single "the parent", and picking one by declaration order would make a
* data-integrity lock depend on field ordering.
*/
function masterDetailCount(obj: AnyRec): number {
let n = 0;
for (const [, def] of fieldEntries(obj)) {
if (def.type !== 'master_detail') continue;
// `reference` is the ONLY spelling `FieldSchema` declares. `referenceTo`
// (with `relatedTo` / `target` / `targetObject` / `lookupObject`) is a
// rejected alias — `field.zod.ts:331` maps it to `reference` in the strict
// error map, so a field spelling it does not parse (#5017). See the
// `## Scope` table on this module for why a consumer must not re-admit it.
//
// [#18550] Read through the ONE arbiter. ABSENCE is unchanged and still
// uncounted — `undefined` / `null` / `''` answer `undefined`, and the
// `.trim()` test below still drops a whitespace-only carrier, which names
// no object either. UNREADABILITY used to be uncounted too, and that is
// the silence: an object-valued carrier made a declared `master_detail`
// invisible to this count, so `parent` was judged unbound (or two masters
// read as one) from metadata that does declare a relationship.
// ⭐ The literal `.reference` read STAYS here, in the argument, and only
// the SHAPE judgment moves out — the form `validate-security-posture.ts`
// and `data-model-rules.ts` already use, and for their stated reason: the
// #5017 receiver meta-test reads this rule's SOURCE to prove it reads
// `reference` and never an alias, and a read folded inside a helper call
// would disarm that scan silently.
const ref = referenceCarrierOf({ reference: def.reference }, 'validate-expressions masterDetailCount');
if (ref !== undefined && ref.trim() !== '') n += 1;
}
return n;
}
/** The raw CEL source behind a predicate slot (string or `{ dialect, source }`). */
function celSourceOf(raw: unknown): string | undefined {
if (typeof raw === 'string') return raw;
if (raw && typeof raw === 'object') {
const rec = raw as AnyRec;
// A non-CEL dialect (`js`) has its own null semantics — not ours to judge.
if (typeof rec.dialect === 'string' && rec.dialect !== 'cel') return undefined;
if (typeof rec.source === 'string') return rec.source;
}
return undefined;
}
/** One predicate a validation rule carries — see {@link rulePredicates}. */
interface RulePredicate {
label: string;
raw: unknown;
/** 0 for the rule itself; 1 inside its `then` / `otherwise`, and so on down. */
depth: number;
/** The key the predicate is written under on its own rule. */
slot: 'condition' | 'when';
/** The name of the rule that carries it (the nested rule's own, below depth 0). */
ruleName: string;
/**
* [#27643] Its key path inside the top-level rule (`condition`, `when`,
* `then.condition`, `otherwise.then.when`), for a finding's `path`.
*/
key: string;
}
/**
* Every predicate a validation rule carries, including the ones nested inside a
* `conditional` rule's `then` / `otherwise` — the trap hides there just as
* happily as at the top level.
*/
function rulePredicates(rule: AnyRec, path: string, depth = 0, keyPrefix = ''): RulePredicate[] {
const out: RulePredicate[] = [];
const name = typeof rule.name === 'string' ? rule.name : '?';
const here = path ? `${path} → '${name}'` : `'${name}'`;
// `condition` is the declared predicate key on every validation-rule variant
// that has one (`script`, `cross_field`). `expression` / `predicate` /
// `formula` / `rule` are the four names `validation.zod.ts` REJECTS by name
// (`aliases: { formula: 'condition', expression: 'condition', predicate:
// 'condition', rule: 'condition' }`), so a rule spelling any of them does not
// parse. Reading them here put the canonical key in THIRD position — an
// author who wrote both `condition` and a rejected alias had their canonical
// predicate short-circuited away and the alias validated instead (#5017).
const main = rule.condition;
if (main != null) {
out.push({ label: `validation rule ${here}`, raw: main, depth, slot: 'condition', ruleName: name, key: `${keyPrefix}condition` });
}
if (rule.when != null) {
out.push({
label: `validation rule ${here} when-predicate`, raw: rule.when, depth, slot: 'when', ruleName: name, key: `${keyPrefix}when`,
});
}
for (const branch of ['then', 'otherwise'] as const) {
const nested = rule[branch];
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
out.push(...rulePredicates(nested as AnyRec, `${here} ${branch}`, depth + 1, `${keyPrefix}${branch}.`));
}
}
return out;
}
/**
* A FIELD-level conditional rule (`visibleWhen` / `readonlyWhen` /
* `requiredWhen`) that reaches for a namespace root the field level does not
* bind. The field level binds THREE roots and nothing else — `record`,
* `previous`, and `parent` on a master-detail line item — measured at three
* independent ends: the server (`rule-validator.ts` binds
* `{ record, previous, extra: { parent } }` for `readonlyWhen` and
* `{ record, previous, ...parentScope }` for `requiredWhen`), the client
* (`evalFieldPredicate` binds `record` + `previous` + a caller `scope` that
* is only ever `{ parent }` across objectui's five field-level call sites),
* and the authoring surface (objectui's `FIELD_RULE_ROOTS`, whose comment
* says "nothing else").
*
* ## Why this is a rule of its own rather than a missing root
*
* Until commit e9b526597 the same rejection fell out of `@objectstack/formula`'s
* `SCOPE_ROOTS` not listing `current_user` — a global baseline, doing a
* per-surface job by accident. Two things were wrong with that:
*
* 1. The rest of the package said the opposite. `introspectScope` hands
* `current_user` to authors as a legal root and `checkRoleCatalog`'s four
* position-membership regexes all lead with it, because ADR-0068 D1 makes
* it THE canonical spelling and `buildScope` really does mount it. One
* package, two accounts of one root.
* 2. The diagnostic was the generic bare-field one, so it prescribed
* "Write `record.current_user`" — a shape that binds on NO layer. An
* author who followed it wrote something strictly worse than what they
* started with, and the failure stayed silent.
*
* So the baseline now declares the root (a reference through it never faults
* platform-wide) and the surface that genuinely cannot bind it says so here,
* in its own words, with the prescriptions that actually exist.
*
* ## Why the membership test is an ALLOWLIST (#6713)
*
* Until #6713 this rule matched a hand-written DENYLIST — one root in #6584,
* three after #6585 (`current_user` / `user` / `ctx`). The denylist was never
* the truth: the three anchors above pin a three-item ALLOWLIST, and
* everything outside it is equally unbound, faults identically, and — this is
* the part that made the hole invisible — is equally SILENT. A root in
* `SCOPE_ROOTS` resolves in the strict env, so the bare-reference check one
* line up never fires on it either. #6713 measured 21 roots living in that
* gap (`input`, `output`, `os`, `vars`, `variables`, `automation`, `context`,
* `args`, `item`, `env`, `step`, `result`, `trigger`, `event`, `payload`,
* `data`, `params`, `config`, `settings`, `features`, `current`), two of them
* highly credible author typos rather than theoretical members:
*
* - `os.user.id` — ADR-0068 D1's FOURTH user spelling. #6585 took three and
* left this one, so the same semantic error stayed silent under one of the
* four names the platform itself mounts the user object under.
* - `data.status == 'x'` — `data` is the LEGAL root of this very
* `visibleWhen` key on a METADATA form (`view.zod.ts`: "Root: `record` …
* in runtime forms, or `data` in metadata forms"). Two form kinds, one key
* name, different roots — and the repo's own `*.form.ts` files are full of
* the `data` spelling for an author to copy.
*
* A denylist cannot track `SCOPE_ROOTS`: every root added there is unreported
* here until somebody remembers to copy it across (`current_user` itself
* arrived in commit e9b526597, and needed that same change (#6584) to be noticed). The allowlist inverts the
* maintenance burden onto the three roots that are pinned by three anchors and
* change only when the evaluators do.
*
* The membership test is {@link FIELD_RULE_JUDGED_ROOTS} minus the allowlist.
* Its bulk is `SCOPE_ROOTS`, taken from `@objectstack/formula` rather than
* restated here (#6713 published it for this consumer) — one list, one
* definition, no drift — plus the nowhere-bound roots #13935 measured outside
* that baseline; see "Why the judged vocabulary is WIDER than `SCOPE_ROOTS`"
* below.
* It deliberately is NOT `firstUndeclaredReference`, the declaredness oracle
* the sibling visibility rule uses, and the difference is a measured false
* positive rather than a preference: the strict env also declares CEL's TYPE
* names, so `type(record.x) == string` reports `string` as a root that
* "resolves". Judging by declaredness would reject that legitimate predicate;
* judging by membership does not. Everything the oracle owns and this list
* does not — bare field references, comprehension-macro variables — keeps
* falling to the bare-reference check, which has the right prescription for
* it. The two partitions are disjoint and there is no gap between them.
*
* ⚠️ That disjointness used to hold for FREE and no longer does. While every
* judged root was a `SCOPE_ROOTS` member it was declared in the strict env, so
* the bare-reference check could not fire on it whatever this rule decided.
* A NOWHERE-BOUND root is undeclared there, so both checks see it — the walk
* suppresses the bare-reference verdict for a root this rule has claimed
* (`claimedRoot` on the `check` closure) to keep the invariant true by
* construction instead of by coincidence.
*
* ## Why it is an error and not a warning
*
* Every fault direction available here is either silent or the opposite of
* what the author declared (see the per-slot table below): a `visibleWhen`
* written to HIDE leaves the field visible to everyone, a `readonlyWhen`
* written to unlock-under-a-condition locks the field on every write, and a
* `requiredWhen` — silent until ADR-0137 D2 — now refuses every write that
* reaches the root, because that root is never bound there. Only the last
* produces a runtime error at all, and it produces it on the first write in
* production; build time is where the author should meet it (#6146).
*
* Verdict scope is the field level only. Per-option `visibleWhen` is checked
* by the loop in the field walk and deliberately NOT passed through here:
* options resolve against the host's predicate scope, which binds
* `current_user` (ADR-0068 / objectui#2284) — that surface is where such a
* predicate belongs, which is why it is also the first prescription below.
* The option has its own root verdict (`optionVisibleWhenRootIssue`, #22157),
* over the roots the server's option check binds: the acting user, and never
* `parent`.
*
* ## The user roots, and why `ctx` is judged whole-root (#6585)
*
* ADR-0068 D1 makes `user` and `ctx.user` ALIASES of `current_user` — one
* `EvalUser` object under every spelling (`buildScope` in
* `formula/stdlib.ts` hangs the same reference on `current_user` / `user` /
* `ctx.user` / `os.user`). Matching only the canonical spelling meant the
* identical semantic error got a diagnostic under `current_user` and total
* silence under the aliases — which spelling the author picked decided
* whether they got the diagnostic, the exact fork AI authors cannot
* self-check. All four roots now share one verdict and one prescription;
* the message names the spelling found, nothing else varies. (`os` joined the
* set in #6713 — it is the fourth name `buildScope` mounts the same object
* under, and the allowlist would have rejected it anyway; what the user tier
* decides is only WHICH prescription it gets, and the user one is right for
* `os.user`. `os.org` / `os.env` land in the same tier too. The option
* surface named by the first prescription does NOT bind them: the server's
* option check fills `os` with its `user` member only, so a predicate moved
* there meets that surface's member verdict (#22274), which names what is
* bound instead.)
*
* `ctx` is judged as a WHOLE root, not only in `ctx.user` form, because at
* this surface that is simply what is true: `buildScope` creates the `ctx`
* root ONLY when the evaluation carries a user (`scope.ctx = { user }`
* inside `if (ctx.user !== undefined)`), and no field-level site passes one
* — the server binds `record`+`previous`(+`parent`) (`rule-validator.ts`
* `readonlyWhenBindings` / the `requiredWhen` block) and the client's
* `evalFieldPredicate` binds `record`+`previous`+caller `scope` (only ever
* `{ parent }`). So `ctx.locale` faults exactly like `ctx.user.id` here.
* `ctx` IS ActionEngine's predicate root elsewhere — the platform's real
* `ctx.user` predicates all sit on action `visible` (`sys-user.object.ts`,
* `sys-invitation.object.ts`), a surface this helper never reads — and
* measured usage of field-level `*When` with ANY user root is zero across
* examples/, packages/ and objectui (#6585's sweep).
*
* The rejected alternative was matching `ctx` only in its `ctx.user` form.
* `collectCelRootIdentifiers` reports ROOTS and drops member names by
* design, so that reading needs a source-level spelling match — and a
* spelling match is precisely the defect this rule exists to remove: it
* would re-open the same fork one level down (`ctx["user"].id` silent,
* `ctx.user.id` rejected), while leaving a real fail-open fault (`ctx.locale`)
* unreported for the sake of a narrower rule NAME.
*
* ## The message tiers on TWO orthogonal axes
*
* The causal half tiers by SLOT (#6716); the prescription half tiers by ROOT
* (#6713). They are independent — `data` on a `readonlyWhen` needs the
* metadata-form prescription AND the locked-field consequence — so one
* skeleton carries both rather than a message per combination.
*
* ### Axis 1 — the consequence, by SLOT (#6716, all three cells measured)
*
* Until #6713/#6716 all three slots shared ONE sentence: "the predicate
* faults and falls back to VISIBLE, leaving the field the test was meant to
* hide showing for everyone". That is precise for exactly one of them. The
* measurement, at both ends of each slot:
*
* - **`visibleWhen` — client-only, and since objectui#6010 it fails in TWO
* ways rather than one.** The server half is unchanged and still exactly
* right: it never evaluates a FIELD-level `visibleWhen` at all —
* `rule-validator.ts`'s `ConditionalFieldDef` has no such member, and
* `hasFieldRules` gates on `requiredWhen || readonlyWhen ||
* fieldHasOptionVisibility` — the `visibleWhen` it does evaluate is the
* per-OPTION one. The RENDERER half moved. `plugin-form`'s
* `sectionFields.ts` (`fromObjectSchema`) copies an object field's ADR-0036
* `visibleWhen` / `readonlyWhen` / `requiredWhen` straight onto the runtime
* form field, and the SDUI form renderer resolves those through
* `resolveFieldRuleState` **with `predicateScope` bound**. So under a host
* that publishes a scope the predicate does not fault: it RESOLVES, the
* control is hidden in that one form, and the server still returns the value
* to every other reader — a silent enforcement gap. Only where no host
* publishes a scope (the public `/f/:slug` route, every non-form reader)
* does the old sentence still describe what happens: unbound root, fault,
* `fallback: true`, field visible to everyone.
*
* ⛔ This is not a reason to relax {@link FIELD_RULE_BOUND_ROOTS}. The
* verdict is MORE justified than when it was written, not less: trading a
* loud lint error for a gap nobody can see is the one outcome worse than
* the error. Only the causal clause was re-measured.
* - **`readonlyWhen` — the two ends fault in OPPOSITE directions, and the
* server wins.** Server: `isReadonlyWhenLocked` matches the fault with
* `unknownVariableOf` and returns `true` — "the declared lock is not
* waived because it could not be evaluated" (#4889's carve-out, whose
* trigger is precisely the unbound-ROOT case, not the undeclared-key one)
* — and `stripReadonlyWhenFields` then DELETES the field from the incoming
* payload and lets the rest of the write through. Client:
* `resolveFieldRuleState` passes `fallback: false`, so the form renders the
* field editable. The standing "server enforces, client is courtesy" rule
* — ADR-0124 D1 — resolves the disagreement: the author edits
* the field, the save reports
* success, and the value silently never lands. The old sentence told this
* author the field would be VISIBLE TO EVERYONE — the opposite failure, and
* the opposite troubleshooting direction.
* - **`requiredWhen` — the server REFUSES, and never about visibility.**
* Server: a `requiredWhen` that cannot be evaluated refuses the write,
* naming the field and the rule (ADR-0137 D2). It was fail-OPEN until then
* — the block logged `unknownVariableOf`'s name and `continue`d, and a
* record saved with the field empty. Client: `fallback: false`, so the form
* does not mark the field required, and the save it submits is refused. An
* unbound root faults on every write that reaches it. "Falls back to
* VISIBLE" was not merely imprecise here, it named the wrong property of
* the field.
*
* `conditionalRequired` reached this helper until #25767: its `FieldSchema`
* tombstone was a ≤16.x retirement kit and aged out, so the schema no longer
* declares the key and the field walk no longer passes it (the strict parse
* refuses it as an unknown key, naming the upgrade guide).
*
* ### Axis 2 — the prescription, by ROOT (#6713)
*
* The pre-#6713 prescriptions are user-oriented (move to the option level,
* declare permission-set FLS) because the pre-#6713 denylist held only user
* roots. Handed to an author who wrote `data.type == 'select'`, "move it to
* the option's `visibleWhen`" answers a question nobody asked. Three tiers:
*
* - **user roots** (`current_user` / `user` / `ctx` / `os`) keep the existing
* two user-specific prescriptions plus the `record` rewrite. What changed is
* the GROUNDING of the first one, not the prescription: it used to read
* "per-option is the one `*When` surface that binds `current_user`", and
* since objectui#6010 a form VIEW's field predicate binds those roots too —
* client-side only, because nothing on the write path evaluates a form-view
* field `visibleWhen`. So per-option is recommended for the reason that
* survives the change: the rule validator enforces it on write. The message
* now says that, and names the view surface only to refuse it as a
* destination for a server-enforced object rule (trading a loud lint error
* for a silent enforcement gap is the one outcome worse than the error);
* - **`data`** gets the metadata-form-vs-runtime-form explanation, because
* that is what the mistake IS — the same key name, the other form kind's
* root;
* - **`app` and the other NOWHERE-BOUND roots** get the bound-nowhere
* explanation and an explicit refusal of `record.<root>` (#13935, below);
* - **everything else** gets the general rewrite, phrased without claiming
* which other surface the author copied it from.
*
* ## Why the judged vocabulary is WIDER than `SCOPE_ROOTS` (#13935)
*
* `SCOPE_ROOTS` was the membership test until #13935, and it is the wrong
* question by one word: it answers "is this root declared PLATFORM-WIDE",
* while the rule needs "does this rule have a measured verdict for this root".
* The two agreed for 27 roots and then disagreed for `app`. Falling outside the
* membership test sent it to the bare-reference check, whose prescription is to
* rewrite the root as a member of the record — and following that earns
* ``unknown field `app` `` from the field-existence pass one line up. A first
* diagnostic that is false about the root, and a wasted correction cycle.
*
* ⭐ **Decision batch #67 (2026-09-07) moved the grounds and left the verdict
* standing.** #13935 justified the widening by saying `app` was bound at the
* renderer; the batch ruled the engine's declared scope to be the contract,
* ObjectUI aligned to it, and `app` is now bound at no site at all. What that
* falsifies is the WORD "ambient", not the membership: the two-step wrong
* correction above is still exactly what an author gets if this root leaves the
* judged vocabulary, so the constant keeps the root, under
* {@link FIELD_RULE_NOWHERE_BOUND_ROOTS}'s name and grounds, and the tier's
* message states what is now true instead of what was true then.
*
* `SCOPE_ROOTS`' own docblock made the original widening measurable rather than
* a matter of taste: its `current_user` entry claims to be "the last one this
* list was missing" (commit e9b526597 wrote it). `app` is that sentence's second counterexample —
* the same mechanism (#6713's point: a hand-maintained list doing a per-surface
* job drifts), a second sighting, not an analogy to the first.
*
* ⛔ The repair deliberately does NOT add `app` to `SCOPE_ROOTS`. That list is
* the published strict-lint accept baseline in `@objectstack/formula`, so
* adding a root there stops EVERY surface that judges bare identifiers from
* faulting it — a widened public accept set, to fix one surface's diagnostic.
* The judged vocabulary is assembled HERE, where the per-surface question is
* asked, and `SCOPE_ROOTS` is left as the proper subset it already is.
*/
/**
* The roots a field-level `*When` predicate binds. Everything else in
* {@link FIELD_RULE_JUDGED_ROOTS} is rejected — see the allowlist section above.
*/
export const FIELD_RULE_BOUND_ROOTS = ['record', 'previous', 'parent'] as const;
/**
* Author-reachable roots that bind at NO evaluation site and that `SCOPE_ROOTS`
* does not declare either — judged HERE so this rule owns their verdict instead
* of dropping them through to the bare-reference check.
*
* ## Why this is no longer called AMBIENT (decision batch #67, 2026-09-07)
*
* #13935 added `app` under that name on a premise the batch has since ruled
* away: that objectui's app-shell bound it at the renderer, so the honest
* verdict was "bound somewhere, just not here". Batch #67 ruled that the
* engine's `SCOPE_ROOTS` is the contract and ObjectUI aligns to it; ObjectUI
* shipped that — its scope builder no longer binds `app` — and the
* producer-side card asking for the opposite was closed as not planned under
* the same ruling. The membership survives and its GROUNDS do not: `app` binds
* nowhere at all now, which is what this name asserts and the old one did not.
*
* ⛔ The constant is NOT emptied, and that is the whole point of the repair.
* Membership is what keeps the field-rule verdict reachable; drop the root and
* it falls through to `@objectstack/formula`'s generic bare-reference check,
* whose prescription is to rewrite the root as a member of the record — the
* exact advice #13935 existed to remove, because following it earns an
* `unknown field` error on that same root one pass later. Both directions were
* measured before this was written, and the emptied form is strictly worse for
* the author.
*
* ⛔ Nor is the repair to widen `SCOPE_ROOTS`. That list is the published
* strict-lint accept baseline in `@objectstack/formula`, so a root added there
* stops EVERY surface that judges bare identifiers from faulting it — a widened
* public accept set, to fix one surface's wording. The judged vocabulary is
* assembled HERE, where the per-surface question is asked.
*
* ⚠️ Membership says only that this rule has a measured verdict for the root.
* It is emphatically NOT a claim that anything binds it — that reading is the
* one that went stale — and not a claim that the FIELD level binds it either,
* which is {@link FIELD_RULE_BOUND_ROOTS} and is unchanged.
*/
export const FIELD_RULE_NOWHERE_BOUND_ROOTS = ['app'] as const;
/**
* The vocabulary this rule judges against, of which `SCOPE_ROOTS` is a proper
* subset (#13935): the platform-wide baseline, plus the roots that bind nowhere
* at all and still earn a measured verdict here.
*
* `SCOPE_ROOTS` comes FIRST so the tie-break in {@link fieldRuleRootIssue} —
* "anything else falls back to `SCOPE_ROOTS` order" — keeps its exact
* pre-#13935 precedence: a predicate reading both a baseline root and a
* nowhere-bound one reports the baseline root, the same root it reported
* before.
*/
export const FIELD_RULE_JUDGED_ROOTS: readonly string[] = [
...SCOPE_ROOTS,
...FIELD_RULE_NOWHERE_BOUND_ROOTS,
];
/**
* ADR-0068 D1's four user spellings, in the order the message's tie-break
* prefers them (canonical first — the #6585 ordering, with `os` appended so
* the pre-existing three keep their exact precedence).
*/
const FIELD_RULE_USER_ROOTS = ['current_user', 'user', 'ctx', 'os'] as const;
/**
* [#26646] The slot name an object's `fieldGroups[].visibleWhen` is judged
* under: the field `visibleWhen` slot's root verdict, located on the group
* and worded for it ({@link FIELD_RULE_SLOT_CONSEQUENCE}, {@link fieldRuleRootIssue}).
*/
const FIELD_GROUP_VISIBLE_WHEN_SLOT = 'field group visibleWhen';
/**
* The slot-agnostic clause — provably true for any slot, specific to none.
* Used where the honest answer is "not measured for this slot", never as a
* softening of a cell that WAS measured.
*/
const FIELD_RULE_SLOT_CONSEQUENCE_GENERIC =
'the predicate faults, and a faulting rule never produces the verdict you declared — each ' +
'slot resolves the fault to its own fallback, and none of those fallbacks is yours';
/** Axis 1 — see "the consequence, by SLOT" above. Every cell is measured. */
const FIELD_RULE_SLOT_CONSEQUENCE: Record<string, string> = {
visibleWhen:
'the predicate no longer merely faults — and BOTH of its outcomes are wrong, in opposite ' +
'directions. Under a host that publishes a predicate scope the renderer RESOLVES it ' +
// `sectionFields` is spelled WITHOUT its `.ts` extension for the same
// reason the `*.form` spelling below is: this is a STRING literal, and
// #5017's receiver scan strips comments but not strings, so `sectionFields.ts`
// inside the message registers `sectionFields` as a read receiver of this
// rule. Measured — it went red on the first run, exactly as the sibling did.
'(plugin-form\'s `sectionFields` copies this object rule onto the runtime form field and ' +
'`resolveFieldRuleState` evaluates it with the host scope bound, the `current_user` binding ' +
'ADR-0089 D1 gives every runtime record surface) — so the ' +
'control is hidden in that one form while NO server-side gate evaluates a field-level ' +
'`visibleWhen` at all: the record still carries the value and every other reader still ' +
'returns it, a SILENT enforcement gap. Where no host publishes a scope (the console public ' +
'`/f/:slug` route, and every non-form reader) the root is unbound, the predicate faults and ' +
'the renderer falls back to VISIBLE (`resolveFieldRuleState` evaluates visibility with ' +
'`fallback: true`), leaving the field the test was meant to hide showing for everyone. ' +
'The gap is the WORSE of the two — a visible fail-open gets reported, and a ' +
'silent one does not',
readonlyWhen:
'the predicate faults — and the two ends fault in OPPOSITE directions. The server treats ' +
'the field as LOCKED (`isReadonlyWhenLocked` will not waive a declared lock it could not ' +
'evaluate) and drops your value from the payload, while the form still renders the ' +
'field editable (`fallback: false`). The server is the one that decides: ' +
'the field looks writable, the save reports success, and the value silently never lands',
requiredWhen:
'the predicate faults on every write that reaches that root, and the server REFUSES a write ' +
'whose requirement it cannot evaluate (ADR-0137 D2: the refusal names the field and the ' +