Repository navigation
Expand file tree
/
Copy pathreference-integrity-suite.ts
More file actions
728 lines (719 loc) · 45.9 KB
/
Copy pathreference-integrity-suite.ts
File metadata and controls
728 lines (719 loc) · 45.9 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
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* The reference-integrity suite — one entry point for the rules that answer
* "does this name resolve to anything?" (issue #3583, assessment §5 D5).
*
* ## Why this exists
*
* These rules were wired **by hand** into each CLI entry point that runs them.
* `os validate`, `os lint` and `os compile` each grew their own import list and
* their own call site, so landing a rule meant remembering three places — and
* the assessment's §2.2 already named the resulting drift as the enemy: the
* same stack, checked by a different rule subset depending on which command
* the author happened to run.
*
* The suite makes the next rule's wiring a ONE-LINE edit here, and makes the
* question "which rules run on this path?" answerable by reading one list.
*
* ## What belongs in it
*
* A rule belongs here when it resolves a NAME written in metadata against the
* things a stack actually declares — objects, actions, fields, measures,
* permissions, translation keys. That is the family the HotCRM audit found
* shipping broken: every instance parsed, validated, and failed silently at
* runtime because nothing checked that the name pointed at anything.
*
* `validateFlowTemplatePaths` is a member for exactly that reason: a
* `{record.<field>}` token is a field name written in metadata, resolved
* against the bound object's declared fields. It was wired by hand into
* `os validate` alone — the drift this suite exists to end — so `os lint` and
* `os compile` accepted a flow the runtime refuses. Its findings carry BOTH
* severities (see that module: a filter-position miss gates, every other
* position advises), which is why the suite's contract is severity-agnostic.
*
* `validateSearchableFields` is a member on the same reading, one layer in: an
* ADR-0061 `searchableFields` entry is a field name written in metadata,
* resolved against the object's own declared fields. It gates (`error`) because
* the engine's tolerance for a stale entry — silently filtering it out — either
* narrows the searched set below what the object declares or, once every entry
* is stale, falls through to the auto-default and searches a set the author
* never wrote. See that module for why the other field-existence rules stay
* advisory and this one does not.
*
* `validateSortableFields` is the same reading one axis over (#9257): a list
* view's `sort` names a field, resolved against the object's declared fields.
* It gates for a stronger reason than its search sibling — the engine has no
* tolerance to describe here. An unknown sort name is refused at the REST
* ingress (`assertSortFieldsExist`, #6994) and a `formula` one by the engine
* itself (`assertOrderByIsMaterializable`, #7095), both `400 INVALID_SORT`; and
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* `validatePageVisualizationBindings` is the charter's question with the
* resolution target one level up (#14073): an interface page's
* `appearance.allowedVisualizations` entry names a RENDERER, and
* `InterfacePageConfigSchema` carries no per-visualization binding key for the
* author to fill in — so the binding is either derived from the source
* object's declared fields (objectui's own `InterfaceListPage` derivation,
* mirrored) or supplied by the list view the page names through `sourceView`.
* An entry that resolves to neither renders NOTHING: leading the whitelist it
* becomes the forced view type and every visitor gets the renderer's
* "configuration required" refusal screen (`error`); anywhere else it is
* dropped from the switcher without a word (`warning`). Both severities, like
* the two members above, and the same three skips.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
*
* ## The runtime-publish axis (#9313)
*
* The suite is one `AUTHORING_RULES` entry, and the runtime publish gate
* dispatches that entry by the written item's type (`runtimeTypes` on the
* entry: `flow` since #4463 P1, `view` since #9313). Which MEMBERS judge a
* given per-write snapshot is the suite's own, finer axis —
* `ReferenceIntegrityRule.runtimeTypes`, default `['flow']` — because the
* snapshot deliberately carries only the measured context collections
* (objects / permissions / books / datasets), and a member resolving against
* any other collection would read every reference into it as dead. The CLI
* commands ignore the axis entirely: a whole-stack run is always the full
* suite.
*
* ## Known remaining asymmetry
*
* `os doctor` runs only `validateWidgetBindings` and is NOT converted here: it
* is an environment health check (node version, config presence, circular
* lookups), not an authoring gate, so adopting the suite there is a product
* decision about what `doctor` is for — not a wiring cleanup. It is named here
* so the gap stays visible instead of being rediscovered.
*/
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateObjectFieldRefs } from './validate-object-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validateActionDispatchContract } from './validate-action-dispatch-contract.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
import { validateDatasetReferences } from './validate-dataset-references.js';
import { validateMappingTargetFields } from './validate-mapping-target-fields.js';
import { validateNavAccess } from './validate-nav-access.js';
import { validateNavTargetRefs } from './validate-nav-target-refs.js';
import { validateNavObjectServability } from './validate-nav-object-servability.js';
import { validateTranslationReferences } from './validate-translation-references.js';
import { validateTranslatableSections } from './validate-translatable-sections.js';
import { validateFlowTemplatePaths } from './validate-flow-template-paths.js';
import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js';
import { validateAiToolReferences } from './validate-ai-tool-references.js';
import { validateAiAgentAuthoring } from './validate-ai-agent-authoring.js';
import { validateHookBodyWrites } from './validate-hook-body-writes.js';
import { validateActionBodyWrites } from './validate-action-body-writes.js';
import { validateFlowNodeWrites } from './validate-flow-node-writes.js';
import { validateReadonlyFlowWrites } from './validate-readonly-flow-writes.js';
import { validateReadonlyHookWrites } from './validate-readonly-hook-writes.js';
import { validateReadonlyActionWrites } from './validate-readonly-action-writes.js';
import { validateReactPageProps } from './validate-react-page-props.js';
export type ReferenceIntegritySeverity = 'error' | 'warning';
/**
* The shape every rule in the suite already returns. Declared here so callers
* can hold one type instead of a six-way union.
*/
export interface ReferenceIntegrityFinding {
/** `error` = the reference is dead; `warning` = it may resolve elsewhere, or the miss is inert. */
severity: ReferenceIntegritySeverity;
/** Diagnostic rule id (stable; used by allowlists and docs). */
rule: string;
/** Human-readable location. */
where: string;
/** Config path. */
path: string;
/** What is wrong. */
message: string;
/** How to fix it. */
hint: string;
}
/** One member of the suite. `name` is the exported function's name — the id a wiring test can assert on. */
export interface ReferenceIntegrityRule {
name: string;
/**
* [#9313] The runtime-publish per-write snapshot types this member judges.
*
* The suite is ONE entry in `AUTHORING_RULES`, and that entry's
* `runtimeTypes` says which WRITES dispatch the suite at the runtime publish
* gate. This field is the finer axis the entry cannot express: which MEMBERS
* are safe to judge that per-write snapshot. The two axes differ because the
* snapshot is partial by design (`RuntimeStackContext` carries objects /
* permissions / books / datasets and nothing else): a member that resolves
* against a collection the snapshot does not carry would not go quiet — it
* would report every reference into that collection as dead. Measured on the
* `view` widening: `validateActionNameRefs` resolves the action names in
* `views[].list` / `views[].listViews.*` against `stack.actions`, which no
* per-write snapshot carries, so crossing it with the suite would refuse a
* legitimate CONTAINER view write for every stack-level action it names —
* a false 422 on the only door a Studio tenant has (on a FLATTENED overlay
* it has no rung, so the same crossing would be a silent no-op instead;
* measured both ways, `runtime-gate.view-writes.test.ts`).
*
* ABSENT = `['flow']`, the surface the whole suite has run on since #4463 P1.
* The default is deliberately the frozen historical surface, never "all":
* widening a member onto another type is an explicit declaration here plus
* its own false-positive measurement (#4716's budget), exactly the
* discipline `runtimeTypes` gives registry entries. CLI commands ignore this
* field entirely — all members always run there (see
* {@link validateReferenceIntegrity}).
*/
runtimeTypes?: readonly string[];
/**
* `ctx` is the same {@link ReferenceIntegrityRunOptions} `validateReferenceIntegrity`
* received — optional because most members read only `stack`; the two hook
* write-set members (#16546) read `ctx.loweredHookRefs`.
*/
run: (stack: Record<string, unknown>, ctx?: ReferenceIntegrityRunOptions) => ReferenceIntegrityFinding[];
}
/** The runtime snapshot types a member judges when it declares none. */
const DEFAULT_MEMBER_RUNTIME_TYPES: readonly string[] = ['flow'];
/**
* Every reference-integrity rule, in the order their findings are reported.
*
* ADDING A RULE: append it here and it runs on `validate`, `lint` and
* `compile` at once. Do not re-wire the commands. It joins the runtime
* publish gate on the DEFAULT member surface (`flow` snapshots only, #9313) —
* widening it to another write type is a `runtimeTypes` declaration on the
* member plus that type's own false-positive measurement, never automatic.
*/
export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// [#19143] `dataset` joins the frozen `['flow']` default on this member, for
// ONE rung: `datasets[].object`, the dataset's base object (#14105). That
// rung lives here rather than beside the dataset field checks precisely
// because the reference is an object NAME and needs this rule's curated
// `PLATFORM_PROVIDED_OBJECT_NAMES` ladder — the platform's own
// `system.datasets.ts` declares five datasets over `sys_*` objects, three of
// which live in packages a narrower stack cannot see, and a local
// "not in this stack ⇒ error" check would report all five. The ladder is
// exactly what makes the crossing safe: it resolves against `stack.objects`,
// which every per-write snapshot carries, so there is no
// missing-collection false-positive channel, and its other rungs (views,
// pages, apps, nav) find no such collection on a dataset snapshot and
// contribute nothing — rungs that are ABSENT, not rungs that read dead.
// MEASURED before crossing, through the door's own shape (per-write snapshot,
// evaluated differentially as `runtime-gate.ts` does), over every dataset
// shipped in this monorepo: 11 datasets (platform-objects 5 over `sys_*`,
// showcase 4, crm 1, todo 1) against 52 platform objects plus each app's own
// — 0 findings, precision 1.0. The zero is a fact about the corpus AND the
// door: `rulesRun` read 2 on every one, and the same harness's synthetic
// probe is refused (`runtime-gate.dataset-writes.test.ts` makes it permanent).
{ name: 'validateObjectReferences', runtimeTypes: ['flow', 'dataset'], run: validateObjectReferences },
// [#9313] `runtimeTypes` gains `view` on this member and its sort sibling:
// both judge a LIST VIEW's field references, and a standalone list view is
// written through `PUT /api/v1/meta/view` — the only door a Studio tenant or
// an MCP/AI author has. Their walks read the flattened overlay shape that
// door carries (see each rule's `views[]` self rung), and they resolve only
// against `stack.objects`, which the per-write snapshot DOES carry — so the
// crossing has no missing-collection false-positive channel. Measured over
// the shipped view corpus before crossing (0 refusals; population in the
// #9313 PR).
// [#15495] `object` joins the two above — the SAME member, the surface it
// was already reading. Its first rung walks `objects[].searchableFields`
// (the canonical set, ADR-0061) and `objects[].listViews.*.searchableFields`
// (each narrowing of it), and both live on the OBJECT — the artifact
// Studio's app builder actually mints. #15254 crossed the suite ENTRY onto
// the object write door for its object-level sibling; this member's own
// declaration is what makes it judge the snapshot that arrives there, and
// without it the door read an object's `searchableFields` with nothing at
// all, exactly the asymmetry that card closed one key over.
//
// Safe for the #9313 reason, unchanged by the new type: this member resolves
// only against `stack.objects`, the one collection every per-write snapshot
// carries, so it has no missing-collection false-positive channel. Its
// `views[]` rungs simply find no `stack.views` on an object snapshot and
// contribute nothing — a rung that is absent, not a rung that reads dead.
//
// MEASURED before crossing, on the shipped object corpus, through the door's
// own shape (`RuntimeStackContext`, per-write, `stack.objects` only,
// evaluated differentially as `runtime-gate.ts` does): 116 objects — every
// `*.object.ts` under `packages/` and `examples/` (platform-objects 48,
// showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1,
// qa 1) — carrying 5 `searchableFields` entries across the object and
// list-view sets. 0 findings, so 0 false positives: precision 1.0. The zero
// is a fact about the corpus AND the rule: the same sweep's synthetic probe
// (one entry naming a field the object does not have) is refused with
// `searchable-field-unknown` at `error`, and the door controls in
// `runtime-gate.object-writes.test.ts` are that probe made permanent.
{ name: 'validateSearchableFields', runtimeTypes: ['flow', 'view', 'object'], run: validateSearchableFields },
// [#9257] The same reading, one axis over: a list view's `sort` is a field
// name written in metadata, resolved against the object's declared fields. It
// gates (`error`) because the runtime does not tolerate a bad one at all —
// `assertSortFieldsExist` (#6994) and `assertOrderByIsMaterializable` (#7095)
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
// [#15495] `object` joins it for the same reason and on the same evidence as
// its search sibling above: the FIRST rung this member walks is an object's
// built-in `listViews`, and an object is what the click path authors. The
// three siblings walk identical rungs by construction (the note above this
// member says so), so crossing the field axes without this one would have
// left the object door judging a list view's search set and not its columns.
//
// Same missing-collection argument, and it is this member's own reason for
// crossing onto `view` restated one surface in: it resolves only against
// `stack.objects`. MEASURED over the same 116-object corpus and the same
// per-write differential: 105 built-in list views on 40 objects, 666
// field-naming positions judged (`columns`, filter keys, `grouping`,
// `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
// `fieldOrder` and the kanban / calendar / gantt / timeline / gallery / map
// / tree bindings), 0 findings — precision 1.0 — against a synthetic probe
// column that IS refused (`list-view-field-unknown`, `error`).
//
// `validateSortableFields`, the third sibling, is deliberately NOT crossed
// here: it measured equally clean (0 findings over the same corpus) but its
// crossing is its own adjudication with its own non-vacuity control, and
// this card was scoped to the two axes its title names.
//
// [#24563] The one member that reads `packageScope`. Under a package
// closure the snapshot's `objects` are that closure (ruling #9612), so a
// binding missing from it is outside what the package declared it may
// reference, or nowhere at all — and the member's skip 1 used to read that as
// "another package's object" and judge nothing. It now refuses the binding
// instead (`list-view-object-outside-closure`, `error`: the verdict the
// dataset door gives an out-of-closure base object under the same closure;
// the flow door's trigger-object advisory is a separate question). The
// missing-collection argument above is unchanged: the binding is judged
// against `stack.objects`, the collection every per-write snapshot carries,
// and the gate states the scope only when it narrowed that collection.
//
// [#24668] And the other half of that sentence: a runtime write with NO
// closure in force is judged against the whole collection the host serves
// (`RuntimeStackContext.objects`: registry plus stored rows, unnarrowed), so
// a binding missing from it exists nowhere, and the member refuses it
// (`list-view-object-unknown`, `error`) — stated as `wholeCollection`.
// `runtimeWriteType` is the signal because the gate sets it on every gated
// write and the CLI commands never do, and `packageScope` is absent exactly
// when the gate narrowed nothing (one predicate, `packageScopeInForce`). The
// CLI keeps skip 1: its stack is one app, and an unresolved binding there
// may be a dependency package's object.
{
name: 'validateListViewFieldRefs',
runtimeTypes: ['flow', 'view', 'object'],
run: (stack, ctx) =>
validateListViewFieldRefs(
stack,
ctx?.packageScope
? { packageScope: ctx.packageScope }
: ctx?.runtimeWriteType !== undefined
? { wholeCollection: true }
: undefined,
),
},
// [#15254] The same question, one surface IN: the field-name lists the
// OBJECT itself carries (`highlightFields`, `publicSharing.redactFields`),
// which the three list-view members above do not walk because they are not
// on a list view. Placed directly after them because it completes the same
// sweep — every field name an object or its built-in views write down.
//
// `runtimeTypes` is `['flow', 'object']` — the FIRST member of this suite to
// name `object`, and `flow` for the floor the suite keeps. [#15495] It is no
// longer the only one: the two field-existence members above now name it as
// well, each on its own corpus measurement, so `object` is a surface with a
// roster rather than a single crossing (pinned by name in
// `runtime-gate.object-writes.test.ts`).
//
// It names `object` because that is the point of the member: Studio's app
// builder mints no `view` items at all, so the list-view members have
// nothing to inspect on the only artifacts the click path authors. What it
// authors is the OBJECT. The crossing carries the #9313 property that makes
// it safe — this member resolves only against `stack.objects`, the
// collection the per-write snapshot does carry, so it has no
// missing-collection false-positive channel. [#20432] Most of its names
// resolve against the object's OWN field map, but the picker keys a lookup
// field carries (`lookupColumns`, `lookupFilters[].field`, the filter half
// of `dependsOn`) address the REFERENCED object: that object is judged when
// the snapshot's `objects` carries it, and an object it does not carry is
// `unknowable` — never a miss — so the channel stays closed.
//
// It names `flow` because EVERY member of this suite does — the #4463 P1
// surface is the floor the member axis was never meant to narrow, and
// `runtime-gate.view-writes.test.ts` pins it as an invariant over the whole
// roster rather than a preference per member. Worth being plain about what
// it buys here: on a flow snapshot the objects are CONTEXT, present in the
// baseline and the candidate alike, so anything this member could raise
// there cancels in the gate's differential (#4463 D4 — a stored object
// already in violation is never charged to someone else's write). So it
// adds a pass, not a verdict. That is the right trade against being the
// first member to leave the floor.
//
// It does NOT name `view`, and that IS an argued omission: the `crossed`
// list in that same test is written out precisely so each view crossing is
// argued, and this member judges no list view. A view write cannot change
// an object's own field-name lists.
{ name: 'validateObjectFieldRefs', runtimeTypes: ['flow', 'object'], run: validateObjectFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
// [#17319] The same action name, one question on: `validateActionNameRefs`
// asks whether the name a list view's selection bar writes resolves to an
// action at all; this member asks whether the WIRING it resolves through
// matches the dispatch contract that action declares its body was written
// for. Placed directly after it so the two report together — a dead name
// first, then a live name delivered the wrong input shape.
//
// NO `runtimeTypes`, i.e. the frozen `flow` default, and for the same reason
// the member above it takes the default: it resolves against `stack.actions`,
// which no per-write snapshot carries. The failure it would take on a `view`
// crossing is the gentler one (this member returns early on an empty
// declaration map, so it would go silent rather than refuse), but a member
// that is structurally unable to judge the snapshot has no business being
// dispatched on it.
{ name: 'validateActionDispatchContract', run: validateActionDispatchContract },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
// [#14073] The same page, one question out. `validatePageFieldBindings`
// above resolves the field NAMES an interface page writes; this member
// resolves the BINDING behind each visualization the page whitelists —
// `appearance.allowedVisualizations` names a renderer, and the page surface
// has no key to bind it with, so the binding is either derived from the
// source object's fields (objectui's `InterfaceListPage` derivation,
// mirrored) or supplied by the view the page names through `sourceView`.
// Squarely the charter's question with the resolution target one level up:
// the whitelist entry is a name written in metadata whose binding must
// resolve against what the stack declares (the object's fields, that view's
// blocks) or it renders nothing.
//
// NO `runtimeTypes`, i.e. the frozen `flow` default, and deliberately: it
// resolves against `stack.pages` and `stack.views`, neither of which the
// per-write snapshot carries, so crossing it onto `view` writes would report
// every page-door binding as unresolvable. Its page-typed sibling above
// takes the default for the same reason.
{ name: 'validatePageVisualizationBindings', run: validatePageVisualizationBindings },
// [#19542] `runtimeTypes` gains `report` under the ADR-0049 ruling, and ONLY
// `report`. On a report write this member is the subject-reader: it opens
// with `recordsOf(stack.reports)` and resolves that report's own `dataset` /
// `rows` / `columns` / `values` and its chart's axes against `stack.datasets`
// — the one collection this resolution needs and the one the per-write
// snapshot already carries (#7529), so it has no missing-collection
// false-positive channel. ⛔ NOT `dashboard` or `view`: on those writes the
// report/board surfaces the rule walks are the collections the snapshot does
// NOT carry, which is the `dataset` door's own DARK pin one file over.
//
// ⚠️ A REFUSAL widening, like #15254's and #19143's: a report republished
// with a dangling dataset binding or a dimension the dataset does not
// declare is now refused (422) rather than stored silently. MEASURED over
// the shipped report corpus at the door's own snapshot shape before
// crossing: 9 reports (showcase 4, todo 5), 8 of them binding a real dataset
// with `rows`/`values` — 0 findings, with a lit synthetic probe refused.
{ name: 'validateChartBindings', runtimeTypes: ['flow', 'report'], run: validateChartBindings },
// [#14105] One level BELOW the two members above it. `validateChartBindings`
// and `validateWidgetBindings` resolve a presentation's binding against the
// dataset (#7529/#8902); this one resolves the DATASET's own references —
// `include[]`, `dimensions[].field`, `measures[].field` and filter KEYS —
// against the object graph. Placed here so the family reports top-down: a
// board proven to point at a real dataset, then that dataset proven to point
// at real columns. Its base-object sibling is `validateObjectReferences`
// (rung ①, and the reason this member skips a dataset whose object does not
// resolve rather than repeating the typo once per position).
//
// [#19143] `dataset` joins the frozen `['flow']` default, and this member is
// the reason the card exists: the rule judges positions ON the dataset
// document, its own module states the failure mode it prevents as a surface
// that «renders successfully with empty or wrong numbers», and until the
// `dataset` type was mapped at the gate a runtime dataset write dispatched
// nothing at all. It resolves against `stack.objects` and `stack.datasets` —
// both carried by a per-write snapshot — so the crossing has no
// missing-collection false-positive channel, and its three documented skips
// (an object this stack does not define, one with no readable field map, a
// registry-injected system column) are what keep a partial snapshot from
// manufacturing a refusal. Crossed TOGETHER with its base-object sibling
// `validateObjectReferences` on #7220's reading, never one without the other.
{ name: 'validateDatasetReferences', runtimeTypes: ['flow', 'dataset'], run: validateDatasetReferences },
// [#20150] An import mapping's `fieldMapping[].target`, resolved against its
// `targetObject`'s fields — the charter's question on the one field-naming
// surface a mapping carries. The verdict is the spec's
// `unknownImportMappingTargets`, the same function the import door refuses
// with, so this member and that door cannot disagree about a name. It gates:
// the door refuses such a mapping before any row, so it imports nothing.
//
// NO `runtimeTypes`, i.e. the frozen `flow` default: it reads
// `stack.mappings`, which no per-write snapshot carries, so on a flow
// snapshot it finds no mapping and contributes nothing. Judging a `mapping`
// write at the runtime publish gate would be its own crossing, with its own
// measurement; at runtime the import door already refuses the mapping.
{ name: 'validateMappingTargetFields', run: validateMappingTargetFields },
{ name: 'validateNavAccess', run: validateNavAccess },
// Nav targets that are NOT object names — page/report/dashboard. Restores the
// coverage `defineStack`'s own cross-reference block switches off whenever the
// stack declares none of that collection (`pageNames.size > 0 && …`), which is
// exactly the state a stack is in when the target was never written.
// `action` is deliberately absent (validateActionNameRefs owns it) and so is
// `component` (an unregistered ref renders a named diagnostic, not silence).
{ name: 'validateNavTargetRefs', run: validateNavTargetRefs },
// [#17063] `validateViewPageRefs` stood HERE — the `{ type: 'page', pageName }`
// reference one surface over from the nav twin, on a `type: 'page'` list view.
// It was retired with the mount it resolved (ADR-0049 enforce-or-remove,
// maintainer ruling 2026-09-09 「撤」): a list view can no longer carry
// `pageName` at all, so there is no reference left to resolve. It was the only
// member whose `runtimeTypes` reached for `stack.pages`, and the live page
// universe left `RuntimeStackContext` in the same change — the widening and
// its retirement are one edit each, in the direction the runtime-gate docblock
// describes. The nav twin (`validateNavTargetRefs`, above) is untouched.
// [#7912] The THIRD question about a nav entry, after "does the target
// resolve?" (above) and "is it granted?" (`validateNavAccess`): can the
// destination serve at all? An object's own `enable` block can make its list
// answer 404/405 for every persona, and no gate authorable on the entry
// expresses that — which is how #7544's dead row survived review for a year.
// The server now prunes such an entry from the `/meta` payload; the
// maintainer ruling of 2026-08-12 makes THIS the mandatory companion, so the
// prune is never silent to the author who wrote the row.
{ name: 'validateNavObjectServability', run: validateNavObjectServability },
{ name: 'validateTranslationReferences', run: validateTranslationReferences },
// The same family from the other end (#5417). Its sibling above asks "does
// this bundle key resolve?"; this one asks "is there a key at all?" — a form
// section authored with a `label` and no `name` renders a heading that
// `_sections` (keyed by name) can never address, so neither the orphan check
// nor the coverage walk can see it. A reference that cannot be written is
// still a reference question, and warning-only on its own reading — NOT on
// its sibling's any more: `translation-target-unknown` gates, because an
// orphan key is a confident-looking grep hit for a surface that no longer
// exists. Here the surface is real and present; only its heading stays in the
// source locale, so nothing is misdescribed and nothing breaks.
{ name: 'validateTranslatableSections', run: validateTranslatableSections },
{ name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths },
{ name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity },
// [#19542, #19527] NO `runtimeTypes`, i.e. the frozen `flow` default — and
// `skill` stays outside the runtime publish door BY DESIGN: cross-item
// reference resolution belongs to the whole-stack rule (ruling `5791822697`
// on #19527, letter B). `ai-skill-tool-unresolved` is a verdict of
// `os validate` / `os lint` / `os build` and the door NEVER emits it. The
// governing text is ADR-0109 Decision §3: `skill.tools[]` reference
// integrity is decided by THIS member over the WHOLE stack, at warning
// severity, because a runtime plugin outside the registry stays statically
// invisible and the runtime deliberately tolerates unresolved names.
//
// This member does read the written skill as its subject
// (`recordsOf(stack.skills)`, findings pathed `skills[si].tools[ti]`), so on
// paper it could cross. What rules it out is the UNIVERSE it resolves into.
// `collectToolUniverse` unions `PLATFORM_PROVIDED_TOOL_NAMES` ∪
// `stack.tools` ∪ the materialised action family from `stack.actions` and
// every object's `actions`. A per-write snapshot carries `objects` — so an
// object-level `action_<name>` resolves — but NEITHER `tools` NOR `actions`,
// and no snapshot can carry a tool a runtime plugin registers. So at this
// door the member has no truthful `unresolved` verdict at all: only its
// CLEAN answers are reliable.
//
// ⚠️ The reading, measured on the SHIPPED corpus rather than synthetically:
// `app-showcase` declares exactly one AI-exposed action,
// `showcase_portfolio_snapshot`, and it exists at stack level only (todo's
// six are mirrored under `objects[].actions`, so those resolve; crm has
// none). A skill naming `action_showcase_portfolio_snapshot` would be
// advised `ai-skill-tool-unresolved` on the door's snapshot while the same
// member over the whole showcase stack answers `[]`, and the advisory would
// reach `SaveMetaItemResponseSchema.advisories`, which Studio renders. That
// is the reason pin — the LIT case in
// `runtime-gate.inert-type-writes.test.ts` — not a wait state.
//
// ⛔ Not a hold-out awaiting a wider snapshot. The ruling rejected carrying
// `actions` / `tools` in `RuntimeStackContext` + `CONTEXT_STACK_KEYS`: that
// costs an indexed read on EVERY gated write, the hot `object` / `flow`
// doors included, and still leaves the plugin limb false — a narrower false
// advisory, not a true one. `skill` has no `TYPE_TO_STACK_KEY` row either,
// so the two absences are CONSISTENT and the gate dispatches nothing for it.
// With no rule to run, `skill` is not a runtime-gated type under the wiring
// guard's invariant (「every runtime-gated metadata type maps to a stack
// key」), so that invariant asks no row of it; a row with no declaring rule
// is the inert mapping-ahead-of-its-rules shape `runtime-gate.ts` refuses,
// and inventing a rule to give the door something to run is refused by the
// same ruling. `runtime-gate.inert-type-writes.test.ts` holds both absent.
{ name: 'validateAiToolReferences', run: validateAiToolReferences },
{ name: 'validateAiAgentAuthoring', run: validateAiAgentAuthoring },
// Field names WRITTEN by an L2 hook body (`ctx.input.x = …`,
// `ctx.api.object('y').update({ x })`), resolved against the target object's
// declared fields — the write-side counterpart of validateFlowTemplatePaths'
// read-side membership (#4271). Lazy: only a hook that actually carries a
// `language:'js'` body loads the TypeScript parser.
{ name: 'validateHookBodyWrites', run: (stack, ctx) => validateHookBodyWrites(stack, ctx) },
// The same check on the other surface that carries a `HookBodySchema` body:
// action bodies, run by the same sandbox. Only the `ctx.api` write family
// carries over — an action's `ctx.input` is its params bag, not a record
// (see that module's ledger). Lazy on the same terms.
//
// The first member here to emit more than one rule id (`validateReactPageProps`
// below is the other, and carries the most). Besides resolving `ctx.api`
// writes against declared fields (`action-body-write-unknown-field`), it
// reports a `ctx.record` write that can reach nothing
// (`action-record-write-discarded`, #4345) — not a resolution question, so
// by the charter above it does not belong in the suite. It rides along
// anyway because it falls out of the SAME parse of the SAME source: a
// separate member would parse every action body twice to say two things
// about one walk, and hand-wiring it into the CLI instead is exactly the
// drift this suite exists to end — which `validateReadonlyFlowWrites` was
// the standing proof of, until it joined the suite below.
{ name: 'validateActionBodyWrites', run: validateActionBodyWrites },
// The third surface that writes a record field set: a flow `update_record`
// node's `config.fields`. Same question as the two rules above, but the map
// is structural metadata rather than parsed JS, so a finding is a certainty
// and gates (`error`) — see that module for why, and why the docs' long-
// standing "prefer a flow node, it's checked" advice was the least true of
// the three until it landed.
{ name: 'validateFlowNodeWrites', run: validateFlowNodeWrites },
// The OTHER question about that same `config.fields` map: not "does this
// field exist?" but "is it writable?" — a `runAs:'user'` update_record
// writing a static-`readonly` field is stripped by the engine and the step
// still reports success (#2948/#3425). It walks the identical map the rule
// above walks, so the two splitting call sites was never defensible: hand-
// wired into `validate` and `compile` only, it left `os lint` PASSING a flow
// `os validate` refuses — and this one gates, so the divergence shipped a
// build the other command would have stopped. Joining the suite is the whole
// fix; the two hand-wired call sites are deleted with it (#4345 follow-up).
{ name: 'validateReadonlyFlowWrites', run: validateReadonlyFlowWrites },
// [commit 36d287803] The SAME question as the member above, on the surface that had no
// answer for it: a hook body's `ctx.api.object('x').update({ readonlyField })`.
// A hook's `ctx.api` is a ScopedContext over the TRIGGERING operation's
// context, so on a non-system trigger the engine strips the key and the call
// still returns success — the flow rule's silent no-op, reached through JS
// instead of through `config.fields`.
//
// It gates for the flow member's reason and NOT for its neighbour
// `validateHookBodyWrites`': both halves of the judgement are declared in
// THIS stack (the field's `readonly`, the body's literal `ctx.api` update),
// so the finding does not depend on a package the build cannot see. What it
// must never touch is the `ctx.input` stamp — a before-hook writing a
// `readonly` field is CORRECT and widely used, because the strip drops only
// caller-supplied values (#5591) — so the rule keys on the write CHANNEL,
// and both directions are pinned in its tests.
{ name: 'validateReadonlyHookWrites', run: (stack, ctx) => validateReadonlyHookWrites(stack, ctx) },
// [#13770] The THIRD write surface, and the one place the family's answer
// differs. An action body's `ctx.api` is `createContext({ ...ec, isSystem:
// true })` — elevated by design (#3914) — so the engine's STATIC readonly
// strip, which runs only under `!opCtx.context?.isSystem`, is skipped and a
// `readonly:true` write LANDS here. The conditional strip is not skipped
// (`isSystem` is explicitly not an exemption for it, #9107 LOCK 2), so what
// this member reports is exactly one shape: a `readonlyWhen` field written
// through a literal `ctx.api` update, which silently does not land on records
// whose predicate is TRUE. It advises rather than gates for the reason the
// flow and hook siblings advise on the same shape — the outcome depends on
// the ROW, not on anything this stack declares.
//
// `ctx.record` is excluded from its match set entirely, and that exclusion is
// the rule's load-bearing decision rather than an omission: an action's
// `ctx.record` is a dead snapshot the runtime never writes back, so no strip
// is ever consulted and a readonly verdict there would be false on every
// occurrence. `action-record-write-discarded` already owns that shape and
// states its real reason.
{ name: 'validateReadonlyActionWrites', run: validateReadonlyActionWrites },
// The `kind:'react'` page surface. Every prop a react block binds BY FIELD
// NAME is resolved against the object it names (#4340) — `<ListView columns>`,
// `<ObjectForm fields>`, `<Block type="element:…">` through the SAME
// `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface
// over, plus `<ObjectChart>`'s aggregate/axes (#3701/#3729) and
// `searchableFields` (#4329). Squarely the charter's question, on the surface
// where it had no answer at all.
//
// It also carries `react-block-needs-record-context` (#4413) — a BINDING
// question rather than a resolution one: the `record:*` family reads its
// record from a record page's context, so on THIS surface the binding does
// not exist at all and the props the contract published for it were read by
// no renderer. This rule used to resolve those props' field names against
// the object they named — lint standing guard over a binding that never ran.
// It rejects the blocks now, out of the same parse.
//
// It was hand-wired into `os validate` ALONE, so `os lint` and `os compile`
// accepted a react page whose every field binding was stale — including the
// gating ones (a missing required binding, a filter position naming no field:
// the predicate can never match and the list comes back empty). That is
// `validateReadonlyFlowWrites`' divergence again, one surface over, and it is
// the reason this entry exists rather than a fourth hand-wiring.
//
// Like `validateActionBodyWrites` above, it emits ids that are not resolution
// questions — `react-prop-missing-required` and `react-prop-typo` are shape,
// and by the charter belong outside. They ride along for the same reason: they
// fall out of the SAME TypeScript parse of the SAME page source, and splitting
// them into a second member would parse every react page twice to say two
// things about one walk. Lazy on the same terms as the hook/action body rules
// — only a page that is actually `kind:'react'` loads the compiler.
{ name: 'validateReactPageProps', run: validateReactPageProps },
];
/**
* Options for {@link validateReferenceIntegrity}.
*
* Declared as the suite's own type rather than importing
* `AuthoringRuleContext` from `authoring-rules.ts` — the suite predates the
* registry and the registry imports the suite, so the dependency must keep
* pointing that way. The registry's context is assignable to this shape by
* construction (`runtimeWriteType` spells the same key on both).
*/
export interface ReferenceIntegrityRunOptions {
/**
* [#9313] The singular metadata type of the per-write snapshot being judged,
* when the caller is the runtime publish gate. Set by `runtime-gate.ts` for
* every gated write; ABSENT on the three CLI commands and every whole-stack
* caller, which run all members unconditionally.
*/
runtimeWriteType?: string;
/**
* [#16546] `hooks[*].handler` ref strings whose metadata `body` was minted
* by `lowerCallables` from the author's inline `handler` function — spells
* the same key as `AuthoringRuleContext.loweredHookRefs` (see this
* interface's own header on why the two types stay separate). Read by
* `validateReadonlyHookWrites` / `validateHookBodyWrites` to redirect their
* `path` at the key the author actually wrote.
*/
loweredHookRefs?: ReadonlySet<string>;
/**
* [#24563] The package closure the runtime publish gate narrowed
* `stack.objects` to — spells the same key as
* `AuthoringRuleContext.packageScope`. Present only when a closure was in
* force; ABSENT on the CLI commands and on a write that names no package.
* Read by `validateListViewFieldRefs` only — which reads its absence beside
* a set `runtimeWriteType` as the whole collection (#24668).
*/
packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] };
}
/**
* Run every reference-integrity rule over a stack. Returns the concatenated
* findings (empty = clean). Pure: no I/O, safe on both the schema-parsed stack
* and the raw/normalized config the `lint` path carries.
*
* [#9313] When `options.runtimeWriteType` is set — the runtime publish gate
* judging one write's snapshot — only the members declaring that type run
* (see {@link ReferenceIntegrityRule.runtimeTypes}). Whole-stack callers pass
* no options and keep the full suite, byte-identically.
*/
export function validateReferenceIntegrity(
stack: Record<string, unknown>,
options?: ReferenceIntegrityRunOptions,
): ReferenceIntegrityFinding[] {
const findings: ReferenceIntegrityFinding[] = [];
const writeType = options?.runtimeWriteType;
for (const rule of REFERENCE_INTEGRITY_RULES) {
if (writeType !== undefined
&& !(rule.runtimeTypes ?? DEFAULT_MEMBER_RUNTIME_TYPES).includes(writeType)) continue;
findings.push(...rule.run(stack, options));
}
return findings;
}