@objectstack/lint 17.0.0-rc.2 → 17.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runtime.js CHANGED
@@ -1,19 +1,23 @@
1
1
  // src/validate-expressions.ts
2
- import { validateExpression } from "@objectstack/formula";
2
+ import { validateExpression, collectCelRootIdentifiers } from "@objectstack/formula";
3
3
  import { collectFlowGraphs, resolveFlowNodeExpressions } from "@objectstack/spec/automation";
4
4
 
5
+ // src/system-fields.ts
6
+ import { FIELD_GROUP_SYSTEM_FIELDS, resolveInjectedSystemColumns } from "@objectstack/spec/data";
7
+ import { SystemFieldName } from "@objectstack/spec/system";
8
+ var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
9
+ ...FIELD_GROUP_SYSTEM_FIELDS,
10
+ ...Object.values(SystemFieldName)
11
+ ]);
12
+ function injectedColumnsFor(objectDef) {
13
+ return resolveInjectedSystemColumns(objectDef).names;
14
+ }
15
+
5
16
  // src/validate-null-guards.ts
6
- import { Environment } from "@marcbachmann/cel-js";
17
+ import { parseCelToAst } from "@objectstack/formula";
7
18
  var NULL_GUARD_HINT = `Guard it with '!= null' \u2014 'has(x)' does NOT do that: a declared field holding null is still PRESENT, so has(x) is true.`;
8
19
  var FAULTING_BINARY_OPS = /* @__PURE__ */ new Set(["<", "<=", ">", ">=", "+", "-", "*", "/", "%"]);
9
20
  var DEFAULT_RECORD_ROOTS = ["record", "previous"];
10
- var parseEnv;
11
- function getParseEnv() {
12
- if (!parseEnv) {
13
- parseEnv = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
14
- }
15
- return parseEnv;
16
- }
17
21
  function isNode(v) {
18
22
  return !!v && typeof v === "object" && typeof v.op === "string";
19
23
  }
@@ -146,12 +150,8 @@ function findUnguardedNullableOperands(source, opts) {
146
150
  if (typeof source !== "string" || !source.trim()) return [];
147
151
  if (opts.nullableFields.size === 0) return [];
148
152
  const roots = opts.roots ?? DEFAULT_RECORD_ROOTS;
149
- let ast;
150
- try {
151
- ast = getParseEnv().parse(source).ast;
152
- } catch {
153
- return [];
154
- }
153
+ const ast = parseCelToAst(source);
154
+ if (!ast) return [];
155
155
  const hasOperands = /* @__PURE__ */ new Set();
156
156
  collectHasOperands(ast, roots, hasOperands);
157
157
  const findings = [];
@@ -214,10 +214,14 @@ function findUnguardedNullableOperands(source, opts) {
214
214
  visit(ast, /* @__PURE__ */ new Set());
215
215
  return findings;
216
216
  }
217
- function nullGuardMessage(subject, objectName, finding) {
217
+ var OUTCOME_CLAUSE = {
218
+ "fail-closed": "so the rule enforces nothing and the write is rejected fail-closed (#4649/#4763)",
219
+ "fail-open": "so the predicate is SKIPPED fail-open \u2014 the field is never actually required, the write proceeds unchecked, and the only trace is a `requiredWhen \u2026 failed to evaluate \u2014 skipped` log line (#4649/#4811)"
220
+ };
221
+ function nullGuardMessage(subject, objectName, finding, outcome = "fail-closed") {
218
222
  const owner = objectName ? `'${objectName}'` : "this object";
219
223
  const hasNote = finding.hasOnlyGuard ? ` \`has(${finding.operand})\` does not guard it.` : "";
220
- return `${subject} applies \`${finding.operator}\` to \`${finding.operand}\`, which ${owner} declares as nullable (no \`required: true\`, no \`defaultValue\`).${hasNote} At runtime the operand is null, CEL has no \`${finding.operator}\` overload for null, and the whole predicate aborts \u2014 so the rule enforces nothing and the write is rejected fail-closed (#4649/#4763). The predicate compares a value that is null. ${NULL_GUARD_HINT}`;
224
+ return `${subject} applies \`${finding.operator}\` to \`${finding.operand}\`, which ${owner} declares as nullable (no \`required: true\`, no \`defaultValue\`).${hasNote} At runtime the operand is null, CEL has no \`${finding.operator}\` overload for null, and the whole predicate aborts \u2014 ${OUTCOME_CLAUSE[outcome]}. The predicate compares a value that is null. ${NULL_GUARD_HINT}`;
221
225
  }
222
226
 
223
227
  // src/validate-expressions.ts
@@ -237,7 +241,7 @@ function buildFieldIndex(objects) {
237
241
  let names = [];
238
242
  if (Array.isArray(fields)) names = fields.map((f) => f.name).filter((n) => typeof n === "string");
239
243
  else if (fields && typeof fields === "object") names = Object.keys(fields);
240
- idx.set(name, names);
244
+ idx.set(name, [.../* @__PURE__ */ new Set([...names, ...injectedColumnsFor(obj)])]);
241
245
  }
242
246
  return idx;
243
247
  }
@@ -297,6 +301,19 @@ function buildNullableFieldIndex(objects) {
297
301
  }
298
302
  return idx;
299
303
  }
304
+ function readsParentRoot(source) {
305
+ const roots = collectCelRootIdentifiers(source);
306
+ return roots.ok && roots.roots.includes("parent");
307
+ }
308
+ function masterDetailCount(obj) {
309
+ let n = 0;
310
+ for (const [, def] of fieldEntries(obj)) {
311
+ if (def.type !== "master_detail") continue;
312
+ const ref = def.reference;
313
+ if (typeof ref === "string" && ref.trim() !== "") n += 1;
314
+ }
315
+ return n;
316
+ }
300
317
  function celSourceOf(raw) {
301
318
  if (typeof raw === "string") return raw;
302
319
  if (raw && typeof raw === "object") {
@@ -310,7 +327,7 @@ function rulePredicates(rule, path) {
310
327
  const out = [];
311
328
  const name = typeof rule.name === "string" ? rule.name : "?";
312
329
  const here = path ? `${path} \u2192 '${name}'` : `'${name}'`;
313
- const main = rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula;
330
+ const main = rule.condition;
314
331
  if (main != null) out.push({ label: `validation rule ${here}`, raw: main });
315
332
  if (rule.when != null) out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when });
316
333
  for (const branch of ["then", "otherwise"]) {
@@ -327,7 +344,7 @@ function validateStackExpressions(stack) {
327
344
  const fieldIndex = buildFieldIndex(objects);
328
345
  const fieldTypeIndex = buildFieldTypeIndex(objects);
329
346
  const nullableIndex = buildNullableFieldIndex(objects);
330
- const checkNullGuards = (where, subject, raw, objectName) => {
347
+ const checkNullGuards = (where, subject, raw, objectName, outcome = "fail-closed") => {
331
348
  if (!objectName) return;
332
349
  const nullableFields = nullableIndex.get(objectName);
333
350
  if (!nullableFields || nullableFields.size === 0) return;
@@ -336,7 +353,7 @@ function validateStackExpressions(stack) {
336
353
  for (const finding of findUnguardedNullableOperands(source, { nullableFields })) {
337
354
  issues.push({
338
355
  where,
339
- message: nullGuardMessage(subject, objectName, finding),
356
+ message: nullGuardMessage(subject, objectName, finding, outcome),
340
357
  source,
341
358
  severity: "error"
342
359
  });
@@ -405,31 +422,45 @@ function validateStackExpressions(stack) {
405
422
  }
406
423
  for (const obj of objects) {
407
424
  const objectName = typeof obj.name === "string" ? obj.name : void 0;
408
- const validations = obj.validations ?? obj.validationRules;
425
+ const validations = obj.validations;
409
426
  for (const rule of asArray(validations)) {
410
427
  const where = `object '${objectName}' \xB7 validation '${rule.name ?? "?"}'`;
411
- check(where, rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula, objectName, "record");
428
+ check(where, rule.condition, objectName, "record");
412
429
  check(`${where} when`, rule.when, objectName, "record");
413
430
  for (const p of rulePredicates(rule, "")) {
414
431
  checkNullGuards(`object '${objectName}' \xB7 ${p.label}`, p.label, p.raw, objectName);
415
432
  }
416
433
  }
417
434
  const fields = obj.fields;
418
- const fieldList = Array.isArray(fields) ? fields : fields && typeof fields === "object" ? Object.values(fields) : [];
419
- for (const f of fieldList) {
420
- if (f && typeof f === "object") {
421
- const fname = f.name ?? "?";
422
- for (const key of ["requiredWhen", "readonlyWhen", "conditionalRequired", "visibleWhen"]) {
423
- check(`object '${objectName}' \xB7 field '${fname}' ${key}`, f[key], objectName, "record");
424
- }
435
+ const fieldList = Array.isArray(fields) ? fields.filter((f) => !!f && typeof f === "object").map((f) => [typeof f.name === "string" ? f.name : "?", f]) : fields && typeof fields === "object" ? Object.entries(fields).filter(([, def]) => !!def && typeof def === "object").map(([n, def]) => [n, def]) : [];
436
+ const masters = masterDetailCount(obj);
437
+ for (const [fname, f] of fieldList) {
438
+ for (const key of ["requiredWhen", "readonlyWhen", "conditionalRequired", "visibleWhen"]) {
439
+ check(`object '${objectName}' \xB7 field '${fname}' ${key}`, f[key], objectName, "record");
440
+ }
441
+ const roWhenSource = celSourceOf(f.readonlyWhen);
442
+ if (masters !== 1 && roWhenSource && readsParentRoot(roWhenSource)) {
443
+ issues.push({
444
+ where: `object '${objectName}' \xB7 field '${fname}' readonlyWhen`,
445
+ message: `\`readonlyWhen\` reads \`parent\`, but object '${objectName}' declares ${masters === 0 ? "no" : `${masters}`} \`master_detail\` relationship${masters === 1 ? "" : "s"} \u2014 so the server has no header record to bind as \`parent\` and the field would be locked on every write. ` + (masters === 0 ? `Declare the owning relationship as \`Field.masterDetail('<master>')\`, or rewrite the predicate against \`record\`.` : `\`parent\` needs exactly one master; name the header explicitly through \`record.<fk>\` state instead, or model the extra relationship as a \`lookup\`.`),
446
+ source: roWhenSource,
447
+ severity: "error"
448
+ });
425
449
  }
426
- if (f && typeof f === "object" && f.formula) {
450
+ checkNullGuards(
451
+ `object '${objectName}' \xB7 field '${fname}' requiredWhen`,
452
+ `field '${fname}' requiredWhen`,
453
+ f.requiredWhen,
454
+ objectName,
455
+ "fail-open"
456
+ );
457
+ if (f.expression) {
427
458
  const res = validateExpression(
428
459
  "value",
429
- f.formula,
460
+ f.expression,
430
461
  objectName ? { objectName, fields: fieldIndex.get(objectName), fieldTypes: fieldTypeIndex.get(objectName), scope: "record" } : { scope: "record" }
431
462
  );
432
- const fieldWhere = `object '${objectName}' \xB7 field '${f.name ?? "?"}' formula`;
463
+ const fieldWhere = `object '${objectName}' \xB7 field '${fname}' expression`;
433
464
  for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: "error" });
434
465
  for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: "warning" });
435
466
  }
@@ -437,7 +468,7 @@ function validateStackExpressions(stack) {
437
468
  }
438
469
  const seenActions = /* @__PURE__ */ new Set();
439
470
  const checkAction = (where, action, objectName) => {
440
- const obj = objectName ?? (typeof action.objectName === "string" ? action.objectName : void 0) ?? (typeof action.object === "string" ? action.object : void 0);
471
+ const obj = objectName ?? (typeof action.objectName === "string" ? action.objectName : void 0);
441
472
  const name = typeof action.name === "string" ? action.name : "?";
442
473
  const key = `${obj ?? ""}:${name}`;
443
474
  if (seenActions.has(key)) return;
@@ -456,10 +487,10 @@ function validateStackExpressions(stack) {
456
487
  checkAction(`object '${objectName}'`, action, objectName);
457
488
  }
458
489
  }
459
- for (const rule of asArray(stack.sharingRules)) {
460
- const ruleObj = typeof rule.object === "string" ? rule.object : void 0;
461
- const where = `sharingRule '${rule.name ?? "?"}'${ruleObj ? ` (${ruleObj})` : ""} condition`;
462
- check(where, rule.condition ?? rule.criteria ?? rule.predicate, ruleObj, "record");
490
+ for (const sharingRule of asArray(stack.sharingRules)) {
491
+ const ruleObj = typeof sharingRule.object === "string" ? sharingRule.object : void 0;
492
+ const where = `sharingRule '${sharingRule.name ?? "?"}'${ruleObj ? ` (${ruleObj})` : ""} condition`;
493
+ check(where, sharingRule.condition, ruleObj, "record");
463
494
  }
464
495
  for (const hook of asArray(stack.hooks)) {
465
496
  const hookName = hook.name ?? "?";
@@ -681,16 +712,6 @@ function validateViewContainers(stack) {
681
712
  // src/validate-widget-bindings.ts
682
713
  import { isIncoherentAggregate } from "@objectstack/spec/data";
683
714
  import { ChartTypeSchema } from "@objectstack/spec/ui";
684
-
685
- // src/system-fields.ts
686
- import { FIELD_GROUP_SYSTEM_FIELDS } from "@objectstack/spec/data";
687
- import { SystemFieldName } from "@objectstack/spec/system";
688
- var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
689
- ...FIELD_GROUP_SYSTEM_FIELDS,
690
- ...Object.values(SystemFieldName)
691
- ]);
692
-
693
- // src/validate-widget-bindings.ts
694
715
  var WIDGET_DATASET_UNKNOWN = "widget-dataset-unknown";
695
716
  var WIDGET_DIMENSION_UNKNOWN = "widget-dimension-unknown";
696
717
  var WIDGET_MEASURE_UNKNOWN = "widget-measure-unknown";
@@ -1144,18 +1165,6 @@ function validateDashboardActionRefs(stack) {
1144
1165
  `${dashPath}.header.actions[${ai}].actionUrl`
1145
1166
  );
1146
1167
  }
1147
- const widgets = asArray4(dash.widgets);
1148
- for (let wi = 0; wi < widgets.length; wi++) {
1149
- const widget = widgets[wi];
1150
- if (!widget || typeof widget !== "object") continue;
1151
- if (!strName(widget.actionUrl)) continue;
1152
- const widgetId = strName(widget.id) ?? `#${wi}`;
1153
- checkOne(
1154
- { actionType: widget.actionType, actionUrl: widget.actionUrl },
1155
- `dashboard "${dashName}" \xB7 widget "${widgetId}" action`,
1156
- `${dashPath}.widgets[${wi}].actionUrl`
1157
- );
1158
- }
1159
1168
  }
1160
1169
  return findings;
1161
1170
  }
@@ -1654,13 +1663,13 @@ function validateSearchableFields(stack) {
1654
1663
  for (let vi = 0; vi < views.length; vi++) {
1655
1664
  const view = views[vi];
1656
1665
  if (!isRec2(view)) continue;
1657
- const viewLabel = strName3(view.name) ?? strName3(view.objectName) ?? `#${vi}`;
1666
+ const viewLabel2 = strName3(view.name) ?? strName3(view.objectName) ?? `#${vi}`;
1658
1667
  const viewObject = strName3(view.objectName) ?? strName3(view.object);
1659
1668
  if (isRec2(view.list)) {
1660
1669
  check(
1661
1670
  view.list.searchableFields,
1662
1671
  listViewObject(view.list) ?? viewObject,
1663
- `view "${viewLabel}" \u203A list`,
1672
+ `view "${viewLabel2}" \u203A list`,
1664
1673
  `views[${vi}].list.searchableFields`,
1665
1674
  "list-view searchableFields",
1666
1675
  "narrowing"
@@ -1672,7 +1681,7 @@ function validateSearchableFields(stack) {
1672
1681
  check(
1673
1682
  lv.searchableFields,
1674
1683
  listViewObject(lv) ?? viewObject,
1675
- `view "${viewLabel}" \u203A listViews.${key}`,
1684
+ `view "${viewLabel2}" \u203A listViews.${key}`,
1676
1685
  `views[${vi}].listViews.${key}.searchableFields`,
1677
1686
  "list-view searchableFields",
1678
1687
  "narrowing"
@@ -1980,8 +1989,11 @@ function sortFieldRefs(value, basePath) {
1980
1989
  }
1981
1990
  var COMPONENT_FIELD_SPECS = {
1982
1991
  "record:highlights": { props: ["fields"] },
1983
- // `sections`/`hideFields` are not in RecordDetailsProps, but every real page
1984
- // authors them (they survive because `properties` is unvalidated).
1992
+ // `sections` (object form) and `hideFields` are what every real page authors,
1993
+ // and since #5611 they are what `RecordDetailsProps` declares — this model and
1994
+ // the spec agree. (Before that, `sections` was declared as an ID `string[]`
1995
+ // and `hideFields` not at all; both survived only because `properties` is
1996
+ // unvalidated.)
1985
1997
  "record:details": { props: ["fields", "hideFields"], nestedSections: ["sections"] },
1986
1998
  "record:path": { props: ["statusField"] },
1987
1999
  "element:number": { props: ["field"] },
@@ -2044,7 +2056,7 @@ function indexObjectFields(stack) {
2044
2056
  }
2045
2057
  return objectFields;
2046
2058
  }
2047
- function checkFieldRefs(refs, objectName, objectFields, where, consequence = "skipped") {
2059
+ function checkFieldRefs(refs, objectName, objectFields, where, consequence2 = "skipped") {
2048
2060
  const findings = [];
2049
2061
  if (!objectName) return findings;
2050
2062
  const known = objectFields.get(objectName);
@@ -2053,11 +2065,11 @@ function checkFieldRefs(refs, objectName, objectFields, where, consequence = "sk
2053
2065
  if (ref.name.includes(".")) continue;
2054
2066
  if (known.has(ref.name) || SYSTEM_FIELDS.has(ref.name)) continue;
2055
2067
  findings.push({
2056
- severity: consequence === "queried" ? "error" : "warning",
2068
+ severity: consequence2 === "queried" ? "error" : "warning",
2057
2069
  rule: PAGE_FIELD_UNKNOWN,
2058
2070
  where,
2059
2071
  path: ref.path,
2060
- message: `field "${ref.name}" is not a field on object "${objectName}" \u2014 ` + (consequence === "queried" ? 'it is used in a QUERY, so the predicate can never match: the surface renders an empty result that looks exactly like "there is no data".' : "the component silently skips it, so it never renders."),
2072
+ message: `field "${ref.name}" is not a field on object "${objectName}" \u2014 ` + (consequence2 === "queried" ? 'it is used in a QUERY, so the predicate can never match: the surface renders an empty result that looks exactly like "there is no data".' : "the component silently skips it, so it never renders."),
2061
2073
  hint: `Fix the field name, or add "${ref.name}" to ${objectName}. References must match the object's field names exactly.` + (known.size > 0 ? ` Object fields: ${[...known].sort().join(", ")}.` : "")
2062
2074
  });
2063
2075
  }
@@ -2626,6 +2638,13 @@ function collectViewRecord(view, factsFor) {
2626
2638
  const addView = (objectName, name) => {
2627
2639
  if (objectName && name) factsFor(objectName).views.add(name);
2628
2640
  };
2641
+ const addSections = (container, binding) => {
2642
+ if (!binding) return;
2643
+ for (const section of asArray14(container.sections)) {
2644
+ const sectionName = strName10(section.name);
2645
+ if (sectionName) factsFor(binding).sections.add(sectionName);
2646
+ }
2647
+ };
2629
2648
  const listBinding = isRec7(view.list) ? bindingOf(view.list) : void 0;
2630
2649
  if (isRec7(view.list)) addView(listBinding, strName10(view.list.name));
2631
2650
  addView(recordObject ?? listBinding, strName10(view.name));
@@ -2637,21 +2656,11 @@ function collectViewRecord(view, factsFor) {
2637
2656
  const binding = bindingOf(sub) ?? listBinding;
2638
2657
  addView(binding, subKey);
2639
2658
  addView(binding, strName10(sub.name));
2640
- if (binding) {
2641
- for (const section of asArray14(sub.sections)) {
2642
- const sectionName = strName10(section.name);
2643
- if (sectionName) factsFor(binding).sections.add(sectionName);
2644
- }
2645
- }
2646
- }
2647
- }
2648
- const sectionBinding = recordObject ?? listBinding;
2649
- if (sectionBinding) {
2650
- for (const section of asArray14(view.sections)) {
2651
- const sectionName = strName10(section.name);
2652
- if (sectionName) factsFor(sectionBinding).sections.add(sectionName);
2659
+ addSections(sub, binding);
2653
2660
  }
2654
2661
  }
2662
+ if (isRec7(view.form)) addSections(view.form, bindingOf(view.form) ?? listBinding);
2663
+ addSections(view, recordObject ?? listBinding);
2655
2664
  }
2656
2665
  function viewObjectName(view) {
2657
2666
  return strName10(view.objectName) ?? strName10(view.object) ?? (isRec7(view.data) ? strName10(view.data.object) : void 0);
@@ -3016,24 +3025,169 @@ function checkActionParams(findings, ctx) {
3016
3025
  }
3017
3026
  }
3018
3027
 
3019
- // src/flow-walk.ts
3020
- import { FLOW_REGION_SLOTS_BY_TYPE, FLOW_REGION_CONFIG_KEYS } from "@objectstack/spec/automation";
3028
+ // src/validate-translatable-sections.ts
3029
+ var TRANSLATION_SECTION_NAME_MISSING = "translation-section-name-missing";
3021
3030
  function isRec8(v) {
3022
3031
  return !!v && typeof v === "object" && !Array.isArray(v);
3023
3032
  }
3024
3033
  function strName11(v) {
3025
3034
  return typeof v === "string" && v.length > 0 ? v : void 0;
3026
3035
  }
3036
+ function viewObjectName2(view) {
3037
+ return strName11(view.objectName) ?? strName11(view.object) ?? (isRec8(view.data) ? strName11(view.data.object) : void 0);
3038
+ }
3039
+ function collectionEntries(v, base) {
3040
+ if (Array.isArray(v)) {
3041
+ const out = [];
3042
+ for (let i = 0; i < v.length; i++) {
3043
+ if (isRec8(v[i])) out.push({ rec: v[i], path: `${base}[${i}]` });
3044
+ }
3045
+ return out;
3046
+ }
3047
+ if (isRec8(v)) {
3048
+ return Object.entries(v).filter(([, def]) => isRec8(def)).map(([name, def]) => ({ rec: { name, ...def }, path: `${base}.${name}` }));
3049
+ }
3050
+ return [];
3051
+ }
3052
+ function viewLabel(view) {
3053
+ const name = strName11(view.name);
3054
+ return name ? `view "${name}"` : "";
3055
+ }
3056
+ function joinWhere(...parts) {
3057
+ return parts.filter((p) => p.length > 0).join(" \xB7 ");
3058
+ }
3059
+ function collectViewSites(view, basePath, label2, sites) {
3060
+ const recordObject = viewObjectName2(view);
3061
+ const listBinding = isRec8(view.list) ? viewObjectName2(view.list) ?? recordObject : void 0;
3062
+ const bindingOf = (container) => viewObjectName2(container) ?? recordObject;
3063
+ sites.push({
3064
+ path: `${basePath}.sections`,
3065
+ surface: label2,
3066
+ objectName: recordObject ?? listBinding,
3067
+ sections: view.sections
3068
+ });
3069
+ if (isRec8(view.form)) {
3070
+ sites.push({
3071
+ path: `${basePath}.form.sections`,
3072
+ surface: joinWhere(label2, "form"),
3073
+ objectName: bindingOf(view.form) ?? listBinding,
3074
+ sections: view.form.sections
3075
+ });
3076
+ }
3077
+ for (const key of ["listViews", "formViews"]) {
3078
+ const container = view[key];
3079
+ if (!isRec8(container)) continue;
3080
+ for (const [subKey, sub] of Object.entries(container)) {
3081
+ if (!isRec8(sub)) continue;
3082
+ sites.push({
3083
+ path: `${basePath}.${key}.${subKey}.sections`,
3084
+ surface: joinWhere(label2, `${key}.${subKey}`),
3085
+ objectName: bindingOf(sub) ?? listBinding,
3086
+ sections: sub.sections
3087
+ });
3088
+ }
3089
+ }
3090
+ }
3091
+ function translatedObjectNames(stack) {
3092
+ const out = /* @__PURE__ */ new Set();
3093
+ const bundles = Array.isArray(stack.translations) ? stack.translations : [];
3094
+ for (const bundle of bundles) {
3095
+ if (!isRec8(bundle)) continue;
3096
+ for (const data of Object.values(bundle)) {
3097
+ if (!isRec8(data) || !isRec8(data.objects)) continue;
3098
+ for (const [objectName, node] of Object.entries(data.objects)) {
3099
+ if (isRec8(node)) out.add(objectName);
3100
+ }
3101
+ }
3102
+ }
3103
+ return out;
3104
+ }
3105
+ function suggestedName(label2) {
3106
+ const slug = label2.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
3107
+ return slug.length > 0 ? slug : void 0;
3108
+ }
3109
+ function validateTranslatableSections(stack) {
3110
+ const findings = [];
3111
+ if (!isRec8(stack)) return findings;
3112
+ const translated = translatedObjectNames(stack);
3113
+ if (translated.size === 0) return findings;
3114
+ const sites = [];
3115
+ for (const { rec: obj, path: objPath } of collectionEntries(stack.objects, "objects")) {
3116
+ const objectName = strName11(obj.name);
3117
+ if (!objectName) continue;
3118
+ for (const { rec: view, path } of collectionEntries(obj.views, `${objPath}.views`)) {
3119
+ collectViewSites(
3120
+ { ...view, object: strName11(view.object) ?? objectName },
3121
+ path,
3122
+ viewLabel(view),
3123
+ sites
3124
+ );
3125
+ }
3126
+ if (isRec8(obj.listViews)) {
3127
+ collectViewSites({ object: objectName, listViews: obj.listViews }, objPath, "", sites);
3128
+ }
3129
+ }
3130
+ for (const { rec: view, path } of collectionEntries(stack.views, "views")) {
3131
+ collectViewSites(view, path, viewLabel(view), sites);
3132
+ }
3133
+ for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, "pages")) {
3134
+ const pageName = strName11(page.name);
3135
+ const pageLabel = pageName ? `page "${pageName}"` : "";
3136
+ for (const walked of walkPageComponents(page, pagePath)) {
3137
+ if (!walked.objectName) continue;
3138
+ const props = isRec8(walked.component.properties) ? walked.component.properties : void 0;
3139
+ if (!props) continue;
3140
+ const type = strName11(walked.component.type) ?? "component";
3141
+ sites.push({
3142
+ path: `${walked.path}.properties.sections`,
3143
+ surface: joinWhere(pageLabel, type),
3144
+ objectName: walked.objectName,
3145
+ sections: props.sections
3146
+ });
3147
+ }
3148
+ }
3149
+ for (const site of sites) {
3150
+ const objectName = site.objectName;
3151
+ if (!objectName || !translated.has(objectName)) continue;
3152
+ if (!Array.isArray(site.sections)) continue;
3153
+ for (let i = 0; i < site.sections.length; i++) {
3154
+ const section = site.sections[i];
3155
+ if (!isRec8(section)) continue;
3156
+ if (strName11(section.name)) continue;
3157
+ const heading = strName11(section.label) ?? strName11(section.title);
3158
+ if (!heading) continue;
3159
+ const slug = suggestedName(heading);
3160
+ findings.push({
3161
+ severity: "warning",
3162
+ rule: TRANSLATION_SECTION_NAME_MISSING,
3163
+ where: joinWhere(`object "${objectName}"`, site.surface, `section "${heading}"`),
3164
+ path: `${site.path}[${i}]`,
3165
+ message: `Section "${heading}" declares a label but no \`name\`. Headings resolve through \`objects.${objectName}._sections.<name>.label\`, so a section with no name has no key a bundle can carry \u2014 this heading can never be translated and renders in the source locale in EVERY locale. Object "${objectName}" IS translated, which is what makes the hole invisible: every neighbouring label resolves and only the heading stays behind. The i18n coverage report cannot see it either \u2014 it walks \`sections[].name\`, and a nameless section contributes nothing to walk.`,
3166
+ hint: `Give the section a stable \`name\` (snake_case)` + (slug ? `, e.g. \`name: '${slug}'\`` : "") + `, then translate it as \`objects.${objectName}._sections.${slug ?? "<name>"}.label\` in each locale bundle. The renderers look the heading up by name only \u2014 the name above is a suggestion to write down, never a key derived from the label, so renaming the heading later cannot break the lookup.`
3167
+ });
3168
+ }
3169
+ }
3170
+ return findings;
3171
+ }
3172
+
3173
+ // src/flow-walk.ts
3174
+ import { FLOW_REGION_SLOTS_BY_TYPE, FLOW_REGION_CONFIG_KEYS } from "@objectstack/spec/automation";
3175
+ function isRec9(v) {
3176
+ return !!v && typeof v === "object" && !Array.isArray(v);
3177
+ }
3178
+ function strName12(v) {
3179
+ return typeof v === "string" && v.length > 0 ? v : void 0;
3180
+ }
3027
3181
  var REGION_SLOTS = new Map(
3028
3182
  [...FLOW_REGION_SLOTS_BY_TYPE].map(([type, slots]) => [type, slots.map((s) => s.key)])
3029
3183
  );
3030
3184
  var REGION_CONFIG_KEYS = FLOW_REGION_CONFIG_KEYS;
3031
3185
  var MAX_REGION_DEPTH = 16;
3032
3186
  function flowNodeLabel(node, index) {
3033
- return strName11(node.label) ?? strName11(node.id) ?? `#${index}`;
3187
+ return strName12(node.label) ?? strName12(node.id) ?? `#${index}`;
3034
3188
  }
3035
3189
  function stripRegions(config) {
3036
- if (!isRec8(config)) return void 0;
3190
+ if (!isRec9(config)) return void 0;
3037
3191
  let out;
3038
3192
  for (const key of Object.keys(config)) {
3039
3193
  if (!REGION_CONFIG_KEYS.has(key)) continue;
@@ -3044,11 +3198,11 @@ function stripRegions(config) {
3044
3198
  }
3045
3199
  function walkFlowNodes(flow, flowPath) {
3046
3200
  const out = [];
3047
- if (!isRec8(flow)) return out;
3201
+ if (!isRec9(flow)) return out;
3048
3202
  const visitList = (nodes, basePath, trail, depth) => {
3049
3203
  if (!Array.isArray(nodes) || depth > MAX_REGION_DEPTH) return;
3050
3204
  nodes.forEach((raw, index) => {
3051
- if (!isRec8(raw)) return;
3205
+ if (!isRec9(raw)) return;
3052
3206
  const path = `${basePath}[${index}]`;
3053
3207
  out.push({
3054
3208
  node: raw,
@@ -3057,9 +3211,9 @@ function walkFlowNodes(flow, flowPath) {
3057
3211
  regionTrail: trail,
3058
3212
  depth
3059
3213
  });
3060
- const type = strName11(raw.type);
3214
+ const type = strName12(raw.type);
3061
3215
  const slots = type ? REGION_SLOTS.get(type) : void 0;
3062
- if (!slots || !isRec8(raw.config)) return;
3216
+ if (!slots || !isRec9(raw.config)) return;
3063
3217
  const config = raw.config;
3064
3218
  const here = `${type} "${flowNodeLabel(raw, index)}"`;
3065
3219
  for (const slot of slots) {
@@ -3067,8 +3221,8 @@ function walkFlowNodes(flow, flowPath) {
3067
3221
  if (slot === "branches") {
3068
3222
  if (!Array.isArray(value)) continue;
3069
3223
  value.forEach((branch, b) => {
3070
- if (!isRec8(branch)) return;
3071
- const branchName = strName11(branch.name) ?? `#${b}`;
3224
+ if (!isRec9(branch)) return;
3225
+ const branchName = strName12(branch.name) ?? `#${b}`;
3072
3226
  visitList(
3073
3227
  branch.nodes,
3074
3228
  `${path}.config.branches[${b}].nodes`,
@@ -3078,7 +3232,7 @@ function walkFlowNodes(flow, flowPath) {
3078
3232
  });
3079
3233
  continue;
3080
3234
  }
3081
- if (!isRec8(value)) continue;
3235
+ if (!isRec9(value)) continue;
3082
3236
  visitList(
3083
3237
  value.nodes,
3084
3238
  `${path}.config.${slot}.nodes`,
@@ -3303,7 +3457,7 @@ function asArray16(v) {
3303
3457
  }
3304
3458
  return [];
3305
3459
  }
3306
- function strName12(v) {
3460
+ function strName13(v) {
3307
3461
  return typeof v === "string" && v.length > 0 ? v : void 0;
3308
3462
  }
3309
3463
  function surfaceOf(v) {
@@ -3314,17 +3468,17 @@ function validateAiSurfaceAffinity(stack) {
3314
3468
  if (!stack || typeof stack !== "object") return findings;
3315
3469
  const skillsByName = /* @__PURE__ */ new Map();
3316
3470
  for (const skill of asArray16(stack.skills)) {
3317
- const n = strName12(skill.name);
3471
+ const n = strName13(skill.name);
3318
3472
  if (n) skillsByName.set(n, skill);
3319
3473
  }
3320
3474
  const agents = asArray16(stack.agents);
3321
3475
  for (let ai = 0; ai < agents.length; ai++) {
3322
3476
  const agent = agents[ai];
3323
- const agentName = strName12(agent.name) ?? `#${ai}`;
3477
+ const agentName = strName13(agent.name) ?? `#${ai}`;
3324
3478
  const agentSurface = surfaceOf(agent.surface);
3325
3479
  const skillRefs = Array.isArray(agent.skills) ? agent.skills : [];
3326
3480
  for (let si = 0; si < skillRefs.length; si++) {
3327
- const ref = strName12(skillRefs[si]);
3481
+ const ref = strName13(skillRefs[si]);
3328
3482
  if (!ref) continue;
3329
3483
  const skill = skillsByName.get(ref);
3330
3484
  if (!skill) continue;
@@ -3353,7 +3507,7 @@ function asArray17(v) {
3353
3507
  }
3354
3508
  return [];
3355
3509
  }
3356
- function strName13(v) {
3510
+ function strName14(v) {
3357
3511
  return typeof v === "string" && v.length > 0 ? v : void 0;
3358
3512
  }
3359
3513
  function distance6(a, b) {
@@ -3394,8 +3548,8 @@ function materialisesAsTool(action) {
3394
3548
  if (!ai || typeof ai !== "object") return false;
3395
3549
  const aiRec = ai;
3396
3550
  if (aiRec.exposed !== true) return false;
3397
- if (!strName13(aiRec.description)) return false;
3398
- const type = strName13(action.type);
3551
+ if (!strName14(aiRec.description)) return false;
3552
+ const type = strName14(action.type);
3399
3553
  if (!type || !HEADLESS_ACTION_TYPES.has(type)) return false;
3400
3554
  if (type === "script") return Boolean(action.target || action.body);
3401
3555
  return Boolean(action.target);
@@ -3403,12 +3557,12 @@ function materialisesAsTool(action) {
3403
3557
  function collectToolUniverse(stack) {
3404
3558
  const universe = new Set(PLATFORM_PROVIDED_TOOL_NAMES);
3405
3559
  for (const tool of asArray17(stack.tools)) {
3406
- const n = strName13(tool.name);
3560
+ const n = strName14(tool.name);
3407
3561
  if (n) universe.add(n);
3408
3562
  }
3409
3563
  const addActionFamily = (actions) => {
3410
3564
  for (const action of asArray17(actions)) {
3411
- const n = strName13(action.name);
3565
+ const n = strName14(action.name);
3412
3566
  if (n && materialisesAsTool(action)) universe.add(`action_${n}`);
3413
3567
  }
3414
3568
  };
@@ -3422,7 +3576,7 @@ function collectUnexposedActionNames(stack) {
3422
3576
  const names = /* @__PURE__ */ new Set();
3423
3577
  const scan = (actions) => {
3424
3578
  for (const action of asArray17(actions)) {
3425
- const n = strName13(action.name);
3579
+ const n = strName14(action.name);
3426
3580
  if (n && !materialisesAsTool(action)) names.add(n);
3427
3581
  }
3428
3582
  };
@@ -3448,10 +3602,10 @@ function validateAiToolReferences(stack) {
3448
3602
  const skills = asArray17(stack.skills);
3449
3603
  for (let si = 0; si < skills.length; si++) {
3450
3604
  const skill = skills[si];
3451
- const skillName = strName13(skill.name) ?? `#${si}`;
3605
+ const skillName = strName14(skill.name) ?? `#${si}`;
3452
3606
  const refs = Array.isArray(skill.tools) ? skill.tools : [];
3453
3607
  for (let ti = 0; ti < refs.length; ti++) {
3454
- const ref = strName13(refs[ti]);
3608
+ const ref = strName14(refs[ti]);
3455
3609
  if (!ref || resolves(ref)) continue;
3456
3610
  const isPattern = ref.endsWith("*");
3457
3611
  const unexposed = !isPattern && ref.startsWith("action_") && unexposedActions.has(ref.slice("action_".length)) ? ref.slice("action_".length) : void 0;
@@ -3477,7 +3631,7 @@ function asArray18(v) {
3477
3631
  }
3478
3632
  return [];
3479
3633
  }
3480
- function strName14(v) {
3634
+ function strName15(v) {
3481
3635
  return typeof v === "string" && v.length > 0 ? v : void 0;
3482
3636
  }
3483
3637
  var PLATFORM_AGENT_NAMES = /* @__PURE__ */ new Set(["ask", "build", "data_chat", "metadata_assistant"]);
@@ -3487,7 +3641,7 @@ function validateAiAgentAuthoring(stack) {
3487
3641
  const agents = asArray18(stack.agents);
3488
3642
  for (let ai = 0; ai < agents.length; ai++) {
3489
3643
  const agent = agents[ai];
3490
- const name = strName14(agent.name) ?? `#${ai}`;
3644
+ const name = strName15(agent.name) ?? `#${ai}`;
3491
3645
  const isPlatformName = PLATFORM_AGENT_NAMES.has(name);
3492
3646
  const skillCount = Array.isArray(agent.skills) ? agent.skills.length : 0;
3493
3647
  findings.push({
@@ -3585,13 +3739,13 @@ var IMPLICIT_FIELDS2 = /* @__PURE__ */ new Set([
3585
3739
  "owner",
3586
3740
  "record_type"
3587
3741
  ]);
3588
- var isRec9 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3742
+ var isRec10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3589
3743
  function asArray19(v) {
3590
- if (Array.isArray(v)) return v.filter((x) => isRec9(x));
3591
- if (isRec9(v)) {
3744
+ if (Array.isArray(v)) return v.filter((x) => isRec10(x));
3745
+ if (isRec10(v)) {
3592
3746
  return Object.entries(v).map(([name, def]) => ({
3593
3747
  name,
3594
- ...isRec9(def) ? def : {}
3748
+ ...isRec10(def) ? def : {}
3595
3749
  }));
3596
3750
  }
3597
3751
  return [];
@@ -3737,7 +3891,7 @@ function validateHookBodyWrites(stack) {
3737
3891
  let objectFields = null;
3738
3892
  hooks.forEach((hook, hookIndex) => {
3739
3893
  const body = hook.body;
3740
- if (!isRec9(body) || body.language !== "js") return;
3894
+ if (!isRec10(body) || body.language !== "js") return;
3741
3895
  const source = body.source;
3742
3896
  if (typeof source !== "string" || source.trim() === "") return;
3743
3897
  const writes = extractHookBodyWrites(source).filter((w) => HOOK_APPLICABLE_IDS.has(w.patternId));
@@ -3808,13 +3962,13 @@ var ACTION_BODY_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_B
3808
3962
  var ACTION_RECORD_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_RECORD_WRITE_PATTERN_IDS.includes(p.id));
3809
3963
  var APPLICABLE_IDS = new Set(ACTION_BODY_WRITE_PATTERN_IDS);
3810
3964
  var RECORD_WRITE_IDS = new Set(ACTION_RECORD_WRITE_PATTERN_IDS);
3811
- var isRec10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3965
+ var isRec11 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3812
3966
  function asArray20(v) {
3813
- if (Array.isArray(v)) return v.filter((x) => isRec10(x));
3814
- if (isRec10(v)) {
3967
+ if (Array.isArray(v)) return v.filter((x) => isRec11(x));
3968
+ if (isRec11(v)) {
3815
3969
  return Object.entries(v).map(([name, def]) => ({
3816
3970
  name,
3817
- ...isRec10(def) ? def : {}
3971
+ ...isRec11(def) ? def : {}
3818
3972
  }));
3819
3973
  }
3820
3974
  return [];
@@ -3832,7 +3986,7 @@ function collectActionBodies(stack) {
3832
3986
  const type = typeof action.type === "string" ? action.type : "script";
3833
3987
  if (type !== "script") return;
3834
3988
  const body = action.body;
3835
- if (!isRec10(body) || body.language !== "js") return;
3989
+ if (!isRec11(body) || body.language !== "js") return;
3836
3990
  const source = body.source;
3837
3991
  if (typeof source !== "string" || source.trim() === "") return;
3838
3992
  const name = typeof action.name === "string" && action.name ? action.name : `#${index}`;
@@ -3851,7 +4005,7 @@ function collectActionBodies(stack) {
3851
4005
  }
3852
4006
  function validateActionBodyWrites(stack) {
3853
4007
  const findings = [];
3854
- if (!isRec10(stack)) return findings;
4008
+ if (!isRec11(stack)) return findings;
3855
4009
  const sites = collectActionBodies(stack);
3856
4010
  if (sites.length === 0) return findings;
3857
4011
  let objectFields = null;
@@ -3909,13 +4063,13 @@ function fixHint2(field, declared) {
3909
4063
  import { findClosestMatches as findClosestMatches3, formatSuggestion as formatSuggestion3 } from "@objectstack/spec/shared";
3910
4064
  var FLOW_NODE_WRITE_UNKNOWN_FIELD = "flow-node-write-unknown-field";
3911
4065
  var FLOW_WRITE_NODE_TYPES = ["update_record", "create_record"];
3912
- var isRec11 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4066
+ var isRec12 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3913
4067
  function asArray21(v) {
3914
- if (Array.isArray(v)) return v.filter((x) => isRec11(x));
3915
- if (isRec11(v)) {
4068
+ if (Array.isArray(v)) return v.filter((x) => isRec12(x));
4069
+ if (isRec12(v)) {
3916
4070
  return Object.entries(v).map(([name, def]) => ({
3917
4071
  name,
3918
- ...isRec11(def) ? def : {}
4072
+ ...isRec12(def) ? def : {}
3919
4073
  }));
3920
4074
  }
3921
4075
  return [];
@@ -3928,7 +4082,7 @@ function readLiteralObjectName(config) {
3928
4082
  var COVERED_TYPES = new Set(FLOW_WRITE_NODE_TYPES);
3929
4083
  function validateFlowNodeWrites(stack) {
3930
4084
  const findings = [];
3931
- if (!isRec11(stack)) return findings;
4085
+ if (!isRec12(stack)) return findings;
3932
4086
  const flows = asArray21(stack.flows);
3933
4087
  if (flows.length === 0) return findings;
3934
4088
  let objectFields = null;
@@ -3937,10 +4091,10 @@ function validateFlowNodeWrites(stack) {
3937
4091
  const walked = walkFlowNodes(flow, `flows[${flowIndex}]`);
3938
4092
  walked.forEach(({ node, path: nodePath, regionTrail }, walkIndex) => {
3939
4093
  if (typeof node.type !== "string" || !COVERED_TYPES.has(node.type)) return;
3940
- const config = isRec11(node.config) ? node.config : void 0;
4094
+ const config = isRec12(node.config) ? node.config : void 0;
3941
4095
  if (!config) return;
3942
4096
  const fields = config.fields;
3943
- if (!isRec11(fields)) return;
4097
+ if (!isRec12(fields)) return;
3944
4098
  const written = Object.keys(fields);
3945
4099
  if (written.length === 0) return;
3946
4100
  const objectName = readLiteralObjectName(config);
@@ -4068,10 +4222,50 @@ import {
4068
4222
  REACT_BLOCKS,
4069
4223
  RECORD_CONTEXT_BLOCK_TAGS,
4070
4224
  REACT_RECORD_BLOCK_ALTERNATIVES,
4225
+ ChartAggregateSchema,
4226
+ ChartDrillDownSchema,
4071
4227
  chartAggregateResultKeys,
4072
4228
  isRecordContextBlockType
4073
4229
  } from "@objectstack/spec/ui";
4074
4230
  import { VALID_AST_OPERATORS } from "@objectstack/spec/data";
4231
+
4232
+ // src/zod-issue-format.ts
4233
+ var isRec13 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4234
+ var valueAtPath = (root, path) => {
4235
+ let cur = root;
4236
+ for (const key of path) {
4237
+ if (!isRec13(cur) && !Array.isArray(cur)) return void 0;
4238
+ cur = cur[key];
4239
+ }
4240
+ return cur;
4241
+ };
4242
+ var preview = (value) => {
4243
+ let text;
4244
+ try {
4245
+ text = JSON.stringify(value) ?? String(value);
4246
+ } catch {
4247
+ text = String(value);
4248
+ }
4249
+ return text.length > 80 ? `${text.slice(0, 77)}\u2026` : text;
4250
+ };
4251
+ function describeIssue(issue, root, depth = 0) {
4252
+ const value = depth === 0 ? valueAtPath(root, issue.path) : void 0;
4253
+ const seen = depth > 0 || issue.code === "custom" || issue.message.includes("received ") ? "" : value === void 0 ? " (nothing is set there)" : ` (received ${preview(value)})`;
4254
+ const armIssues = issue.code === "invalid_union" ? issue.errors : void 0;
4255
+ if (!armIssues || armIssues.length === 0) {
4256
+ return `${issue.message}${seen}`;
4257
+ }
4258
+ const arms = armIssues.map(
4259
+ (arm) => arm.map((inner) => {
4260
+ const where = inner.path.length ? `${inner.path.join(".")} \u2014 ` : "";
4261
+ return `${where}${describeIssue(inner, root, depth + 1)}`;
4262
+ }).join("; ")
4263
+ ).filter((text) => text.length > 0);
4264
+ if (arms.length === 0) return `${issue.message}${seen}`;
4265
+ return `${issue.message}${seen} \u2014 no accepted form matched: ` + arms.map((text, i) => `(${i + 1}) ${text}`).join(" ");
4266
+ }
4267
+
4268
+ // src/validate-react-page-props.ts
4075
4269
  var cachedTs2 = null;
4076
4270
  function loadTypeScript2() {
4077
4271
  if (cachedTs2) return cachedTs2;
@@ -4175,35 +4369,78 @@ function filterAttrValue(tsc, sf, attr) {
4175
4369
  var REACT_CHART_FIELD_UNKNOWN = "react-chart-field-unknown";
4176
4370
  var REACT_CHART_AGGREGATE_INVALID = "react-chart-aggregate-invalid";
4177
4371
  var REACT_CHART_AXIS_UNKNOWN = "react-chart-axis-unknown";
4178
- var CHART_FUNCTIONS = ["count", "sum", "avg", "min", "max"];
4179
- var isRec12 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4372
+ var REACT_CHART_DRILLDOWN_INVALID = "react-chart-drilldown-invalid";
4373
+ function checkChartDrillDown(raw, push2) {
4374
+ if (raw === void 0 || raw === NOT_STATIC) return;
4375
+ if (!isRec14(raw)) {
4376
+ push2(
4377
+ "error",
4378
+ REACT_CHART_DRILLDOWN_INVALID,
4379
+ `drillDown must be a configuration object, not ${Array.isArray(raw) ? "an array" : typeof raw}.`,
4380
+ "Write drillDown={{ \u2026 }} \u2014 or, to turn the drill on with all defaults, drillDown={{}}. Omit the prop entirely to leave drill off."
4381
+ );
4382
+ return;
4383
+ }
4384
+ const parsed = ChartDrillDownSchema.safeParse(raw);
4385
+ if (parsed.success) return;
4386
+ for (const issue of parsed.error.issues) {
4387
+ const at = issue.path.length ? `drillDown.${issue.path.join(".")}` : "drillDown";
4388
+ push2(
4389
+ "error",
4390
+ REACT_CHART_DRILLDOWN_INVALID,
4391
+ `${at}: ${issue.message}`,
4392
+ "The drill config is declared by ChartDrillDownSchema (@objectstack/spec/ui) \u2014 the rejection above carries the fix."
4393
+ );
4394
+ }
4395
+ }
4396
+ function checkChartAggregate(raw, push2) {
4397
+ if (raw === void 0 || raw === NOT_STATIC) return;
4398
+ if (!isRec14(raw)) {
4399
+ push2(
4400
+ "error",
4401
+ REACT_CHART_AGGREGATE_INVALID,
4402
+ `aggregate must be a configuration object, not ${Array.isArray(raw) ? "an array" : typeof raw}.`,
4403
+ 'Write aggregate={{ function: "count", groupBy: "<field>" }} \u2014 or bind data={\u2026} instead to chart precomputed rows.'
4404
+ );
4405
+ return;
4406
+ }
4407
+ const groupByAbsent = raw.groupBy === void 0;
4408
+ if (groupByAbsent) {
4409
+ push2(
4410
+ "warning",
4411
+ REACT_CHART_AGGREGATE_INVALID,
4412
+ "aggregate.groupBy is not set, so the aggregate returns ONE ungrouped row and the chart plots a single point.",
4413
+ "Add aggregate.groupBy (a field name, or { field, dateGranularity } to bucket dates) to give the chart a category axis. Deliberate single-value charts are tolerated at warning level for now: ChartAggregateSchema declares groupBy required while ObjectChart honours its absence by falling back to xAxisKey \u2014 objectstack#5583 decides which of the two moves."
4414
+ );
4415
+ }
4416
+ const parsed = ChartAggregateSchema.safeParse(raw);
4417
+ if (parsed.success) return;
4418
+ for (const issue of parsed.error.issues) {
4419
+ if (groupByAbsent && issue.path[0] === "groupBy") continue;
4420
+ const at = issue.path.length ? `aggregate.${issue.path.join(".")}` : "aggregate";
4421
+ push2(
4422
+ "error",
4423
+ REACT_CHART_AGGREGATE_INVALID,
4424
+ `${at}: ${describeIssue(issue, raw)}`,
4425
+ "The aggregate is declared by ChartAggregateSchema (@objectstack/spec/ui) \u2014 the rejection above carries the fix."
4426
+ );
4427
+ }
4428
+ }
4429
+ var isRec14 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4180
4430
  var strOf = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
4181
4431
  function checkObjectChart(attrs, objectFields, findings) {
4182
4432
  const { values, where, path } = attrs;
4183
4433
  const push2 = (severity, rule, message, hint) => findings.push({ severity, rule, where, path, message, hint });
4434
+ checkChartDrillDown(values.get("drillDown"), push2);
4184
4435
  if (values.has("data")) return;
4185
4436
  const aggregate = values.get("aggregate");
4437
+ checkChartAggregate(aggregate, push2);
4186
4438
  if (aggregate === void 0 || aggregate === NOT_STATIC) return;
4187
- if (!isRec12(aggregate)) return;
4439
+ if (!isRec14(aggregate)) return;
4188
4440
  const fn = strOf(aggregate.function);
4189
4441
  const field = strOf(aggregate.field);
4190
4442
  const groupBy = aggregate.groupBy;
4191
- const groupByField = strOf(groupBy) ?? (isRec12(groupBy) ? strOf(groupBy.field) : void 0);
4192
- if (fn && !CHART_FUNCTIONS.includes(fn)) {
4193
- push2(
4194
- "error",
4195
- REACT_CHART_AGGREGATE_INVALID,
4196
- `aggregate.function "${fn}" is not an aggregation this chart can run.`,
4197
- `Use one of: ${CHART_FUNCTIONS.join(", ")}.`
4198
- );
4199
- } else if (fn && fn !== "count" && !field) {
4200
- push2(
4201
- "error",
4202
- REACT_CHART_AGGREGATE_INVALID,
4203
- `aggregate.function "${fn}" has no "field" to aggregate.`,
4204
- 'Add aggregate.field, or use function "count" (the only one that may omit it).'
4205
- );
4206
- }
4443
+ const groupByField = strOf(groupBy) ?? (isRec14(groupBy) ? strOf(groupBy.field) : void 0);
4207
4444
  const objectName = strOf(values.get("objectName"));
4208
4445
  const known = objectName ? objectFields.get(objectName) : void 0;
4209
4446
  if (objectName && known) {
@@ -4236,18 +4473,18 @@ function checkObjectChart(attrs, objectFields, findings) {
4236
4473
  );
4237
4474
  };
4238
4475
  const xAxisRaw = values.get("xAxis");
4239
- const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (isRec12(xAxisRaw) ? strOf(xAxisRaw.field) : void 0);
4476
+ const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (isRec14(xAxisRaw) ? strOf(xAxisRaw.field) : void 0);
4240
4477
  const categoryProp = values.has("xAxisKey") ? "xAxisKey" : "xAxis.field";
4241
4478
  axisRef(categoryAxis, categoryProp);
4242
4479
  const yAxisRaw = values.get("yAxis");
4243
4480
  const yAxisList = Array.isArray(yAxisRaw) ? yAxisRaw : yAxisRaw !== void 0 ? [yAxisRaw] : [];
4244
4481
  for (const a of yAxisList) {
4245
- axisRef(strOf(a) ?? (isRec12(a) ? strOf(a.field) : void 0), "yAxis[].field");
4482
+ axisRef(strOf(a) ?? (isRec14(a) ? strOf(a.field) : void 0), "yAxis[].field");
4246
4483
  }
4247
4484
  const series = values.get("series");
4248
4485
  if (Array.isArray(series)) {
4249
4486
  for (const s of series) {
4250
- if (!isRec12(s)) continue;
4487
+ if (!isRec14(s)) continue;
4251
4488
  const dataKey = strOf(s.dataKey);
4252
4489
  axisRef(dataKey ?? strOf(s.name), dataKey ? "series[].dataKey" : "series[].name");
4253
4490
  }
@@ -4303,7 +4540,7 @@ function subformFieldRefs(value, basePath) {
4303
4540
  if (!Array.isArray(value)) return { child, parent };
4304
4541
  for (let i = 0; i < value.length; i++) {
4305
4542
  const sub = value[i];
4306
- if (!isRec12(sub)) continue;
4543
+ if (!isRec14(sub)) continue;
4307
4544
  const at = (key) => `${basePath}[${i}].${key}`;
4308
4545
  child.push({
4309
4546
  objectName: strOf(sub.childObject),
@@ -4348,20 +4585,20 @@ function reactFieldRefs(spec, values, basePath) {
4348
4585
  }
4349
4586
  for (const key of spec.nestedFields ?? []) {
4350
4587
  const v = readable(key);
4351
- if (isRec12(v)) own.push(...fieldRefsFrom(v.fields, at(`${key}.fields`)));
4588
+ if (isRec14(v)) own.push(...fieldRefsFrom(v.fields, at(`${key}.fields`)));
4352
4589
  }
4353
4590
  for (const key of spec.sections ?? []) {
4354
4591
  const v = readable(key);
4355
4592
  if (!Array.isArray(v)) continue;
4356
4593
  for (let i = 0; i < v.length; i++) {
4357
4594
  const section = v[i];
4358
- if (!isRec12(section)) continue;
4595
+ if (!isRec14(section)) continue;
4359
4596
  own.push(...fieldRefsFrom(section.fields, at(`${key}[${i}].fields`)));
4360
4597
  }
4361
4598
  }
4362
4599
  for (const key of spec.keyedByField ?? []) {
4363
4600
  const v = readable(key);
4364
- if (!isRec12(v)) continue;
4601
+ if (!isRec14(v)) continue;
4365
4602
  for (const k of Object.keys(v)) own.push({ name: k, path: at(`${key}.${k}`) });
4366
4603
  }
4367
4604
  for (const key of spec.filterArrays ?? []) {
@@ -4551,6 +4788,14 @@ var REFERENCE_INTEGRITY_RULES = [
4551
4788
  // `component` (an unregistered ref renders a named diagnostic, not silence).
4552
4789
  { name: "validateNavTargetRefs", run: validateNavTargetRefs },
4553
4790
  { name: "validateTranslationReferences", run: validateTranslationReferences },
4791
+ // The same family from the other end (#5417). Its sibling above asks "does
4792
+ // this bundle key resolve?"; this one asks "is there a key at all?" — a form
4793
+ // section authored with a `label` and no `name` renders a heading that
4794
+ // `_sections` (keyed by name) can never address, so neither the orphan check
4795
+ // nor the coverage walk can see it. A reference that cannot be written is
4796
+ // still a reference question, and warning-only for the same reason its
4797
+ // sibling is: one heading stays in the source locale, nothing breaks.
4798
+ { name: "validateTranslatableSections", run: validateTranslatableSections },
4554
4799
  { name: "validateFlowTemplatePaths", run: validateFlowTemplatePaths },
4555
4800
  { name: "validateAiSurfaceAffinity", run: validateAiSurfaceAffinity },
4556
4801
  { name: "validateAiToolReferences", run: validateAiToolReferences },
@@ -4635,6 +4880,90 @@ function validateReferenceIntegrity(stack) {
4635
4880
  return findings;
4636
4881
  }
4637
4882
 
4883
+ // src/validate-component-props.ts
4884
+ import { ComponentPropsMap } from "@objectstack/spec/ui";
4885
+ import { lintUnknownKeysAgainstSchema } from "@objectstack/spec";
4886
+ var COMPONENT_PROPS_UNKNOWN_KEY = "component-props-unknown-key";
4887
+ var COMPONENT_PROPS_INVALID = "component-props-invalid";
4888
+ function isRec15(v) {
4889
+ return !!v && typeof v === "object" && !Array.isArray(v);
4890
+ }
4891
+ function strName16(v) {
4892
+ return typeof v === "string" && v.length > 0 ? v : void 0;
4893
+ }
4894
+ function asArray24(v) {
4895
+ if (Array.isArray(v)) return v;
4896
+ if (v && typeof v === "object") {
4897
+ return Object.entries(v).map(([name, def]) => ({ name, ...def }));
4898
+ }
4899
+ return [];
4900
+ }
4901
+ var PROPS_SCHEMAS = ComponentPropsMap;
4902
+ var DATASOURCE_SUPPLIED_PROP = "object";
4903
+ function suppliedByDataSource(issue, component) {
4904
+ if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false;
4905
+ const dataSource = isRec15(component.dataSource) ? component.dataSource : void 0;
4906
+ return strName16(dataSource?.object) !== void 0;
4907
+ }
4908
+ function validateComponentProps(stack) {
4909
+ const findings = [];
4910
+ if (!isRec15(stack)) return findings;
4911
+ const pages = asArray24(stack.pages);
4912
+ for (let pi = 0; pi < pages.length; pi++) {
4913
+ const page = pages[pi];
4914
+ if (!isRec15(page)) continue;
4915
+ const pageName = strName16(page.name) ?? `#${pi}`;
4916
+ for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
4917
+ const type = strName16(component.type);
4918
+ if (!type) continue;
4919
+ const schema = PROPS_SCHEMAS[type];
4920
+ if (!schema) continue;
4921
+ const props = isRec15(component.properties) ? component.properties : void 0;
4922
+ if (!props) continue;
4923
+ const where = `page "${pageName}" \xB7 ${type}`;
4924
+ const base = `${path}.properties`;
4925
+ for (const f of lintUnknownKeysAgainstSchema(schema, props, type, base)) {
4926
+ findings.push({
4927
+ severity: "warning",
4928
+ rule: COMPONENT_PROPS_UNKNOWN_KEY,
4929
+ where,
4930
+ path: f.path,
4931
+ message: `\`${f.key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui), so nothing verifies it: \`properties\` is an untyped bag, the renderer spreads whatever it carries, and a key it does not read is ignored in silence.` + (f.suggestion ? ` Did you mean \`${f.suggestion}\`?` : ""),
4932
+ hint: f.guidance ?? (f.suggestion ? `Rename \`${f.key}\` \u2192 \`${f.suggestion}\`.` : `Remove \`${f.key}\`, or \u2014 if the component really does honour it \u2014 declare it on \`${type}\`'s props schema so the declaration and the renderer agree.`)
4933
+ });
4934
+ }
4935
+ const parsed = schema.safeParse(props);
4936
+ if (parsed.success) continue;
4937
+ for (const issue of parsed.error?.issues ?? []) {
4938
+ if (suppliedByDataSource(issue, component)) continue;
4939
+ const at = issue.path.length ? `${base}.${issue.path.join(".")}` : base;
4940
+ if (issue.code === "unrecognized_keys") {
4941
+ for (const key of issue.keys ?? []) {
4942
+ findings.push({
4943
+ severity: "warning",
4944
+ rule: COMPONENT_PROPS_UNKNOWN_KEY,
4945
+ where,
4946
+ path: `${at}.${key}`,
4947
+ message: `\`${key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui): ${issue.message}`,
4948
+ hint: `Remove \`${key}\`, or declare it on \`${type}\`'s props schema if the component honours it.`
4949
+ });
4950
+ }
4951
+ continue;
4952
+ }
4953
+ findings.push({
4954
+ severity: "warning",
4955
+ rule: COMPONENT_PROPS_INVALID,
4956
+ where,
4957
+ path: at,
4958
+ message: `${at.slice(base.length + 1) || "properties"}: ${describeIssue(issue, props)}`,
4959
+ hint: `\`${type}\`'s props are declared by ComponentPropsMap (@objectstack/spec/ui) \u2014 the rejection above carries the fix. Advisory for now: the props bag is not parsed on the storage path either, so nothing rejects this today (objectstack#5068).`
4960
+ });
4961
+ }
4962
+ }
4963
+ }
4964
+ return findings;
4965
+ }
4966
+
4638
4967
  // src/validate-responsive-styles.ts
4639
4968
  var STYLE_NODE_MISSING_ID = "style-node-missing-id";
4640
4969
  var STYLE_CLASSNAME_TAILWIND = "style-classname-tailwind";
@@ -4838,7 +5167,7 @@ function looksLikeTailwind(className) {
4838
5167
  return false;
4839
5168
  });
4840
5169
  }
4841
- function asArray24(v) {
5170
+ function asArray25(v) {
4842
5171
  if (Array.isArray(v)) return v;
4843
5172
  if (v && typeof v === "object") {
4844
5173
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -4931,13 +5260,13 @@ function checkNode(node, pageName, path, findings) {
4931
5260
  }
4932
5261
  function validateResponsiveStyles(stack) {
4933
5262
  const findings = [];
4934
- const pages = asArray24(stack.pages);
5263
+ const pages = asArray25(stack.pages);
4935
5264
  for (let p = 0; p < pages.length; p++) {
4936
5265
  const page = pages[p];
4937
5266
  const pageName = typeof page.name === "string" ? page.name : `pages[${p}]`;
4938
- const regions = asArray24(page.regions);
5267
+ const regions = asArray25(page.regions);
4939
5268
  for (let r = 0; r < regions.length; r++) {
4940
- const components = asArray24(regions[r].components);
5269
+ const components = asArray25(regions[r].components);
4941
5270
  for (let c = 0; c < components.length; c++) {
4942
5271
  checkNode(components[c], pageName, `pages[${p}].regions[${r}].components[${c}]`, findings);
4943
5272
  }
@@ -4948,10 +5277,10 @@ function validateResponsiveStyles(stack) {
4948
5277
 
4949
5278
  // src/validate-jsx-pages.ts
4950
5279
  import { parseJsx, compile } from "@objectstack/sdui-parser";
4951
- var asArray25 = (v) => Array.isArray(v) ? v : [];
5280
+ var asArray26 = (v) => Array.isArray(v) ? v : [];
4952
5281
  function validateJsxPages(stack, opts = {}) {
4953
5282
  const findings = [];
4954
- const pages = asArray25(stack.pages);
5283
+ const pages = asArray26(stack.pages);
4955
5284
  for (let p = 0; p < pages.length; p++) {
4956
5285
  const page = pages[p];
4957
5286
  if (!page || page.kind !== "html" && page.kind !== "jsx") continue;
@@ -4998,10 +5327,10 @@ function loadSucraseTransform() {
4998
5327
  }
4999
5328
  return cachedTransform;
5000
5329
  }
5001
- var asArray26 = (v) => Array.isArray(v) ? v : [];
5330
+ var asArray27 = (v) => Array.isArray(v) ? v : [];
5002
5331
  function validateReactPages(stack) {
5003
5332
  const findings = [];
5004
- const pages = asArray26(stack.pages);
5333
+ const pages = asArray27(stack.pages);
5005
5334
  for (let p = 0; p < pages.length; p++) {
5006
5335
  const page = pages[p];
5007
5336
  if (!page || page.kind !== "react") continue;
@@ -5038,11 +5367,11 @@ function validateReactPages(stack) {
5038
5367
 
5039
5368
  // src/validate-page-source-styling.ts
5040
5369
  var PAGE_SOURCE_CLASSNAME = "page-source-className-tailwind";
5041
- var asArray27 = (v) => Array.isArray(v) ? v : [];
5370
+ var asArray28 = (v) => Array.isArray(v) ? v : [];
5042
5371
  var CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g;
5043
5372
  function validatePageSourceStyling(stack) {
5044
5373
  const findings = [];
5045
- const pages = asArray27(stack.pages);
5374
+ const pages = asArray28(stack.pages);
5046
5375
  for (let p = 0; p < pages.length; p++) {
5047
5376
  const page = pages[p];
5048
5377
  if (!page) continue;
@@ -5070,7 +5399,7 @@ function validatePageSourceStyling(stack) {
5070
5399
  // src/validate-capability-references.ts
5071
5400
  import { PLATFORM_CAPABILITY_NAMES } from "@objectstack/spec/security";
5072
5401
  var CAPABILITY_REFERENCE_UNKNOWN = "capability-reference-unknown";
5073
- function asArray28(v) {
5402
+ function asArray29(v) {
5074
5403
  if (Array.isArray(v)) return v;
5075
5404
  if (v && typeof v === "object") {
5076
5405
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5095,13 +5424,13 @@ function validateCapabilityReferences(stack) {
5095
5424
  const findings = [];
5096
5425
  if (!stack || typeof stack !== "object") return findings;
5097
5426
  const known = new Set(PLATFORM_CAPABILITY_NAMES);
5098
- for (const cap of asArray28(stack.capabilities)) {
5427
+ for (const cap of asArray29(stack.capabilities)) {
5099
5428
  if (typeof cap.name === "string" && cap.name.length > 0) known.add(cap.name);
5100
5429
  }
5101
- for (const ps of asArray28(stack.permissions)) {
5430
+ for (const ps of asArray29(stack.permissions)) {
5102
5431
  for (const cap of asCapArray(ps.systemPermissions)) known.add(cap);
5103
5432
  }
5104
- for (const seed of asArray28(stack.data)) {
5433
+ for (const seed of asArray29(stack.data)) {
5105
5434
  if (seed.object !== "sys_capability") continue;
5106
5435
  for (const rec of Array.isArray(seed.records) ? seed.records : []) {
5107
5436
  const name = rec?.name;
@@ -5120,7 +5449,7 @@ function validateCapabilityReferences(stack) {
5120
5449
  hint
5121
5450
  });
5122
5451
  };
5123
- const objects = asArray28(stack.objects);
5452
+ const objects = asArray29(stack.objects);
5124
5453
  for (let i = 0; i < objects.length; i++) {
5125
5454
  const obj = objects[i];
5126
5455
  if (!obj || typeof obj !== "object") continue;
@@ -5129,27 +5458,27 @@ function validateCapabilityReferences(stack) {
5129
5458
  for (const { cap, key } of flattenObjectRequired(obj.requiredPermissions)) {
5130
5459
  flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ""}`);
5131
5460
  }
5132
- const fields = asArray28(obj.fields);
5461
+ const fields = asArray29(obj.fields);
5133
5462
  for (const f of fields) {
5134
5463
  const fname = typeof f.name === "string" ? f.name : "(field)";
5135
5464
  for (const cap of asCapArray(f.requiredPermissions)) {
5136
5465
  flag(cap, `field "${objName}.${fname}"`, `${objPath}.fields.${fname}.requiredPermissions`);
5137
5466
  }
5138
5467
  }
5139
- for (const [ai, action] of asArray28(obj.actions).entries()) {
5468
+ for (const [ai, action] of asArray29(obj.actions).entries()) {
5140
5469
  const aName = typeof action.name === "string" ? action.name : `(action ${ai})`;
5141
5470
  for (const cap of asCapArray(action.requiredPermissions)) {
5142
5471
  flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`);
5143
5472
  }
5144
5473
  }
5145
5474
  }
5146
- for (const [i, action] of asArray28(stack.actions).entries()) {
5475
+ for (const [i, action] of asArray29(stack.actions).entries()) {
5147
5476
  const aName = typeof action.name === "string" ? action.name : `(action ${i})`;
5148
5477
  for (const cap of asCapArray(action.requiredPermissions)) {
5149
5478
  flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`);
5150
5479
  }
5151
5480
  }
5152
- const apps = asArray28(stack.apps);
5481
+ const apps = asArray29(stack.apps);
5153
5482
  for (let i = 0; i < apps.length; i++) {
5154
5483
  const app = apps[i];
5155
5484
  if (!app || typeof app !== "object") continue;
@@ -5176,11 +5505,14 @@ function validateCapabilityReferences(stack) {
5176
5505
  }
5177
5506
 
5178
5507
  // src/validate-flow-trigger-readiness.ts
5508
+ import { TimeRelativeTriggerSchema } from "@objectstack/spec/automation";
5179
5509
  var FLOW_TRIGGER_UNKNOWN_OBJECT = "flow-trigger-unknown-object";
5180
5510
  var FLOW_DRAFT_STATUS_AMBIGUOUS = "flow-draft-status-ambiguous";
5181
5511
  var FLOW_TRIGGER_UNKNOWN_EVENT = "flow-trigger-unknown-event";
5512
+ var FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID = "flow-time-relative-descriptor-invalid";
5513
+ var FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = "flow-time-relative-descriptor-unroutable";
5182
5514
  var VALID_RECORD_TRIGGER = /^record-(?:before|after)-(?:create|insert|update|delete|write)$/;
5183
- function asArray29(v) {
5515
+ function asArray30(v) {
5184
5516
  if (Array.isArray(v)) return v;
5185
5517
  if (v && typeof v === "object") {
5186
5518
  return Object.entries(v).map(([name, def]) => ({
@@ -5190,6 +5522,12 @@ function asArray29(v) {
5190
5522
  }
5191
5523
  return [];
5192
5524
  }
5525
+ function renderNonObject(v) {
5526
+ const t = typeof v;
5527
+ if (t === "string" || t === "number" || t === "boolean") return `${JSON.stringify(v)} (a ${t})`;
5528
+ if (t === "bigint") return `${String(v)}n (a bigint)`;
5529
+ return `a ${t}`;
5530
+ }
5193
5531
  function startNodeOf(flow) {
5194
5532
  const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
5195
5533
  const index = nodes.findIndex((n) => n?.type === "start");
@@ -5197,10 +5535,10 @@ function startNodeOf(flow) {
5197
5535
  }
5198
5536
  function validateFlowTriggerReadiness(stack) {
5199
5537
  const findings = [];
5200
- const flows = asArray29(stack.flows);
5538
+ const flows = asArray30(stack.flows);
5201
5539
  if (flows.length === 0) return findings;
5202
5540
  const objectNames = new Set(
5203
- asArray29(stack.objects).map((o) => typeof o.name === "string" ? o.name : void 0).filter((n) => !!n)
5541
+ asArray30(stack.objects).map((o) => typeof o.name === "string" ? o.name : void 0).filter((n) => !!n)
5204
5542
  );
5205
5543
  flows.forEach((flow, flowIndex) => {
5206
5544
  const flowName = typeof flow.name === "string" ? flow.name : `#${flowIndex}`;
@@ -5237,10 +5575,35 @@ function validateFlowTriggerReadiness(stack) {
5237
5575
  hint: `Object names match exactly. Check config.timeRelative.object against the object's registered name. If the object comes from another installed package, this warning can be ignored.`
5238
5576
  });
5239
5577
  }
5578
+ const parsed = TimeRelativeTriggerSchema.safeParse(tr);
5579
+ if (!parsed.success) {
5580
+ const problems = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message.replace(/\s+/g, " ").trim()}`).join("; ");
5581
+ findings.push({
5582
+ // `error` (#5762): the verdict is `TimeRelativeTriggerSchema`'s, and it
5583
+ // is the same schema the trigger safeParses at bind time. A descriptor
5584
+ // it refuses is refused at bind too — the sweep is never installed, on
5585
+ // every deployment, with no installed package able to change the
5586
+ // answer. Nothing is left for the author to weigh.
5587
+ severity: "error",
5588
+ rule: FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
5589
+ where: `flow "${flowName}" \u203A start node`,
5590
+ path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`,
5591
+ message: `has a config.timeRelative descriptor the time-relative trigger REFUSES at bind time, so the sweep is never installed \u2014 the flow declares a time-relative trigger and then never runs (the only trace is one warn in the server log). ${problems}`,
5592
+ hint: `Those messages are TimeRelativeTriggerSchema's own \u2014 the same schema the trigger safeParses at bind time, so a descriptor that satisfies them binds. An unrecognized key names the declared key it was probably meant to be; see content/docs/references/automation/time-relative-trigger.mdx.`
5593
+ });
5594
+ }
5240
5595
  }
5241
5596
  if (start && isRecordTriggered2 && !VALID_RECORD_TRIGGER.test((triggerType ?? "").trim())) {
5242
5597
  findings.push({
5243
- severity: "warning",
5598
+ // `error` (#5762). The token grammar is CLOSED and local: the engine
5599
+ // routes any `record-`-prefixed string to the record-change trigger by a
5600
+ // hardcoded prefix test (no registry lookup, so installing a package
5601
+ // cannot claim a new `record-*` token), and that trigger maps the token
5602
+ // with `triggerTypeToHookEvents` — the same regex this file's
5603
+ // `VALID_RECORD_TRIGGER` mirrors. Off-grammar means zero hook events,
5604
+ // which means bound-to-nothing on every deployment. Unlike an object
5605
+ // name, there is no other-package reading that rescues it.
5606
+ severity: "error",
5244
5607
  rule: FLOW_TRIGGER_UNKNOWN_EVENT,
5245
5608
  where: `flow "${flowName}" \u203A start node`,
5246
5609
  path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
@@ -5250,7 +5613,14 @@ function validateFlowTriggerReadiness(stack) {
5250
5613
  }
5251
5614
  if (start && isArrayRecordTriggered) {
5252
5615
  findings.push({
5253
- severity: "warning",
5616
+ // `error` (#5762), same id and same reason as 1c: an array maps to no
5617
+ // hook event either. The engine routes it to the record-change trigger
5618
+ // for the express purpose of making it loud, and its own comment names
5619
+ // THIS rule as the primary catch — a primary catch that only warns is
5620
+ // the "declared ≠ enforced" shape the registry's tier exists to close.
5621
+ // Multi-event arrays are deferred, not unsupported-by-accident (#3457),
5622
+ // so if they land the grammar widens here in the same commit.
5623
+ severity: "error",
5254
5624
  rule: FLOW_TRIGGER_UNKNOWN_EVENT,
5255
5625
  where: `flow "${flowName}" \u203A start node`,
5256
5626
  path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
@@ -5258,6 +5628,25 @@ function validateFlowTriggerReadiness(stack) {
5258
5628
  hint: `Use one triggerType string. For "created or updated" use record-after-write (one flow, both events, #3427). For any other combination, author one flow per event \u2014 multi-event arrays are deferred (#3457).`
5259
5629
  });
5260
5630
  }
5631
+ if (start && config.timeRelative != null && typeof config.timeRelative !== "object") {
5632
+ const fallback = isRecordTriggered2 || isArrayRecordTriggered ? "its record-change trigger" : config.schedule != null || flow.type === "schedule" ? "its plain `config.schedule` cadence" : triggerType === "api" || flow.type === "api" ? "its api trigger" : void 0;
5633
+ const consequence2 = fallback ? `The flow still binds through ${fallback}, so the descriptor is silently DROPPED \u2014 it fires on that trigger's terms (once per firing, with no record on the context) instead of once per matching record, and nothing anywhere reports the difference.` : `Nothing else on this start node declares a trigger either, so the flow binds to NOTHING and never fires \u2014 with zero diagnostics at any layer, not even the one bind-time warn a descriptor that IS an object gets when the trigger refuses it.`;
5634
+ findings.push({
5635
+ // `error` (#5762). The criterion IS the engine's routing predicate, so a
5636
+ // value that fails it is not routed to the time-relative trigger by any
5637
+ // deployment — the strongest verdict in this file, and the one case with
5638
+ // no runtime channel to fall back on (not even the bind-time warn 1b-ii
5639
+ // moves earlier). Note the two consequences below are both defects: one
5640
+ // never fires, the other silently drops the descriptor. Neither is a
5641
+ // shape the author can have meant, so both gate.
5642
+ severity: "error",
5643
+ rule: FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
5644
+ where: `flow "${flowName}" \u203A start node`,
5645
+ path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`,
5646
+ message: `has config.timeRelative = ${renderNonObject(config.timeRelative)}, which is not the descriptor OBJECT this slot takes \u2014 the engine routes a flow to the time-relative sweep only when config.timeRelative is an object, so this one is never routed there and the sweep is never installed. ${consequence2}`,
5647
+ hint: `config.timeRelative describes WHICH records to sweep \u2014 an object: { object, dateField, and exactly one of withinDays | offsetDays } (plus optional filter / maxRecords). A cadence like 'daily' is not a descriptor: HOW OFTEN the sweep runs is the sibling key config.schedule on the same start node (it defaults to daily, so it is usually omitted). See TimeRelativeTriggerSchema and content/docs/references/automation/time-relative-trigger.mdx.`
5648
+ });
5649
+ }
5261
5650
  if (isAutoTriggered && (flow.status == null || flow.status === "draft")) {
5262
5651
  findings.push({
5263
5652
  severity: "warning",
@@ -5283,7 +5672,7 @@ import {
5283
5672
  normalizeDecisionOutputs
5284
5673
  } from "@objectstack/spec/automation";
5285
5674
  import { BUILTIN_MEMBERSHIP_ROLES } from "@objectstack/spec";
5286
- import { collectCelRootIdentifiers } from "@objectstack/formula";
5675
+ import { collectCelRootIdentifiers as collectCelRootIdentifiers2 } from "@objectstack/formula";
5287
5676
  var APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = "approval-approver-not-membership-tier";
5288
5677
  var APPROVAL_APPROVER_TYPE_DEPRECATED = "approval-approver-type-deprecated";
5289
5678
  var APPROVAL_APPROVER_TYPE_UNKNOWN = "approval-approver-type-unknown";
@@ -5303,7 +5692,7 @@ var TYPE_FIX = {
5303
5692
  business_unit: "department",
5304
5693
  bu: "department"
5305
5694
  };
5306
- function asArray30(v) {
5695
+ function asArray31(v) {
5307
5696
  if (Array.isArray(v)) return v;
5308
5697
  if (v && typeof v === "object") {
5309
5698
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5313,7 +5702,7 @@ function asArray30(v) {
5313
5702
  function validateApprovalApprovers(stack) {
5314
5703
  const findings = [];
5315
5704
  if (!stack || typeof stack !== "object") return findings;
5316
- const flows = asArray30(stack.flows);
5705
+ const flows = asArray31(stack.flows);
5317
5706
  const validTypes = new Set(ApproverType.options);
5318
5707
  for (let fi = 0; fi < flows.length; fi++) {
5319
5708
  const flow = flows[fi];
@@ -5358,7 +5747,7 @@ function validateApprovalApprovers(stack) {
5358
5747
  hint: `Write a CEL expression over current.* (the record's live state at node entry), trigger.* (the submit-time snapshot) or vars.* (flow variables), e.g. current.approvers_dynamic or vars.approval_lead.picked_departments.`
5359
5748
  });
5360
5749
  } else {
5361
- const parsed = collectCelRootIdentifiers(source);
5750
+ const parsed = collectCelRootIdentifiers2(source);
5362
5751
  if (!parsed.ok) {
5363
5752
  findings.push({
5364
5753
  severity: "error",
@@ -5496,7 +5885,7 @@ function validateApprovalApprovers(stack) {
5496
5885
  import { objectTitleCompleteness } from "@objectstack/spec/data";
5497
5886
  var TITLE_FORMAT_RETIRED = "title-format-retired";
5498
5887
  var TITLE_UNRESOLVABLE = "title-unresolvable";
5499
- function asArray31(v) {
5888
+ function asArray32(v) {
5500
5889
  if (Array.isArray(v)) return v;
5501
5890
  if (v && typeof v === "object") {
5502
5891
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5505,7 +5894,7 @@ function asArray31(v) {
5505
5894
  }
5506
5895
  function validateRecordTitle(stack) {
5507
5896
  const findings = [];
5508
- const objects = asArray31(stack.objects);
5897
+ const objects = asArray32(stack.objects);
5509
5898
  for (let i = 0; i < objects.length; i++) {
5510
5899
  const obj = objects[i];
5511
5900
  const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
@@ -5541,7 +5930,7 @@ var FIELD_GROUP_UNDECLARED = "field-group-undeclared";
5541
5930
  var FIELD_GROUP_EMPTY = "field-group-empty";
5542
5931
  var FIELD_GROUP_SHADOWED = "field-group-shadowed";
5543
5932
  var SEMANTIC_ROLE_FIELD_UNKNOWN = "semantic-role-field-unknown";
5544
- function asArray32(v) {
5933
+ function asArray33(v) {
5545
5934
  if (Array.isArray(v)) return v;
5546
5935
  if (v && typeof v === "object") {
5547
5936
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5550,7 +5939,7 @@ function asArray32(v) {
5550
5939
  }
5551
5940
  function validateSemanticRoles(stack) {
5552
5941
  const findings = [];
5553
- const objects = asArray32(stack.objects);
5942
+ const objects = asArray33(stack.objects);
5554
5943
  for (let i = 0; i < objects.length; i++) {
5555
5944
  const obj = objects[i];
5556
5945
  if (!obj || typeof obj !== "object") continue;
@@ -5558,7 +5947,7 @@ function validateSemanticRoles(stack) {
5558
5947
  const where = `object "${objName}"`;
5559
5948
  const path = `objects[${i}]`;
5560
5949
  const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? obj.fields : {};
5561
- const fieldNames = new Set(Object.keys(fields));
5950
+ const fieldNames = /* @__PURE__ */ new Set([...Object.keys(fields), ...injectedColumnsFor(obj)]);
5562
5951
  const declaredGroups = new Set(
5563
5952
  (Array.isArray(obj.fieldGroups) ? obj.fieldGroups : []).filter((g) => !!g && typeof g === "object").map((g) => g.key).filter((k) => typeof k === "string" && k.length > 0)
5564
5953
  );
@@ -5645,7 +6034,7 @@ function validateSemanticRoles(stack) {
5645
6034
  // src/validate-form-layout.ts
5646
6035
  var FORM_FIELD_UNKNOWN = "form-field-unknown";
5647
6036
  var FORM_COLSPAN_ABSOLUTE = "absolute-colspan-discouraged";
5648
- function asArray33(v) {
6037
+ function asArray34(v) {
5649
6038
  if (Array.isArray(v)) return v;
5650
6039
  if (v && typeof v === "object") {
5651
6040
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5670,13 +6059,13 @@ function boundObject(view) {
5670
6059
  function validateFormLayout(stack) {
5671
6060
  const findings = [];
5672
6061
  const objectFields = /* @__PURE__ */ new Map();
5673
- for (const obj of asArray33(stack.objects)) {
6062
+ for (const obj of asArray34(stack.objects)) {
5674
6063
  const name = typeof obj.name === "string" ? obj.name : void 0;
5675
6064
  if (!name) continue;
5676
6065
  const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? Object.keys(obj.fields) : [];
5677
6066
  objectFields.set(name, new Set(fields));
5678
6067
  }
5679
- const views = asArray33(stack.views);
6068
+ const views = asArray34(stack.views);
5680
6069
  for (let i = 0; i < views.length; i++) {
5681
6070
  const view = views[i];
5682
6071
  if (!view || typeof view !== "object") continue;
@@ -5816,7 +6205,7 @@ var VISIBILITY_ALIAS_DEPRECATED = "visibility-alias-deprecated";
5816
6205
  var VISIBILITY_ROOT_MISLAYERED = "visibility-root-mislayered";
5817
6206
  var CANONICAL = "visibleWhen";
5818
6207
  var ALIASES = ["visibleOn", "visibility"];
5819
- function asArray34(v) {
6208
+ function asArray35(v) {
5820
6209
  if (Array.isArray(v)) return v;
5821
6210
  if (v && typeof v === "object") {
5822
6211
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5878,7 +6267,7 @@ function isFieldObject(entry) {
5878
6267
  function validateVisibilityPredicates(stack, opts = {}) {
5879
6268
  const layer = opts.layer ?? "runtime";
5880
6269
  const findings = [];
5881
- const views = asArray34(stack.views);
6270
+ const views = asArray35(stack.views);
5882
6271
  for (let i = 0; i < views.length; i++) {
5883
6272
  const view = views[i];
5884
6273
  if (!view || typeof view !== "object") continue;
@@ -5901,7 +6290,7 @@ function validateVisibilityPredicates(stack, opts = {}) {
5901
6290
  }
5902
6291
  }
5903
6292
  }
5904
- const pages = asArray34(stack.pages);
6293
+ const pages = asArray35(stack.pages);
5905
6294
  for (let i = 0; i < pages.length; i++) {
5906
6295
  const page = pages[i];
5907
6296
  if (!page || typeof page !== "object") continue;
@@ -5948,7 +6337,7 @@ var OWD_WIDTH = {
5948
6337
  public_read: 1,
5949
6338
  public_read_write: 2
5950
6339
  };
5951
- function asArray35(v) {
6340
+ function asArray36(v) {
5952
6341
  if (Array.isArray(v)) return v;
5953
6342
  if (v && typeof v === "object") {
5954
6343
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5956,7 +6345,7 @@ function asArray35(v) {
5956
6345
  return [];
5957
6346
  }
5958
6347
  function owdOf(obj) {
5959
- return obj.sharingModel ?? obj.security?.sharingModel;
6348
+ return obj.sharingModel;
5960
6349
  }
5961
6350
  function isSystemObject(obj) {
5962
6351
  return obj.isSystem === true || String(obj.name ?? "").startsWith("sys_");
@@ -5970,11 +6359,11 @@ function labelHasRoleWord(label2) {
5970
6359
  return /\brole(s)?\b/i.test(label2);
5971
6360
  }
5972
6361
  function refOf(def) {
5973
- const r = def.reference ?? def.reference_to;
6362
+ const r = def.reference;
5974
6363
  return typeof r === "string" && r ? r : void 0;
5975
6364
  }
5976
6365
  function firstMasterDetailField(obj) {
5977
- for (const f of asArray35(obj.fields)) {
6366
+ for (const f of asArray36(obj.fields)) {
5978
6367
  if (f.type === "master_detail") {
5979
6368
  return { name: String(f.name ?? "?"), parent: refOf(f) };
5980
6369
  }
@@ -5987,8 +6376,8 @@ function grantsObjectAccess(p) {
5987
6376
  function validateSecurityPosture(stack, opts) {
5988
6377
  const findings = [];
5989
6378
  if (!stack || typeof stack !== "object") return findings;
5990
- const objects = asArray35(stack.objects);
5991
- const permissionSets = asArray35(stack.permissions);
6379
+ const objects = asArray36(stack.objects);
6380
+ const permissionSets = asArray36(stack.permissions);
5992
6381
  for (let i = 0; i < objects.length; i++) {
5993
6382
  const obj = objects[i];
5994
6383
  if (!obj || typeof obj !== "object") continue;
@@ -6117,10 +6506,10 @@ function validateSecurityPosture(stack, opts) {
6117
6506
  if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
6118
6507
  const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
6119
6508
  flagRole("object", obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`);
6120
- for (const f of asArray35(obj.fields)) {
6509
+ for (const f of asArray36(obj.fields)) {
6121
6510
  flagRole("field", f.name, f.label, `field "${objName}.${String(f.name ?? "?")}"`, `objects[${i}].fields.${String(f.name ?? "?")}.name`);
6122
6511
  }
6123
- for (const [ai, action] of asArray35(obj.actions).entries()) {
6512
+ for (const [ai, action] of asArray36(obj.actions).entries()) {
6124
6513
  flagRole("action", action.name, action.label, `action "${objName}.${String(action.name ?? "?")}"`, `objects[${i}].actions[${ai}].name`);
6125
6514
  }
6126
6515
  }
@@ -6129,19 +6518,19 @@ function validateSecurityPosture(stack, opts) {
6129
6518
  if (!ps || typeof ps !== "object") continue;
6130
6519
  flagRole("permission set", ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`);
6131
6520
  }
6132
- for (const [i, pos] of asArray35(stack.positions).entries()) {
6521
+ for (const [i, pos] of asArray36(stack.positions).entries()) {
6133
6522
  flagRole("position", pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`);
6134
6523
  }
6135
- for (const [i, app] of asArray35(stack.apps).entries()) {
6524
+ for (const [i, app] of asArray36(stack.apps).entries()) {
6136
6525
  flagRole("app", app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
6137
6526
  }
6138
- for (const [i, book] of asArray35(stack.books).entries()) {
6527
+ for (const [i, book] of asArray36(stack.books).entries()) {
6139
6528
  flagRole("book", book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
6140
6529
  }
6141
6530
  const stackSetNames = new Set(
6142
6531
  permissionSets.map((ps) => typeof ps.name === "string" ? ps.name : void 0).filter((n) => !!n)
6143
6532
  );
6144
- for (const [i, book] of asArray35(stack.books).entries()) {
6533
+ for (const [i, book] of asArray36(stack.books).entries()) {
6145
6534
  const audience = book.audience;
6146
6535
  if (!audience || typeof audience !== "object") continue;
6147
6536
  const setName = audience.permissionSet;
@@ -6219,7 +6608,7 @@ function validateSecurityPosture(stack, opts) {
6219
6608
  }
6220
6609
  const GRANT_SEED_OBJECTS = /* @__PURE__ */ new Set(["sys_user_position", "sys_user_permission_set"]);
6221
6610
  const nowMs = opts?.nowMs ?? Date.now();
6222
- for (const [i, seed] of asArray35(stack.data).entries()) {
6611
+ for (const [i, seed] of asArray36(stack.data).entries()) {
6223
6612
  const seedObject = typeof seed.object === "string" ? seed.object : "";
6224
6613
  if (!GRANT_SEED_OBJECTS.has(seedObject)) continue;
6225
6614
  const records = Array.isArray(seed.records) ? seed.records : [];
@@ -6263,7 +6652,8 @@ function validateSecurityPosture(stack, opts) {
6263
6652
  var ORG_AXIS_PERMISSION_INHERITANCE = "org-axis-permission-inheritance";
6264
6653
  var ORG_AXIS_CROSS_ORG_BU_GRANT = "org-axis-cross-org-bu-grant";
6265
6654
  var ORG_PARENT_FIELD = "parent_organization_id";
6266
- function asArray36(v) {
6655
+ var BU_TREE_RECIPIENT_TYPES = /* @__PURE__ */ new Set(["business_unit", "unit_and_subordinates"]);
6656
+ function asArray37(v) {
6267
6657
  if (Array.isArray(v)) return v;
6268
6658
  if (v && typeof v === "object") {
6269
6659
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -6273,6 +6663,15 @@ function asArray36(v) {
6273
6663
  function str(v) {
6274
6664
  return typeof v === "string" ? v : "";
6275
6665
  }
6666
+ function expressionText(v) {
6667
+ if (typeof v === "string") return v;
6668
+ if (v && typeof v === "object") {
6669
+ const rec = v;
6670
+ if (typeof rec.source === "string") return rec.source;
6671
+ if (rec.ast !== void 0) return JSON.stringify(rec.ast) ?? "";
6672
+ }
6673
+ return "";
6674
+ }
6276
6675
  function isTenancyDisabled(object) {
6277
6676
  const tenancy = object.tenancy;
6278
6677
  if (tenancy && typeof tenancy === "object" && tenancy.enabled === false) return true;
@@ -6284,9 +6683,9 @@ var INHERITANCE_HINT = `Remove the ${ORG_PARENT_FIELD} reference. Cross-organiza
6284
6683
  function validateOrgAxisRedLines(stack) {
6285
6684
  const findings = [];
6286
6685
  const cfg = stack ?? {};
6287
- const permissionSets = asArray36(cfg.permissions ?? cfg.permissionSets);
6686
+ const permissionSets = asArray37(cfg.permissions);
6288
6687
  permissionSets.forEach((ps, psIndex) => {
6289
- asArray36(ps.rowLevelSecurity).forEach((policy, pIndex) => {
6688
+ asArray37(ps.rowLevelSecurity).forEach((policy, pIndex) => {
6290
6689
  for (const clause of ["using", "check"]) {
6291
6690
  if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
6292
6691
  findings.push({
@@ -6300,68 +6699,434 @@ function validateOrgAxisRedLines(stack) {
6300
6699
  }
6301
6700
  });
6302
6701
  });
6303
- const objects = asArray36(cfg.objects);
6304
- objects.forEach((object, oIndex) => {
6305
- const objectName = str(object.name) || String(oIndex);
6306
- asArray36(object.rowLevelSecurity ?? object.rls).forEach((policy, pIndex) => {
6307
- for (const clause of ["using", "check"]) {
6308
- if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
6309
- findings.push({
6310
- severity: "error",
6311
- rule: ORG_AXIS_PERMISSION_INHERITANCE,
6312
- where: `object "${objectName}" policy "${str(policy.name) || pIndex}"`,
6313
- path: `objects[${oIndex}].rowLevelSecurity[${pIndex}].${clause}`,
6314
- message: `RLS ${clause} reads \`${ORG_PARENT_FIELD}\`, which builds a permission hierarchy along the organization axis. ADR-0105 D6 forbids it: the org tree is a REPORTING dimension only.`,
6315
- hint: INHERITANCE_HINT
6316
- });
6317
- }
6318
- });
6319
- });
6320
- asArray36(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
6321
- const criteria = JSON.stringify(rule.criteria ?? rule.filter ?? "");
6322
- const sharedTo = JSON.stringify(rule.sharedTo ?? rule.recipient ?? "");
6323
- if (criteria.includes(ORG_PARENT_FIELD) || sharedTo.includes(ORG_PARENT_FIELD)) {
6702
+ asArray37(cfg.sharingRules).forEach((rule, rIndex) => {
6703
+ const slots = [
6704
+ { key: "condition", text: expressionText(rule.condition) },
6705
+ { key: "sharedWith", text: JSON.stringify(rule.sharedWith ?? "") ?? "" }
6706
+ ];
6707
+ for (const slot of slots) {
6708
+ if (!slot.text.includes(ORG_PARENT_FIELD)) continue;
6324
6709
  findings.push({
6325
6710
  severity: "error",
6326
6711
  rule: ORG_AXIS_PERMISSION_INHERITANCE,
6327
6712
  where: `sharing rule "${str(rule.name) || rIndex}"`,
6328
- path: `sharingRules[${rIndex}]`,
6329
- message: `Sharing rule reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization tree. ADR-0105 D6 forbids permission inheritance along the org axis.`,
6713
+ path: `sharingRules[${rIndex}].${slot.key}`,
6714
+ message: `Sharing rule ${slot.key} reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization tree. ADR-0105 D6 forbids permission inheritance along the org axis.`,
6330
6715
  hint: INHERITANCE_HINT
6331
6716
  });
6332
6717
  }
6333
6718
  });
6334
6719
  const tenancyDisabledObjects = new Set(
6335
- objects.filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
6720
+ asArray37(cfg.objects).filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
6336
6721
  );
6337
- asArray36(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
6338
- const target = str(rule.object ?? rule.objectName);
6722
+ asArray37(cfg.sharingRules).forEach((rule, rIndex) => {
6723
+ const target = str(rule.object);
6339
6724
  if (!target || !tenancyDisabledObjects.has(target)) return;
6340
- const sharedTo = rule.sharedTo ?? rule.recipient;
6341
- const recipientType = str(sharedTo?.type);
6342
- if (recipientType !== "business_unit") return;
6725
+ const sharedWith = rule.sharedWith;
6726
+ const recipientType = str(sharedWith?.type);
6727
+ if (!BU_TREE_RECIPIENT_TYPES.has(recipientType)) return;
6728
+ const reach = recipientType === "unit_and_subordinates" ? "a business unit AND every descendant unit" : "a business unit";
6343
6729
  findings.push({
6344
6730
  severity: "error",
6345
6731
  rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
6346
6732
  where: `sharing rule "${str(rule.name) || rIndex}" on object "${target}"`,
6347
- path: `sharingRules[${rIndex}].sharedTo`,
6348
- message: `A business-unit sharing rule targets "${target}", which opted out of tenancy (\`tenancy.enabled: false\`). Platform-global objects carry no organization column, so this grant spans EVERY organization \u2014 a cross-organization business-unit grant, which ADR-0105 D6 forbids (BU trees are org-internal).`,
6349
- hint: `Either scope the object to organizations (drop \`tenancy.enabled: false\` so Layer 0 walls it), or share it to a position / permission-set audience instead of a business unit. A platform-global catalog that everyone should read wants an OWD of \`public_read\`, not a BU grant.`
6733
+ path: `sharingRules[${rIndex}].sharedWith`,
6734
+ message: `Sharing rule recipient \`${recipientType}\` (${reach}) targets "${target}", which opted out of tenancy (\`tenancy.enabled: false\`). Platform-global objects carry no organization column, so this grant spans EVERY organization \u2014 a cross-organization business-unit grant, which ADR-0105 D6 forbids (BU trees are org-internal).`,
6735
+ hint: `Either scope the object to organizations (drop \`tenancy.enabled: false\` so Layer 0 walls it), or share it to a \`user\` / \`team\` / \`position\` audience instead \u2014 those expand flat, with no business-unit tree to resolve. A platform-global catalog that everyone should read wants an OWD of \`public_read\`, not a BU grant.`
6736
+ });
6737
+ });
6738
+ return findings;
6739
+ }
6740
+
6741
+ // src/validate-sharing-rule-enforceability.ts
6742
+ import { compileCelToFilter } from "@objectstack/formula";
6743
+ var SHARING_RULE_UNLOWERABLE_CONDITION = "sharing-rule-unlowerable-condition";
6744
+ var SHARING_RULE_RUNTIME_VARIABLE_CONDITION = "sharing-rule-runtime-variable-condition";
6745
+ function asArray38(v) {
6746
+ if (Array.isArray(v)) return v;
6747
+ if (v && typeof v === "object") {
6748
+ return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6749
+ }
6750
+ return [];
6751
+ }
6752
+ function str2(v) {
6753
+ return typeof v === "string" ? v : "";
6754
+ }
6755
+ function toCompilerInput(condition) {
6756
+ if (typeof condition === "string") return condition.trim() ? condition : null;
6757
+ if (condition && typeof condition === "object") {
6758
+ const source = condition.source;
6759
+ if (typeof source === "string" && source.trim()) return { source };
6760
+ }
6761
+ return null;
6762
+ }
6763
+ function sourceOf(condition) {
6764
+ const input = toCompilerInput(condition);
6765
+ if (typeof input === "string") return input;
6766
+ return str2(input?.source);
6767
+ }
6768
+ var PUSHDOWN_SUBSET = "The lowerable subset is: `==` `!=` `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and the string methods `startsWith` / `endsWith` / `contains` \u2014 over SINGLE-column `record.<field>` paths (ADR-0058 D2).";
6769
+ function validateSharingRuleEnforceability(stack) {
6770
+ const findings = [];
6771
+ const cfg = stack ?? {};
6772
+ asArray38(cfg.sharingRules).forEach((rule, index) => {
6773
+ const input = toCompilerInput(rule.condition);
6774
+ if (input === null) return;
6775
+ const result = compileCelToFilter(input, { variables: {} });
6776
+ if (result.ok) return;
6777
+ if (result.reason === "parse-error") return;
6778
+ const name = str2(rule.name) || String(index);
6779
+ const object = str2(rule.object);
6780
+ const where = `sharing rule "${name}"${object ? ` on object "${object}"` : ""}`;
6781
+ const path = `sharingRules[${index}].condition`;
6782
+ const source = sourceOf(rule.condition);
6783
+ const skipped = "so `bootstrapDeclaredSharingRules` SKIPS the rule at boot: it is never written to `sys_sharing_rule`, no `sys_record_share` grant is ever materialised, and the only signal is one WARN line in the boot log. The rule is declared and grants nothing (ADR-0049: an unlowerable condition is never seeded as a permissive match-all).";
6784
+ if (result.reason === "unresolved-variable") {
6785
+ findings.push({
6786
+ severity: "error",
6787
+ rule: SHARING_RULE_RUNTIME_VARIABLE_CONDITION,
6788
+ where,
6789
+ path,
6790
+ message: `Sharing-rule condition \`${source}\` reads a runtime variable (${result.detail}), ` + skipped,
6791
+ hint: "A criteria sharing rule is MATERIALISED: the seeder compiles ONE static `criteria_json` per rule and the evaluator writes `sys_record_share` rows from it, so there is no \"current user\" for the condition to read. Express per-user access with the mechanism that runs per request instead \u2014 an RLS policy on a permission set (`rowLevelSecurity[].using`, where `current_user.*` IS resolved), or the record-ownership path. Keep this rule for the part of the predicate that is a property of the RECORD (e.g. `record.stage == 'closed_won'`) and name the audience through `sharedWith`."
6792
+ });
6793
+ return;
6794
+ }
6795
+ findings.push({
6796
+ severity: "error",
6797
+ rule: SHARING_RULE_UNLOWERABLE_CONDITION,
6798
+ where,
6799
+ path,
6800
+ message: `Sharing-rule condition \`${source}\` is outside the pushdown subset the runtime can compile (${result.detail}), ` + skipped,
6801
+ hint: "Rewrite the predicate inside the lowerable subset. " + PUSHDOWN_SUBSET + " Two traps in particular: (1) `has(record.x)` is correct in an object VALIDATION rule, which is INTERPRETED, and wrong here, where the condition is COMPILED \u2014 write the null test as `record.x != null`; (2) a related-record path (`record.account.region`) is a join, which the compiler refuses by design (ADR-0055) \u2014 denormalise the value onto this object (a formula/rollup field) and test that column, or share the related object instead."
6802
+ });
6803
+ });
6804
+ return findings;
6805
+ }
6806
+
6807
+ // src/validate-rls-predicate-enforceability.ts
6808
+ import { isPushdownableCel, isSupportedRlsExpression, sqlPredicateToCel } from "@objectstack/formula";
6809
+ var RLS_PREDICATE_UNENFORCEABLE = "rls-predicate-unenforceable";
6810
+ var RLS_PREDICATE_UNPARSEABLE = "rls-predicate-unparseable";
6811
+ function asArray39(v) {
6812
+ if (Array.isArray(v)) return v;
6813
+ if (v && typeof v === "object") {
6814
+ return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6815
+ }
6816
+ return [];
6817
+ }
6818
+ function str3(v) {
6819
+ return typeof v === "string" ? v : "";
6820
+ }
6821
+ var PUSHDOWN_SUBSET2 = "The lowerable subset is: `==` `!=` `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and the string methods `startsWith` / `endsWith` / `contains` \u2014 over SINGLE-column field paths (ADR-0058 D2), compared against a literal or a `current_user.*` value.";
6822
+ function consequence(clause) {
6823
+ const dropped = 'so `RLSCompiler` DROPS the policy at request time (one WARN line \u2014 "has an uncompilable predicate \u2026 and was DROPPED (no enforcement)" \u2014 is the only signal, and nothing reports it at authoring time). ';
6824
+ return clause === "using" ? dropped + "When it is the only applicable policy for that object and operation, `compileFilter` returns the `RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause: every select / update / delete on the object matches ZERO rows. When other policies also apply, this one just vanishes from the OR and grants none of the access it appears to." : dropped + "On the ADR-0058 D4 write path that leaves the post-image `check` as the `RLS_DENY_FILTER` sentinel, which no record can satisfy: every insert / update the policy governs fails with `PermissionDeniedError`. The policy reads as a write rule and behaves as a blanket refusal.";
6825
+ }
6826
+ function validateRlsPredicateEnforceability(stack) {
6827
+ const findings = [];
6828
+ const cfg = stack ?? {};
6829
+ asArray39(cfg.permissions).forEach((ps, psIndex) => {
6830
+ asArray39(ps.rowLevelSecurity).forEach((policy, pIndex) => {
6831
+ for (const clause of ["using", "check"]) {
6832
+ const source = str3(policy[clause]);
6833
+ if (!source.trim()) continue;
6834
+ if (isSupportedRlsExpression(source)) continue;
6835
+ const why = isPushdownableCel(sqlPredicateToCel(source));
6836
+ const detail = why.ok ? "" : why.detail;
6837
+ const parseError = !why.ok && why.reason === "parse-error";
6838
+ const psName = str3(ps.name) || String(psIndex);
6839
+ const policyName = str3(policy.name) || String(pIndex);
6840
+ const object = str3(policy.object);
6841
+ const where = `permission set "${psName}" policy "${policyName}"` + (object ? ` on object "${object}"` : "");
6842
+ const path = `permissions[${psIndex}].rowLevelSecurity[${pIndex}].${clause}`;
6843
+ if (parseError) {
6844
+ findings.push({
6845
+ severity: "error",
6846
+ rule: RLS_PREDICATE_UNPARSEABLE,
6847
+ where,
6848
+ path,
6849
+ message: `RLS ${clause} \`${source}\` does not parse as CEL even after the legacy SQL bridge (\`=\` \u2192 \`==\`, \`IN\` \u2192 \`in\`) has been applied (${detail}), ` + consequence(clause),
6850
+ hint: "Author the predicate in canonical CEL (ADR-0058 D1). The bridge covers only the historic SQL subset \u2014 a bare `=` and `IN` \u2014 so everything else must already be CEL: combine with `&&` / `||` rather than SQL `AND` / `OR`, negate with `!`, and use `startsWith` / `endsWith` / `contains` rather than `LIKE`. A subquery has no CEL spelling at all: RLS cannot join (ADR-0055), so pre-resolve the set into a membership key the runtime exposes (`field in current_user.<key>`, ADR-0105 D11) or denormalise the value onto this object."
6851
+ });
6852
+ continue;
6853
+ }
6854
+ findings.push({
6855
+ severity: "error",
6856
+ rule: RLS_PREDICATE_UNENFORCEABLE,
6857
+ where,
6858
+ path,
6859
+ message: `RLS ${clause} \`${source}\` is outside the pushdown subset the runtime can compile (${detail}), ` + consequence(clause),
6860
+ hint: "Rewrite the predicate inside the lowerable subset. " + PUSHDOWN_SUBSET2 + " Three traps in particular: (1) a function call \u2014 `size(record.tags) > 0`, `has(record.x)` \u2014 is correct in an object VALIDATION rule, which is INTERPRETED, and wrong here, where the predicate is COMPILED to a filter; write the null test as `field != null`. (2) A related-record path (`record.account.region`) is a join, which the compiler refuses by design (ADR-0055) \u2014 denormalise the value onto this object (a formula/rollup field) and test that column. (3) Arithmetic on a column (`amount * 2 > 100`) never lowers \u2014 precompute it into a field, or compare the column against the literal directly."
6861
+ });
6862
+ }
6350
6863
  });
6351
6864
  });
6352
6865
  return findings;
6353
6866
  }
6354
6867
 
6868
+ // src/validate-rule-compilability.ts
6869
+ import { createRequire as createRequire4 } from "module";
6870
+ var VALIDATION_RULE_REGEX_UNCOMPILABLE = "validation-rule-regex-uncompilable";
6871
+ var VALIDATION_RULE_SCHEMA_UNCOMPILABLE = "validation-rule-json-schema-uncompilable";
6872
+ var RUNTIME_AJV_OPTIONS = { allErrors: true, strict: false };
6873
+ var isRec16 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
6874
+ function asArray40(v) {
6875
+ if (Array.isArray(v)) return v.filter(isRec16);
6876
+ if (isRec16(v)) {
6877
+ return Object.entries(v).filter(([, def]) => isRec16(def)).map(([name, def]) => ({ name, ...def }));
6878
+ }
6879
+ return [];
6880
+ }
6881
+ var cachedAjv = null;
6882
+ var cachedAddFormats = null;
6883
+ function loadAjv() {
6884
+ if (cachedAjv) return cachedAjv;
6885
+ const anchor = typeof import.meta !== "undefined" && import.meta.url ? import.meta.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
6886
+ let mod;
6887
+ try {
6888
+ mod = createRequire4(anchor)("ajv");
6889
+ } catch (err) {
6890
+ throw new Error(
6891
+ `@objectstack/lint: checking a \`json_schema\` validation rule requires the "ajv" package, which could not be loaded (${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint \u2014 if this deployment prunes packages, keep "ajv" in the image; it is only loaded when a stack declares a \`json_schema\` validation rule.`
6892
+ );
6893
+ }
6894
+ const ctor = isRec16(mod) && "default" in mod ? mod.default : mod;
6895
+ cachedAjv = ctor;
6896
+ return ctor;
6897
+ }
6898
+ function loadAddFormats() {
6899
+ if (cachedAddFormats) return cachedAddFormats;
6900
+ const anchor = typeof import.meta !== "undefined" && import.meta.url ? import.meta.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
6901
+ let mod;
6902
+ try {
6903
+ mod = createRequire4(anchor)("ajv-formats");
6904
+ } catch (err) {
6905
+ throw new Error(
6906
+ `@objectstack/lint: checking a \`json_schema\` validation rule requires the "ajv-formats" package, which could not be loaded (${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint \u2014 if this deployment prunes packages, keep "ajv-formats" in the image; it is only loaded when a stack declares a \`json_schema\` validation rule. The runtime registers it too, and this gate must compile in the SAME environment or it starts disagreeing with the write path.`
6907
+ );
6908
+ }
6909
+ const plugin = isRec16(mod) && "default" in mod ? mod.default : mod;
6910
+ cachedAddFormats = plugin;
6911
+ return plugin;
6912
+ }
6913
+ function createRuntimeAjv() {
6914
+ const instance = new (loadAjv())(RUNTIME_AJV_OPTIONS);
6915
+ loadAddFormats()(instance);
6916
+ return instance;
6917
+ }
6918
+ function registeredFormatNames() {
6919
+ const names = Object.keys(createRuntimeAjv().formats).sort();
6920
+ if (names.length === 0) {
6921
+ throw new Error(
6922
+ `@objectstack/lint: the runtime-parity ajv instance has no \`format\` registered. "ajv-formats" loaded but added nothing, so the set of legitimate format names is unknown \u2014 refusing to judge format names against an empty vocabulary, which would report every \`format\` in the stack as misspelled. Check that the installed "ajv-formats" is the real package and matches the version @objectstack/lint declares.`
6923
+ );
6924
+ }
6925
+ return names;
6926
+ }
6927
+ function errorText(err) {
6928
+ return err instanceof Error ? err.message : String(err);
6929
+ }
6930
+ var MAX_RULE_NESTING_DEPTH = 16;
6931
+ function flattenRules(rule, labelTrail, pathTrail, depth = 0) {
6932
+ const name = typeof rule.name === "string" && rule.name ? rule.name : "?";
6933
+ const label2 = labelTrail ? `${labelTrail} \u2192 '${name}'` : `'${name}'`;
6934
+ const path = pathTrail ? `${pathTrail}.${name}` : name;
6935
+ const out = [{ rule, label: label2, path }];
6936
+ if (depth >= MAX_RULE_NESTING_DEPTH) return out;
6937
+ for (const branch of ["then", "otherwise"]) {
6938
+ const nested = rule[branch];
6939
+ if (isRec16(nested)) out.push(...flattenRules(nested, label2, `${path}.${branch}`, depth + 1));
6940
+ }
6941
+ return out;
6942
+ }
6943
+ function walkObjectValidationRules(stack) {
6944
+ const walked = [];
6945
+ if (!isRec16(stack)) return walked;
6946
+ for (const obj of asArray40(stack.objects)) {
6947
+ const objectName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
6948
+ const validations = obj.validations;
6949
+ for (const authored of asArray40(validations)) {
6950
+ for (const { rule, label: label2, path } of flattenRules(authored, "", "")) {
6951
+ walked.push({
6952
+ rule,
6953
+ objectName,
6954
+ label: label2,
6955
+ where: `object '${objectName}' \xB7 validation ${label2}`,
6956
+ basePath: `objects.${objectName}.validations.${path}`
6957
+ });
6958
+ }
6959
+ }
6960
+ }
6961
+ return walked;
6962
+ }
6963
+ function validateRuleCompilability(stack) {
6964
+ const findings = [];
6965
+ for (const { rule, objectName, label: label2, where, basePath } of walkObjectValidationRules(stack)) {
6966
+ if (rule.type === "format" && typeof rule.regex === "string" && rule.regex !== "") {
6967
+ try {
6968
+ new RegExp(rule.regex);
6969
+ } catch (err) {
6970
+ findings.push({
6971
+ severity: "error",
6972
+ rule: VALIDATION_RULE_REGEX_UNCOMPILABLE,
6973
+ where,
6974
+ path: `${basePath}.regex`,
6975
+ message: `\`format\` validation ${label2} on object '${objectName}' declares a \`regex\` that does not compile: ${errorText(err)}. The write path builds it with \`new RegExp(rule.regex)\` and SKIPS the rule when that throws (rule-validator.ts \`checkFormat\`), so the rule is declared, listed in the metadata, and enforces nothing on any record.`,
6976
+ hint: `Fix the pattern so \`new RegExp('${rule.regex}')\` compiles \u2014 a literal \`(\`, \`[\` or \`\\\` must be escaped (\`\\\\(\`, \`\\\\[\`, \`\\\\\\\\\`), and the source is a STRING, so a backslash is written twice in TypeScript ('^\\\\d{2}-\\\\d{7}$'). Or drop \`regex\` and use a named \`format\` ('email' | 'url' | 'phone' | 'json').`
6977
+ });
6978
+ }
6979
+ }
6980
+ if (rule.type === "json_schema" && isRec16(rule.schema)) {
6981
+ try {
6982
+ createRuntimeAjv().compile(rule.schema);
6983
+ } catch (err) {
6984
+ findings.push({
6985
+ severity: "error",
6986
+ rule: VALIDATION_RULE_SCHEMA_UNCOMPILABLE,
6987
+ where,
6988
+ path: `${basePath}.schema`,
6989
+ message: `\`json_schema\` validation ${label2} on object '${objectName}' declares a \`schema\` ajv cannot compile: ${errorText(err)}. The write path compiles it with the same ajv (\`new Ajv({ allErrors: true, strict: false })\` + \`ajv-formats\`) and SKIPS the rule when that throws (rule-validator.ts \`checkJsonSchema\`), so the rule is declared and enforces nothing on any record.`,
6990
+ hint: `Correct the schema so ajv compiles it \u2014 the message above names the offending keyword. \`type\` must be one of null|boolean|object|array|number|string|integer (or an array of those), \`required\` an array of strings, and every \`$ref\` must resolve. Vendor keywords are fine (the runtime runs \`strict: false\`); a MALFORMED standard keyword is not.`
6991
+ });
6992
+ }
6993
+ }
6994
+ }
6995
+ return findings;
6996
+ }
6997
+
6998
+ // src/validate-rule-schema-formats.ts
6999
+ var VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT = "validation-rule-json-schema-unknown-format";
7000
+ var isRec17 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
7001
+ var SUBSCHEMA_KEYS = [
7002
+ "additionalItems",
7003
+ "additionalProperties",
7004
+ "contains",
7005
+ "propertyNames",
7006
+ "if",
7007
+ "then",
7008
+ "else",
7009
+ "not",
7010
+ "unevaluatedItems",
7011
+ "unevaluatedProperties"
7012
+ ];
7013
+ var SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
7014
+ var SUBSCHEMA_MAP_KEYS = [
7015
+ "properties",
7016
+ "patternProperties",
7017
+ "$defs",
7018
+ "definitions",
7019
+ "dependentSchemas"
7020
+ ];
7021
+ var MAX_SCHEMA_WALK_DEPTH = 32;
7022
+ var escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
7023
+ function collectFormatUses(schema, pointer, out, depth) {
7024
+ if (!isRec17(schema)) return;
7025
+ if (typeof schema.format === "string") {
7026
+ out.push({ pointer: `${pointer}/format`, name: schema.format });
7027
+ }
7028
+ if (depth >= MAX_SCHEMA_WALK_DEPTH) return;
7029
+ for (const key of SUBSCHEMA_KEYS) {
7030
+ if (key in schema) {
7031
+ collectFormatUses(schema[key], `${pointer}/${escapePointerSegment(key)}`, out, depth + 1);
7032
+ }
7033
+ }
7034
+ for (const key of SUBSCHEMA_LIST_KEYS) {
7035
+ const value = schema[key];
7036
+ if (!Array.isArray(value)) continue;
7037
+ value.forEach((entry, index) => {
7038
+ collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${index}`, out, depth + 1);
7039
+ });
7040
+ }
7041
+ for (const key of SUBSCHEMA_MAP_KEYS) {
7042
+ const value = schema[key];
7043
+ if (!isRec17(value)) continue;
7044
+ for (const [name, entry] of Object.entries(value)) {
7045
+ collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${escapePointerSegment(name)}`, out, depth + 1);
7046
+ }
7047
+ }
7048
+ const items = schema.items;
7049
+ if (Array.isArray(items)) {
7050
+ items.forEach((entry, index) => collectFormatUses(entry, `${pointer}/items/${index}`, out, depth + 1));
7051
+ } else if (isRec17(items)) {
7052
+ collectFormatUses(items, `${pointer}/items`, out, depth + 1);
7053
+ }
7054
+ const dependencies = schema.dependencies;
7055
+ if (isRec17(dependencies)) {
7056
+ for (const [name, entry] of Object.entries(dependencies)) {
7057
+ if (!isRec17(entry)) continue;
7058
+ collectFormatUses(entry, `${pointer}/dependencies/${escapePointerSegment(name)}`, out, depth + 1);
7059
+ }
7060
+ }
7061
+ }
7062
+ function editDistance2(a, b) {
7063
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
7064
+ for (let i = 1; i <= a.length; i++) {
7065
+ const current = [i];
7066
+ for (let j = 1; j <= b.length; j++) {
7067
+ current[j] = Math.min(
7068
+ previous[j] + 1,
7069
+ current[j - 1] + 1,
7070
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
7071
+ );
7072
+ }
7073
+ previous = current;
7074
+ }
7075
+ return previous[b.length];
7076
+ }
7077
+ function nearestRegisteredFormat(name, registered) {
7078
+ const budget = Math.min(3, Math.floor(name.length / 2));
7079
+ if (budget < 1) return null;
7080
+ const authored = name.toLowerCase();
7081
+ let best = null;
7082
+ let bestDistance = Number.POSITIVE_INFINITY;
7083
+ for (const candidate of [...registered].sort()) {
7084
+ const distance7 = editDistance2(authored, candidate);
7085
+ if (distance7 < bestDistance) {
7086
+ bestDistance = distance7;
7087
+ best = candidate;
7088
+ }
7089
+ }
7090
+ return bestDistance <= budget ? best : null;
7091
+ }
7092
+ function validateRuleSchemaFormats(stack) {
7093
+ const findings = [];
7094
+ const pending = [];
7095
+ for (const { rule, objectName, label: label2, where, basePath } of walkObjectValidationRules(stack)) {
7096
+ if (rule.type !== "json_schema" || !isRec17(rule.schema)) continue;
7097
+ const uses = [];
7098
+ collectFormatUses(rule.schema, "", uses, 0);
7099
+ for (const use of uses) pending.push({ use, where, label: label2, objectName, basePath });
7100
+ }
7101
+ if (pending.length === 0) return findings;
7102
+ const registered = registeredFormatNames();
7103
+ const known = new Set(registered);
7104
+ for (const { use, where, label: label2, objectName, basePath } of pending) {
7105
+ if (known.has(use.name)) continue;
7106
+ const suggestion = nearestRegisteredFormat(use.name, registered);
7107
+ const pointer = `#${use.pointer}`;
7108
+ findings.push({
7109
+ severity: "error",
7110
+ rule: VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT,
7111
+ where,
7112
+ path: `${basePath}.schema${pointer}`,
7113
+ message: `\`json_schema\` validation ${label2} on object '${objectName}' names \`format: '${use.name}'\` at \`${pointer}\`, which is not a registered format. ajv logs \`unknown format "${use.name}" ignored\` once at compile time and DROPS the keyword \u2014 in the write path (rule-validator.ts, \`strict: false\`) and in the publish gate alike \u2014 so the schema compiles, the rule ships and runs on every write, its \`type\`/\`required\` keywords are enforced, and this constraint is enforced on no record, ever. The record is ACCEPTED, so nothing downstream reports the gap either.`,
7114
+ hint: (suggestion ? `Did you mean \`format: '${suggestion}'\`? ` : "") + `The registered names are: ${registered.join(", ")} \u2014 the default \`ajv-formats\` set, the one \`rule-validator.ts\` registers (#5029). Names are case-sensitive and hyphenated (\`date-time\`, not \`datetime\`). If you meant a constraint ajv has no format for, express it with \`pattern\` instead \u2014 a regex is enforced, an unknown format name is not.`
7115
+ });
7116
+ }
7117
+ return findings;
7118
+ }
7119
+
6355
7120
  // src/validate-action-locations.ts
6356
7121
  var ACTION_NO_PLACEMENT = "action-no-placement";
6357
- function asArray37(v) {
7122
+ function asArray41(v) {
6358
7123
  if (Array.isArray(v)) return v;
6359
7124
  if (v && typeof v === "object") {
6360
7125
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6361
7126
  }
6362
7127
  return [];
6363
7128
  }
6364
- function strName15(v) {
7129
+ function strName17(v) {
6365
7130
  return typeof v === "string" && v.length > 0 ? v : void 0;
6366
7131
  }
6367
7132
  function strList3(v) {
@@ -6375,8 +7140,8 @@ function collectNamePlacedActions(stack) {
6375
7140
  for (const key of ["rowActions", "bulkActions"]) {
6376
7141
  for (const n of strList3(list3[key])) placed.add(n);
6377
7142
  }
6378
- for (const def of asArray37(list3.bulkActionDefs)) {
6379
- const n = strName15(def?.name);
7143
+ for (const def of asArray41(list3.bulkActionDefs)) {
7144
+ const n = strName17(def?.name);
6380
7145
  if (n) placed.add(n);
6381
7146
  }
6382
7147
  };
@@ -6384,12 +7149,12 @@ function collectNamePlacedActions(stack) {
6384
7149
  if (!listViews || typeof listViews !== "object" || Array.isArray(listViews)) return;
6385
7150
  for (const lv of Object.values(listViews)) harvest(lv);
6386
7151
  };
6387
- for (const view of asArray37(stack.views)) {
7152
+ for (const view of asArray41(stack.views)) {
6388
7153
  if (!view || typeof view !== "object") continue;
6389
7154
  harvest(view.list);
6390
7155
  harvestListViews(view.listViews);
6391
7156
  }
6392
- for (const obj of asArray37(stack.objects)) {
7157
+ for (const obj of asArray41(stack.objects)) {
6393
7158
  if (!obj || typeof obj !== "object") continue;
6394
7159
  harvestListViews(obj.listViews);
6395
7160
  }
@@ -6402,7 +7167,7 @@ function validateActionLocations(stack) {
6402
7167
  const check = (action, path) => {
6403
7168
  if (!action || typeof action !== "object") return;
6404
7169
  if ("locations" in action) return;
6405
- const name = strName15(action.name);
7170
+ const name = strName17(action.name);
6406
7171
  if (!name) return;
6407
7172
  if (namePlaced.has(name)) return;
6408
7173
  findings.push({
@@ -6414,20 +7179,25 @@ function validateActionLocations(stack) {
6414
7179
  hint: "Add the surface it belongs on, e.g. `locations: ['record_header']` (or `list_item`, `list_toolbar`, `record_more`, `record_section`, `record_related`, `global_nav`); or place it from a list view's `bulkActions` / `bulkActionDefs` if it acts on a selection. If it is meant to be callable over REST / MCP / AI with no UI surface, say so explicitly with `locations: []` \u2014 an empty array is the documented headless shape and is never flagged."
6415
7180
  });
6416
7181
  };
6417
- const actions = asArray37(stack.actions);
7182
+ const actions = asArray41(stack.actions);
6418
7183
  for (let i = 0; i < actions.length; i++) check(actions[i], `actions[${i}]`);
6419
- const objects = asArray37(stack.objects);
7184
+ const objects = asArray41(stack.objects);
6420
7185
  for (let oi = 0; oi < objects.length; oi++) {
6421
7186
  const obj = objects[oi];
6422
7187
  if (!obj || typeof obj !== "object") continue;
6423
- const own = asArray37(obj.actions);
7188
+ const own = asArray41(obj.actions);
6424
7189
  for (let ai = 0; ai < own.length; ai++) check(own[ai], `objects[${oi}].actions[${ai}]`);
6425
7190
  }
6426
7191
  return findings;
6427
7192
  }
6428
7193
 
6429
7194
  // src/lint-flow-patterns.ts
6430
- function asArray38(v) {
7195
+ import {
7196
+ APPROVAL_NODE_TYPE as APPROVAL_NODE_TYPE2,
7197
+ APPROVAL_REVISE_NODE_TYPE,
7198
+ collectFlowGraphs as collectFlowGraphs2
7199
+ } from "@objectstack/spec/automation";
7200
+ function asArray42(v) {
6431
7201
  if (Array.isArray(v)) return v;
6432
7202
  if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6433
7203
  return [];
@@ -6450,6 +7220,7 @@ var FLOW_BARE_DOLLAR_REF = "flow-bare-dollar-reference";
6450
7220
  var FLOW_APPROVAL_REVISE_DEAD_END = "flow-approval-revise-dead-end";
6451
7221
  var FLOW_APPROVAL_REVISE_UNMARKED_BACKEDGE = "flow-approval-revise-unmarked-backedge";
6452
7222
  var FLOW_APPROVAL_REVISE_DISABLED = "flow-approval-revise-disabled";
7223
+ var FLOW_APPROVAL_REVISE_TARGET_NOT_SERVICE_OWNED = "flow-approval-revise-target-not-service-owned";
6453
7224
  var FLOW_RUNAS_UNSCOPED = "flow-runas-unscoped";
6454
7225
  var FLOW_ERROR_LABEL_NOT_FAULT = "flow-error-label-not-fault";
6455
7226
  var FLOW_BRANCH_LABEL_UNMATCHED = "flow-branch-label-unmatched";
@@ -6457,6 +7228,7 @@ var FLOW_DECISION_UNCONDITIONAL_BRANCH = "flow-decision-unconditional-branch";
6457
7228
  var FLOW_DEFAULT_EDGE_WITH_CONDITION = "flow-default-edge-with-condition";
6458
7229
  var FLOW_MULTIPLE_DEFAULT_EDGES = "flow-multiple-default-edges";
6459
7230
  var FLOW_INERT_NODE_CONDITION = "flow-inert-node-condition";
7231
+ var FLOW_MULTI_WRITE_UNFILTERED = "flow-multi-write-unfiltered";
6460
7232
  var INERT_CONDITION_NODE_TYPES = /* @__PURE__ */ new Set([
6461
7233
  "decision",
6462
7234
  "assignment",
@@ -6479,6 +7251,36 @@ var INERT_CONDITION_NODE_TYPES = /* @__PURE__ */ new Set([
6479
7251
  "end"
6480
7252
  ]);
6481
7253
  var DATA_NODE_TYPES = /* @__PURE__ */ new Set(["get_record", "create_record", "update_record", "delete_record"]);
7254
+ var RUNAS_EFFECTIVE_IDENTITY = "`runAs:'user'` (the default when none is declared)";
7255
+ function findDataNodeAnywhere(nodes, edges) {
7256
+ for (const graph of collectFlowGraphs2({
7257
+ nodes,
7258
+ edges
7259
+ })) {
7260
+ for (const node of graph.nodes) {
7261
+ if (DATA_NODE_TYPES.has(typeof node.type === "string" ? node.type : "")) {
7262
+ return { node, scope: graph.scope };
7263
+ }
7264
+ }
7265
+ }
7266
+ return null;
7267
+ }
7268
+ var BULK_WRITE_CONSEQUENCE = /* @__PURE__ */ new Map([
7269
+ ["delete_record", {
7270
+ verb: "deleted",
7271
+ engineCall: "driver.deleteMany",
7272
+ // The delete dispatch is the one that is EXTRACTED and case-set-pinned
7273
+ // (`engine-delete-dispatch.ts`), so it can be cited by name.
7274
+ dispatchNote: "the engine's delete-dispatch case-set lists `multi with no predicate at all` as a legal `multi` call"
7275
+ }],
7276
+ ["update_record", {
7277
+ verb: "overwritten",
7278
+ engineCall: "driver.updateMany",
7279
+ // Update has no extracted dispatch module, so the branch itself is the
7280
+ // authority — and its refusal fires only WITHOUT the declaration.
7281
+ dispatchNote: "the engine takes its bulk branch on `options.multi` alone (`Update requires an ID or options.multi=true` is refused only when the declaration is absent)"
7282
+ }]
7283
+ ]);
6482
7284
  var ERROR_LABELS = /* @__PURE__ */ new Set(["error", "fault", "failure", "failed", "catch", "on_error", "onerror", "on error"]);
6483
7285
  var BRANCH_LABEL_NODE_TYPES = /* @__PURE__ */ new Set(["decision", "approval", "screen", "try_catch"]);
6484
7286
  function isScheduleTriggered(flow, startCfg) {
@@ -6563,7 +7365,7 @@ function collectTemplateStrings(value, key, out) {
6563
7365
  function edgeLabelOf(e) {
6564
7366
  return typeof e.label === "string" ? e.label.trim().toLowerCase() : "";
6565
7367
  }
6566
- function scanErrorLabelledEdges(flowName, nodes, edges, findings) {
7368
+ function scanErrorLabelledEdges(at, nodes, edges, findings) {
6567
7369
  const typeById = /* @__PURE__ */ new Map();
6568
7370
  for (const n of nodes) {
6569
7371
  if (typeof n.id === "string") typeById.set(n.id, typeof n.type === "string" ? n.type : "");
@@ -6576,14 +7378,14 @@ function scanErrorLabelledEdges(flowName, nodes, edges, findings) {
6576
7378
  const src = typeof e.source === "string" ? e.source : "";
6577
7379
  if (BRANCH_LABEL_NODE_TYPES.has(typeById.get(src) ?? "")) continue;
6578
7380
  findings.push({
6579
- where: `flow '${flowName}' \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
7381
+ where: `${at} \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
6580
7382
  message: `edge is labelled '${String(e.label)}' but its type is '${String(e.type ?? "default")}', not 'fault' \u2014 so it is an ORDINARY out-edge. Unconditional out-edges all run in parallel, so '${String(e.target)}' executes on every SUCCESSFUL run of '${src}' and never on a failure. The error path the label describes does not exist, and the run still aborts when '${src}' fails.`,
6581
7383
  hint: `Add \`type: 'fault'\` to this edge. Only runtime failures route \u2014 a guard refusal (a filter token that resolved to nothing, a missing required config key, an unscoped run) stays fatal by design and must be fixed in the metadata, not handled. (#3863)`,
6582
7384
  rule: FLOW_ERROR_LABEL_NOT_FAULT
6583
7385
  });
6584
7386
  }
6585
7387
  }
6586
- function scanBranchRouting(flowName, nodes, edges, findings) {
7388
+ function scanBranchRouting(at, nodes, edges, findings) {
6587
7389
  const outEdgesBySource = /* @__PURE__ */ new Map();
6588
7390
  for (const e of edges) {
6589
7391
  if (e.type === "fault") continue;
@@ -6596,7 +7398,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6596
7398
  for (const e of outs) {
6597
7399
  if (e.isDefault === true && e.condition) {
6598
7400
  findings.push({
6599
- where: `flow '${flowName}' \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
7401
+ where: `${at} \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
6600
7402
  message: `edge sets \`isDefault: true\` AND a \`condition\` \u2014 contradictory. \`isDefault\` means "take this edge when NO sibling condition matched"; a condition makes it an ordinary guarded branch. The condition wins and the default marker routes nothing.`,
6601
7403
  hint: `Drop one: keep \`condition\` for a guarded branch, or drop it and keep \`isDefault: true\` for the "otherwise" path. (#4414)`,
6602
7404
  rule: FLOW_DEFAULT_EDGE_WITH_CONDITION,
@@ -6609,7 +7411,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6609
7411
  const defaults = outs.filter((e) => e.isDefault === true && !e.condition);
6610
7412
  if (defaults.length > 1) {
6611
7413
  findings.push({
6612
- where: `flow '${flowName}' \xB7 node '${src}'`,
7414
+ where: `${at} \xB7 node '${src}'`,
6613
7415
  message: `${defaults.length} out-edges are marked \`isDefault: true\` (${defaults.map((e) => `'${String(e.target)}'`).join(", ")}) \u2014 a node has at most ONE default path. All of them are traversed together when no condition matches, which is a parallel fan-out, not an "otherwise".`,
6614
7416
  hint: `Keep \`isDefault: true\` on the single fallback edge and give the others a \`condition\` (or leave them unconditional if the fan-out really is intended). (#4414)`,
6615
7417
  rule: FLOW_MULTIPLE_DEFAULT_EDGES
@@ -6622,7 +7424,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6622
7424
  const cfg = node.config ?? {};
6623
7425
  if (cfg.condition == null || conditionSource(cfg.condition).trim() === "") continue;
6624
7426
  findings.push({
6625
- where: `flow '${flowName}' \xB7 node '${String(node.id)}' (${nodeType})`,
7427
+ where: `${at} \xB7 node '${String(node.id)}' (${nodeType})`,
6626
7428
  message: `\`config.condition\` is set but nothing reads it \u2014 the key is the trigger gate on a \`start\` node and is ignored on every other node type, so this predicate never gates anything. (It is still parse-validated at registration, which is why a malformed one is caught and an inert one is not.)`,
6627
7429
  hint: nodeType === "decision" ? `Branching lives on the OUT-EDGES: give each branch its own \`condition\` and mark the fallback \`isDefault: true\`. If the edges already carry the predicate, delete this copy. (#4414)` : `Delete it, or move the predicate to the incoming edge's \`condition\` if this step was meant to be conditional. (#4414)`,
6628
7430
  rule: FLOW_INERT_NODE_CONDITION
@@ -6642,7 +7444,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6642
7444
  const unclaimed = [...declaredLabels].filter((l) => !edgeLabels.has(l));
6643
7445
  if (unclaimed.length > 0) {
6644
7446
  findings.push({
6645
- where: `flow '${flowName}' \xB7 decision '${nid}'`,
7447
+ where: `${at} \xB7 decision '${nid}'`,
6646
7448
  message: `declares branch label(s) ${unclaimed.map((l) => `'${l}'`).join(", ")} that no out-edge carries \u2014 out-edge labels are [${[...edgeLabels].map((l) => `'${l}'`).join(", ") || "none"}]. Traversal cannot honour a label nothing claims, so it falls back to considering EVERY out-edge and the branch the decision computed is ignored.`,
6647
7449
  hint: `Make an out-edge's \`label\` match the declared branch exactly, or drop \`config.conditions\` and branch on the edges instead (\`condition\` per branch + \`isDefault: true\` on the fallback) \u2014 one mechanism per decision, never both. (#4414)`,
6648
7450
  rule: FLOW_BRANCH_LABEL_UNMATCHED,
@@ -6658,7 +7460,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6658
7460
  );
6659
7461
  if (ungated.length > 0) {
6660
7462
  findings.push({
6661
- where: `flow '${flowName}' \xB7 decision '${nid}'`,
7463
+ where: `${at} \xB7 decision '${nid}'`,
6662
7464
  message: `has guarded out-edge(s) alongside unconditional one(s) (${ungated.map((e) => `'${String(e.target)}'`).join(", ")}) \u2014 an unconditional out-edge is traversed on EVERY pass, in parallel with whichever guarded branch matched, so the decision does not actually exclude it. A \`label\` alone does not select a path unless the decision declares a matching \`conditions[].label\`.`,
6663
7465
  hint: `Mark the fallback \`isDefault: true\` so it is taken only when no sibling condition matched (BPMN default flow), or give it its own \`condition\`. (#4414)`,
6664
7466
  rule: FLOW_DECISION_UNCONDITIONAL_BRANCH
@@ -6666,10 +7468,39 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6666
7468
  }
6667
7469
  }
6668
7470
  }
6669
- function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
6670
- const approvals = nodes.filter((n) => n.type === "approval");
7471
+ function filterCarriesNoCondition(filter) {
7472
+ if (filter === void 0 || filter === null) return true;
7473
+ if (typeof filter !== "object" || Array.isArray(filter)) return false;
7474
+ return Object.keys(filter).length === 0;
7475
+ }
7476
+ function scanUnboundedBulkWrites(at, nodes, findings) {
7477
+ for (const node of nodes) {
7478
+ const nodeType = typeof node.type === "string" ? node.type : "";
7479
+ const consequence2 = BULK_WRITE_CONSEQUENCE.get(nodeType);
7480
+ if (!consequence2) continue;
7481
+ const cfg = node.config ?? {};
7482
+ if (cfg.multi !== true) continue;
7483
+ if (!filterCarriesNoCondition(cfg.filter)) continue;
7484
+ const objectName = typeof cfg.objectName === "string" && cfg.objectName ? cfg.objectName : "(unnamed object)";
7485
+ const filterState = cfg.filter === void 0 || cfg.filter === null ? "no `filter` key" : "an EMPTY `filter`";
7486
+ findings.push({
7487
+ where: `${at} \xB7 node '${String(node.id)}' (${nodeType})`,
7488
+ message: `declares \`multi: true\` with ${filterState} \u2014 this is a WHOLE-OBJECT write, by declaration: every row of '${objectName}' is ${consequence2.verb} on every run. The executor forwards \`where: {}\` plus the bulk intent, ${consequence2.dispatchNote}, and it lands on \`${consequence2.engineCall}\` with no predicate. Nothing refuses it at run time, so the only feedback is the step's \`acted\` row count \u2014 reported AFTER the rows are gone.`,
7489
+ hint: `Write the constraint you mean into \`filter\` (e.g. \`{ status: 'closed' }\` \u2014 see examples/app-showcase \`showcase_inquiry_purge\`, bulk intent bounded by a predicate). If emptying '${objectName}' really is the intent, keep it: this is a warning, not a gate, and the run-time path stays open. Distinct from the #3810 erased-condition guard, which REFUSES this node at run time when a condition you WROTE interpolated to nothing \u2014 that guard is keyed on "a written condition is gone" and deliberately not on "the filter is empty", which is the fact this rule judges at authoring time. (#5482, #5393)`,
7490
+ // Warning, not `error`: see the severity policy at the top of this file.
7491
+ // The shape has a legitimate reading the engine grants on purpose, so it is
7492
+ // not provably wrong — unlike the gating members of this family.
7493
+ rule: FLOW_MULTI_WRITE_UNFILTERED
7494
+ });
7495
+ }
7496
+ }
7497
+ function scanApprovalReviseLoops(at, nodes, edges, findings) {
7498
+ const approvals = nodes.filter((n) => n.type === APPROVAL_NODE_TYPE2);
6671
7499
  if (approvals.length === 0) return;
6672
7500
  const nodeIds = new Set(nodes.map((n) => typeof n.id === "string" ? n.id : "").filter(Boolean));
7501
+ const nodeTypeById = new Map(
7502
+ nodes.filter((n) => typeof n.id === "string").map((n) => [n.id, typeof n.type === "string" ? n.type : ""])
7503
+ );
6673
7504
  const outEdges = /* @__PURE__ */ new Map();
6674
7505
  for (const e of edges) {
6675
7506
  const src = typeof e.source === "string" ? e.source : "";
@@ -6682,7 +7513,18 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
6682
7513
  if (!aid) continue;
6683
7514
  const reviseTargets = edges.filter((e) => e.source === aid && edgeLabelOf(e) === "revise").map((e) => typeof e.target === "string" ? e.target : "").filter((t) => t && nodeIds.has(t));
6684
7515
  if (reviseTargets.length === 0) continue;
6685
- const where = `flow '${flowName}' \xB7 approval '${aid}'`;
7516
+ const where = `${at} \xB7 approval '${aid}'`;
7517
+ for (const target of reviseTargets) {
7518
+ const targetType = nodeTypeById.get(target) ?? "";
7519
+ if (targetType === APPROVAL_REVISE_NODE_TYPE) continue;
7520
+ findings.push({
7521
+ where,
7522
+ severity: "error",
7523
+ message: `has a 'revise' out-edge into node '${target}' of type '${targetType || "(untyped)"}' \u2014 the revise window must be an '${APPROVAL_REVISE_NODE_TYPE}' node. Send-back parks the run there while the record is unlocked, and only the approvals service may continue it (submitter-only, audited, and refusing a colliding pending request); \`sendBack\` refuses any other target, so this flow's revise branch cannot run.`,
7524
+ hint: `Set node '${target}' to \`type: '${APPROVAL_REVISE_NODE_TYPE}'\` (drop any \`waitEventConfig\` \u2014 the window is ended by POST /api/v1/approvals/requests/:id/resubmit, not by a signal). ADR-0044 D3 originally said 'wait' here; its 2026-07-28 amendment reversed that, because a 'wait' is resumable by anyone with the run id (#3823, #3801).`,
7525
+ rule: FLOW_APPROVAL_REVISE_TARGET_NOT_SERVICE_OWNED
7526
+ });
7527
+ }
6686
7528
  const cfg = a.config ?? {};
6687
7529
  if (cfg.maxRevisions === 0) {
6688
7530
  findings.push({
@@ -6710,7 +7552,7 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
6710
7552
  findings.push({
6711
7553
  where,
6712
7554
  message: `has a 'revise' out-edge but no path loops back to it \u2014 the submitter reworks the record with nowhere to resubmit, so the revise branch dead-ends. (registerFlow accepts this \u2014 it's a valid DAG.)`,
6713
- hint: `Close the loop: the 'revise' edge should reach a wait node whose resubmit edge returns to '${aid}' marked \`type: 'back'\` (ADR-0044). See examples/app-showcase showcase_budget_approval.`,
7555
+ hint: `Close the loop: the 'revise' edge should reach an '${APPROVAL_REVISE_NODE_TYPE}' node whose resubmit edge returns to '${aid}' marked \`type: 'back'\` (ADR-0044). See examples/app-showcase showcase_budget_approval.`,
6714
7556
  rule: FLOW_APPROVAL_REVISE_DEAD_END
6715
7557
  });
6716
7558
  } else if (!returnEdges.some((e) => e.type === "back")) {
@@ -6725,7 +7567,7 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
6725
7567
  }
6726
7568
  function lintFlowPatterns(stack) {
6727
7569
  const findings = [];
6728
- for (const flow of asArray38(stack.flows)) {
7570
+ for (const flow of asArray42(stack.flows)) {
6729
7571
  const flowName = typeof flow.name === "string" ? flow.name : "(unnamed flow)";
6730
7572
  const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
6731
7573
  const edges = Array.isArray(flow.edges) ? flow.edges : [];
@@ -6746,74 +7588,90 @@ function lintFlowPatterns(stack) {
6746
7588
  const runAs = typeof flow.runAs === "string" ? flow.runAs : "user";
6747
7589
  const userLessKind = userLessTriggerKind(flow, startCfg);
6748
7590
  if (userLessKind && runAs !== "system") {
6749
- const dataNode = nodes.find((n) => DATA_NODE_TYPES.has(typeof n.type === "string" ? n.type : ""));
7591
+ const dataNode = findDataNodeAnywhere(nodes, edges);
6750
7592
  if (dataNode) {
6751
- const declared = typeof flow.runAs === "string" ? `\`runAs:'${runAs}'\`` : `the default \`runAs:'user'\``;
7593
+ const at = dataNode.scope ? `, in ${dataNode.scope},` : "";
6752
7594
  findings.push({
6753
7595
  where: `flow '${flowName}' \xB7 runAs`,
6754
- message: `${userLessKind}-triggered flow runs as ${declared}, but a ${userLessKind} run has no trigger user \u2014 so its data node '${dataNode.id}' (${dataNode.type}) has no identity to scope to and will be REFUSED at run time.`,
7596
+ message: `${userLessKind}-triggered flow runs under ${RUNAS_EFFECTIVE_IDENTITY}, but a ${userLessKind} run has no trigger user \u2014 so its data node '${dataNode.node.id}' (${dataNode.node.type})${at} has no identity to scope to and will be REFUSED at run time.`,
6755
7597
  hint: `Declare \`runAs:'system'\` to make the elevation explicit and intended (the run reads/writes every record). A ${userLessKind} flow cannot scope to a user \u2014 there is none. (ADR-0049, ADR-0073 D5, #1888, #3760)`,
6756
7598
  rule: FLOW_RUNAS_UNSCOPED,
6757
7599
  severity: "error"
6758
7600
  });
6759
7601
  }
6760
7602
  }
6761
- for (const node of nodes) {
6762
- const nodeWhere = `flow '${flowName}' \xB7 node '${node.id}' (${node.type})`;
6763
- const cfg = node.config ?? {};
6764
- if (cfg.filter) scanFilterForDateEquality(cfg.filter, `${nodeWhere} filter`, findings);
6765
- for (const key of Object.keys(cfg)) {
6766
- if (PHANTOM_AGG_KEYS.has(key)) {
6767
- findings.push({
6768
- where: nodeWhere,
6769
- message: `node config has \`${key}\` \u2014 the automation engine has no aggregate node, so \`${key}\` is silently ignored and this node computes nothing at runtime.`,
6770
- hint: `Aggregation belongs in the data layer: use \`Field.summary\` for a cross-object rollup (sum/count of children), or \`Field.formula\` for a per-record computed value. (#1870)`,
6771
- rule: FLOW_PHANTOM_AGGREGATION
6772
- });
6773
- }
6774
- }
6775
- const strings = [];
6776
- collectTemplateStrings(node.config, void 0, strings);
6777
- for (const str2 of strings) {
6778
- if (DOUBLE_BRACE.test(str2)) {
6779
- findings.push({
6780
- where: nodeWhere,
6781
- message: `double-brace interpolation \`${str2.trim().slice(0, 80)}\` \u2014 flow node values use SINGLE braces.`,
6782
- hint: `Use \`{var}\` (e.g. \`{record.title}\`). Double-brace \`{{ }}\` is the formula/template-field dialect, not flow node values. (#1315)`,
6783
- rule: FLOW_DOUBLE_BRACE_INTERP
6784
- });
7603
+ for (const graph of collectFlowGraphs2({
7604
+ // A cast, not a parse. `FlowNodeSchema.config` is an open `z.record`, so a
7605
+ // region's contents arrive as raw authored records even in a parsed stack —
7606
+ // a nested edge `condition` may still be a bare string where a top-level
7607
+ // one is an Expression envelope. Every rule below reads both
7608
+ // (`conditionSource`), and the walk itself only touches `type` / `config`.
7609
+ // The already-guarded arrays are passed rather than `flow` itself so a
7610
+ // non-array `nodes` still cannot throw: this function promises it never does.
7611
+ nodes,
7612
+ edges
7613
+ })) {
7614
+ const at = graph.scope ? `flow '${flowName}' \xB7 ${graph.scope}` : `flow '${flowName}'`;
7615
+ const graphNodes = graph.nodes;
7616
+ const graphEdges = graph.edges;
7617
+ for (const node of graphNodes) {
7618
+ const nodeWhere = `${at} \xB7 node '${node.id}' (${node.type})`;
7619
+ const cfg = node.config ?? {};
7620
+ if (cfg.filter) scanFilterForDateEquality(cfg.filter, `${nodeWhere} filter`, findings);
7621
+ for (const key of Object.keys(cfg)) {
7622
+ if (PHANTOM_AGG_KEYS.has(key)) {
7623
+ findings.push({
7624
+ where: nodeWhere,
7625
+ message: `node config has \`${key}\` \u2014 the automation engine has no aggregate node, so \`${key}\` is silently ignored and this node computes nothing at runtime.`,
7626
+ hint: `Aggregation belongs in the data layer: use \`Field.summary\` for a cross-object rollup (sum/count of children), or \`Field.formula\` for a per-record computed value. (#1870)`,
7627
+ rule: FLOW_PHANTOM_AGGREGATION
7628
+ });
7629
+ }
6785
7630
  }
6786
- if (BARE_DOLLAR_REF.test(str2)) {
6787
- findings.push({
6788
- where: nodeWhere,
6789
- message: `\`${str2.trim().slice(0, 80)}\` looks like a reference written as a literal \u2014 a bare \`$ref.field\` is NOT interpolated.`,
6790
- hint: `Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user). (#1315)`,
6791
- rule: FLOW_BARE_DOLLAR_REF
6792
- });
7631
+ const strings = [];
7632
+ collectTemplateStrings(stripRegions(node.config), void 0, strings);
7633
+ for (const str4 of strings) {
7634
+ if (DOUBLE_BRACE.test(str4)) {
7635
+ findings.push({
7636
+ where: nodeWhere,
7637
+ message: `double-brace interpolation \`${str4.trim().slice(0, 80)}\` \u2014 flow node values use SINGLE braces.`,
7638
+ hint: `Use \`{var}\` (e.g. \`{record.title}\`). Double-brace \`{{ }}\` is the formula/template-field dialect, not flow node values. (#1315)`,
7639
+ rule: FLOW_DOUBLE_BRACE_INTERP
7640
+ });
7641
+ }
7642
+ if (BARE_DOLLAR_REF.test(str4)) {
7643
+ findings.push({
7644
+ where: nodeWhere,
7645
+ message: `\`${str4.trim().slice(0, 80)}\` looks like a reference written as a literal \u2014 a bare \`$ref.field\` is NOT interpolated.`,
7646
+ hint: `Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user). (#1315)`,
7647
+ rule: FLOW_BARE_DOLLAR_REF
7648
+ });
7649
+ }
6793
7650
  }
6794
7651
  }
7652
+ scanApprovalReviseLoops(at, graphNodes, graphEdges, findings);
7653
+ scanErrorLabelledEdges(at, graphNodes, graphEdges, findings);
7654
+ scanBranchRouting(at, graphNodes, graphEdges, findings);
7655
+ scanUnboundedBulkWrites(at, graphNodes, findings);
6795
7656
  }
6796
- scanApprovalReviseLoops(flowName, nodes, edges, findings);
6797
- scanErrorLabelledEdges(flowName, nodes, edges, findings);
6798
- scanBranchRouting(flowName, nodes, edges, findings);
6799
7657
  }
6800
7658
  return findings;
6801
7659
  }
6802
7660
 
6803
7661
  // src/lint-liveness-properties.ts
6804
- import { createRequire as createRequire4 } from "module";
7662
+ import { createRequire as createRequire5 } from "module";
6805
7663
  import { dirname, join } from "path";
6806
7664
  import { existsSync, readFileSync } from "fs";
6807
7665
  var LIVENESS_DEAD_PROPERTY = "liveness-dead-property";
6808
7666
  var LIVENESS_EXPERIMENTAL_PROPERTY = "liveness-experimental-property";
6809
- function asArray39(v) {
7667
+ function asArray43(v) {
6810
7668
  if (Array.isArray(v)) return v;
6811
7669
  if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6812
7670
  return [];
6813
7671
  }
6814
7672
  function resolveLivenessDir() {
6815
7673
  try {
6816
- const require2 = createRequire4(import.meta.url);
7674
+ const require2 = createRequire5(import.meta.url);
6817
7675
  const pkgJson = require2.resolve("@objectstack/spec/package.json");
6818
7676
  const dir = join(dirname(pkgJson), "liveness");
6819
7677
  return existsSync(dir) ? dir : null;
@@ -6919,7 +7777,16 @@ var TYPE_COLLECTIONS = [
6919
7777
  { type: "job", key: "jobs" },
6920
7778
  { type: "email_template", key: "emailTemplates" },
6921
7779
  { type: "mapping", key: "mappings" },
6922
- { type: "translation", key: "translations" }
7780
+ { type: "translation", key: "translations" },
7781
+ // #4956 — dashboard joins the list the moment its ledger first warns on
7782
+ // anything, which is exactly the rule the comment above states. Drilling
7783
+ // `widgets` produced five warned keys (`colorVariant`, `actionUrl`,
7784
+ // `actionType`, `actionIcon`, `aria`), all under `widgets[]`; `getNested`
7785
+ // fans a dotted path out over an array level, so `widgets.colorVariant`
7786
+ // checks every widget on the dashboard. Registering it here is not optional
7787
+ // bookkeeping: without it the ledger would be newly correct and newly
7788
+ // silent, which is the shape this lint exists to prevent.
7789
+ { type: "dashboard", key: "dashboards" }
6923
7790
  ];
6924
7791
  function lintLivenessProperties(stack) {
6925
7792
  const dir = resolveLivenessDir();
@@ -6927,11 +7794,11 @@ function lintLivenessProperties(stack) {
6927
7794
  const findings = [];
6928
7795
  const objectWarn = loadWarnMap(dir, "object");
6929
7796
  const fieldWarn = loadWarnMap(dir, "field");
6930
- for (const obj of asArray39(stack.objects)) {
7797
+ for (const obj of asArray43(stack.objects)) {
6931
7798
  const objName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
6932
7799
  if (objectWarn.size > 0) checkItem("object", obj, `object '${objName}'`, objectWarn, findings);
6933
7800
  if (fieldWarn.size > 0) {
6934
- for (const field of asArray39(obj.fields)) {
7801
+ for (const field of asArray43(obj.fields)) {
6935
7802
  const fieldName = typeof field.name === "string" ? field.name : "(unnamed field)";
6936
7803
  checkItem("field", field, `object '${objName}' \xB7 field '${fieldName}'`, fieldWarn, findings);
6937
7804
  }
@@ -6940,7 +7807,7 @@ function lintLivenessProperties(stack) {
6940
7807
  for (const { type, key } of TYPE_COLLECTIONS) {
6941
7808
  const warnMap = loadWarnMap(dir, type);
6942
7809
  if (warnMap.size === 0) continue;
6943
- for (const item of asArray39(stack[key])) {
7810
+ for (const item of asArray43(stack[key])) {
6944
7811
  const name = typeof item.name === "string" ? item.name : typeof item.object === "string" ? item.object : `(unnamed ${type})`;
6945
7812
  checkItem(type, item, `${type} '${name}'`, warnMap, findings);
6946
7813
  }
@@ -6954,7 +7821,7 @@ var AUTONUMBER_UNKNOWN_FIELD = "autonumber-references-unknown-field";
6954
7821
  var AUTONUMBER_OPTIONAL_FIELD = "autonumber-references-optional-field";
6955
7822
  var AUTONUMBER_SELF_REFERENCE = "autonumber-references-self";
6956
7823
  var AUTONUMBER_LITERAL_TOKEN = "autonumber-unrecognized-token";
6957
- function asArray40(v) {
7824
+ function asArray44(v) {
6958
7825
  if (Array.isArray(v)) return v;
6959
7826
  if (v && typeof v === "object") {
6960
7827
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -6963,9 +7830,9 @@ function asArray40(v) {
6963
7830
  }
6964
7831
  function lintAutonumberFormats(stack) {
6965
7832
  const findings = [];
6966
- for (const obj of asArray40(stack.objects)) {
7833
+ for (const obj of asArray44(stack.objects)) {
6967
7834
  const objectName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
6968
- const fields = asArray40(obj.fields);
7835
+ const fields = asArray44(obj.fields);
6969
7836
  const fieldMeta = /* @__PURE__ */ new Map();
6970
7837
  for (const f of fields) {
6971
7838
  if (typeof f.name === "string") fieldMeta.set(f.name, { required: f.required === true });
@@ -7031,7 +7898,7 @@ function lintAutonumberFormats(stack) {
7031
7898
 
7032
7899
  // src/lint-view-refs.ts
7033
7900
  import { expandViewContainerWithDiagnostics, isAggregatedViewContainer } from "@objectstack/spec";
7034
- function asArray41(v) {
7901
+ function asArray45(v) {
7035
7902
  if (Array.isArray(v)) return v;
7036
7903
  if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
7037
7904
  return [];
@@ -7059,7 +7926,7 @@ function lintViewRefs(stack) {
7059
7926
  s.add(kind);
7060
7927
  };
7061
7928
  const containers = [];
7062
- for (const v of asArray41(stack.views)) {
7929
+ for (const v of asArray45(stack.views)) {
7063
7930
  if (v.viewKind) {
7064
7931
  if (typeof v.name === "string") indexKind(v.name, v.viewKind === "form" ? "form" : "list");
7065
7932
  continue;
@@ -7068,7 +7935,7 @@ function lintViewRefs(stack) {
7068
7935
  const object = viewContainerObjectName(v);
7069
7936
  if (object) containers.push({ object, container: v });
7070
7937
  }
7071
- for (const obj of asArray41(stack.objects)) {
7938
+ for (const obj of asArray45(stack.objects)) {
7072
7939
  const object = typeof obj.name === "string" ? obj.name : void 0;
7073
7940
  if (!object) continue;
7074
7941
  if (obj.list || obj.form || obj.listViews || obj.formViews) {
@@ -7122,11 +7989,11 @@ function lintViewRefs(stack) {
7122
7989
  });
7123
7990
  }
7124
7991
  };
7125
- for (const obj of asArray41(stack.objects)) {
7992
+ for (const obj of asArray45(stack.objects)) {
7126
7993
  const object = typeof obj.name === "string" ? obj.name : void 0;
7127
- for (const action of asArray41(obj.actions)) checkAction(action, object);
7994
+ for (const action of asArray45(obj.actions)) checkAction(action, object);
7128
7995
  }
7129
- for (const action of asArray41(stack.actions)) checkAction(action);
7996
+ for (const action of asArray45(stack.actions)) checkAction(action);
7130
7997
  return findings;
7131
7998
  }
7132
7999
 
@@ -7139,8 +8006,43 @@ function fieldEntries2(fields) {
7139
8006
  return Object.entries(fields).map(([name, def]) => ({ name, def }));
7140
8007
  }
7141
8008
  var UNIQUE_DOUBLE_DECLARATION = "unique/double-declaration";
8009
+ var UNIQUE_UNSCOPED_DECLARED_INDEX = "unique/unscoped-declared-index";
8010
+ var UNIQUE_LEGACY_ORGANIZATION_COMPOSITE = "unique/legacy-organization-composite";
8011
+ function authoredTenantColumn(obj) {
8012
+ const declared = obj?.tenancy?.tenantField;
8013
+ return typeof declared === "string" && declared.trim() ? declared.trim() : "organization_id";
8014
+ }
7142
8015
  function uniqueDeclared(u) {
7143
- return u === true || u === "global";
8016
+ return u === true || u === "global" || u === "organization";
8017
+ }
8018
+ function fieldUniqueScope(u) {
8019
+ return u === "global" ? "global" : "organization";
8020
+ }
8021
+ function indexUniqueScope(u) {
8022
+ return u === "organization" ? "organization" : "global";
8023
+ }
8024
+ function lintUnscopedDeclaredIndexes(objects) {
8025
+ const issues = [];
8026
+ if (!Array.isArray(objects) || objects.length === 0) return issues;
8027
+ for (let i = 0; i < objects.length; i++) {
8028
+ const obj = objects[i];
8029
+ if (!obj?.name) continue;
8030
+ const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
8031
+ for (let j = 0; j < declaredIndexes.length; j++) {
8032
+ const idx = declaredIndexes[j];
8033
+ if (idx?.unique !== true) continue;
8034
+ const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string").join(", ") : "";
8035
+ const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
8036
+ issues.push({
8037
+ severity: "warning",
8038
+ rule: UNIQUE_UNSCOPED_DECLARED_INDEX,
8039
+ message: `"${obj.name}" declares index${indexLabel} [${cols}] with bare \`unique: true\` \u2014 a unique index whose scope is unstated (ADR-0120). Today the bare spelling materializes over exactly its \`fields\`, i.e. installation-wide; an author who meant "unique per organization" gets no per-organization constraint and no error. Protocol 18 rejects this spelling (#5082).`,
8040
+ path: `objects[${i}].indexes[${j}]`,
8041
+ fix: `State the scope: \`unique: 'global'\` (installation-wide \u2014 exactly today's behavior) or \`unique: 'organization'\` (one holder per organization \u2014 the driver prepends the NULL-safe organization key part at registration).`
8042
+ });
8043
+ }
8044
+ }
8045
+ return issues;
7144
8046
  }
7145
8047
  function lintUniqueDeclarations(objects) {
7146
8048
  const issues = [];
@@ -7160,16 +8062,59 @@ function lintUniqueDeclarations(objects) {
7160
8062
  if (singleColumnUniqueIndexes.size === 0) continue;
7161
8063
  for (const { name, def } of fieldEntries2(obj.fields)) {
7162
8064
  if (!uniqueDeclared(def?.unique)) continue;
7163
- if (def.unique === "global") continue;
7164
8065
  const idx = singleColumnUniqueIndexes.get(name);
7165
8066
  if (!idx) continue;
8067
+ const fScope = fieldUniqueScope(def.unique);
8068
+ const iScope = indexUniqueScope(idx.unique);
7166
8069
  const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
8070
+ const fieldSpelling = `\`unique: ${typeof def.unique === "string" ? `'${def.unique}'` : def.unique}\``;
8071
+ const indexSpelling = `\`unique: ${typeof idx.unique === "string" ? `'${idx.unique}'` : idx.unique}\``;
8072
+ let message;
8073
+ let fix;
8074
+ if (fScope === iScope) {
8075
+ const boundary = fScope === "global" ? "installation-wide" : "per-organization";
8076
+ message = `"${obj.name}.${name}" declares field-level ${fieldSpelling} AND a single-column unique index${indexLabel} (${indexSpelling}) on the same column. Both ask for the same ${boundary} boundary \u2014 the same unique index declared twice (ADR-0120 D5b). Redundant, not contradictory: drop one so the intent has a single home.`;
8077
+ fix = fScope === "global" ? `Keep ONE spelling of installation-wide uniqueness: \`unique: 'global'\` on '${name}', or the declared index \u2014 not both.` : `Keep ONE spelling of per-organization uniqueness: \`unique: 'organization'\` on '${name}' (preferred), or the declared \`'organization'\` index \u2014 not both.`;
8078
+ } else {
8079
+ const globalSide = fScope === "global" ? `field-level ${fieldSpelling}` : `declared index${indexLabel} (${indexSpelling})`;
8080
+ const orgSide = fScope === "global" ? `declared index${indexLabel} (${indexSpelling})` : `field-level ${fieldSpelling}`;
8081
+ message = `"${obj.name}.${name}" declares an installation-wide unique (${globalSide}) AND a per-organization unique (${orgSide}) on the same column \u2014 the two scopes CONTRADICT (ADR-0120 D5b). The installation-wide index is physically stricter and wins; the per-organization constraint can never be tripped, so one of the two intents you wrote is silently dead.`;
8082
+ fix = `Pick ONE scope and say it once: for installation-wide uniqueness keep \`unique: 'global'\` and drop the per-organization declaration; for per-organization uniqueness set \`unique: 'organization'\` (field-level on '${name}', or on the declared index) and drop the installation-wide one.`;
8083
+ }
7167
8084
  issues.push({
7168
8085
  severity: "warning",
7169
8086
  rule: UNIQUE_DOUBLE_DECLARATION,
7170
- message: `"${obj.name}.${name}" declares field-level \`unique: true\` AND a single-column unique index${indexLabel} on the same column. Since #3696 the field-level form is scoped per tenant \u2014 \`(tenant, ${name})\` \u2014 while a declared index is materialized over exactly its \`fields\`, i.e. platform-wide. On a tenant-scoped object the global index wins and the per-tenant constraint can never be reached; on a tenancy-less object the two are the same index declared twice. Either way one of the two declarations has no effect.`,
8087
+ message,
7171
8088
  path: `objects[${i}]`,
7172
- fix: `Pick the intent: for platform-wide uniqueness set \`unique: 'global'\` on '${name}' and drop the duplicate index; for per-tenant uniqueness drop the index (the field-level declaration already builds the tenant composite), or spell the index out as \`fields: ['organization_id', '${name}']\` if you want it explicit.`
8089
+ fix
8090
+ });
8091
+ }
8092
+ }
8093
+ return issues;
8094
+ }
8095
+ function lintLegacyOrganizationComposites(objects) {
8096
+ const issues = [];
8097
+ if (!Array.isArray(objects) || objects.length === 0) return issues;
8098
+ for (let i = 0; i < objects.length; i++) {
8099
+ const obj = objects[i];
8100
+ if (!obj?.name) continue;
8101
+ const tenantColumn = authoredTenantColumn(obj);
8102
+ const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
8103
+ for (let j = 0; j < declaredIndexes.length; j++) {
8104
+ const idx = declaredIndexes[j];
8105
+ if (!uniqueDeclared(idx?.unique) || idx.unique === "organization") continue;
8106
+ const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string") : [];
8107
+ if (cols.length < 2) continue;
8108
+ if (!cols.includes(tenantColumn)) continue;
8109
+ const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
8110
+ const spelling = `\`unique: ${typeof idx.unique === "string" ? `'${idx.unique}'` : idx.unique}\``;
8111
+ const rest = cols.filter((c) => c !== tenantColumn);
8112
+ issues.push({
8113
+ severity: "warning",
8114
+ rule: UNIQUE_LEGACY_ORGANIZATION_COMPOSITE,
8115
+ message: `"${obj.name}" declares index${indexLabel} [${cols.join(", ")}] with ${spelling} and lists the organization column '${tenantColumn}' itself \u2014 the hand-written per-organization composite that predates the scope vocabulary (ADR-0120 S6). It reads as "unique per organization" but materializes as a plain composite, and SQL UNIQUE is NULL-distinct: on every row whose '${tenantColumn}' is NULL it enforces nothing (#5030) \u2014 which on a single-organization deployment is every row.`,
8116
+ path: `objects[${i}].indexes[${j}]`,
8117
+ fix: `State the scope instead: \`unique: 'organization'\` on this index (keep \`fields\` exactly as they are \u2014 the driver makes the listed '${tenantColumn}' NULL-safe in place rather than prepending a second organization key part). ${rest.length > 0 ? `The constraint then really is "one ${rest.join(" + ")} per organization". ` : ""}Opting in is a physical tightening: it surfaces as a \`recreate_index\` drift op gated by the duplicate pre-flight probe (ADR-0120 D4), so pre-existing duplicate NULL-organization rows block it with a report rather than failing a boot. Leaving it as-is stays valid indefinitely and forces no drift.`
7173
8118
  });
7174
8119
  }
7175
8120
  }
@@ -7318,6 +8263,42 @@ var AUTHORING_RULES = [
7318
8263
  runtimeTypes: ["flow"],
7319
8264
  run: (stack) => validateReferenceIntegrity(stack)
7320
8265
  },
8266
+ // ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
8267
+ // is `z.record(z.string(), z.unknown())` and ADR-0089 D3a strictness does not
8268
+ // recurse into it, so until this entry existed the 31 typed prop schemas in
8269
+ // `ComponentPropsMap` were parsed by NOTHING (#4001 批 17's `no gate`
8270
+ // verdict): an undeclared or wrongly-typed prop parsed clean, was retained,
8271
+ // and reached objectui's renderer to be ignored there. This dispatches on
8272
+ // `type` and judges the bag; unregistered types are skipped, which is a
8273
+ // required semantic (`type` is an open union — the example corpus authors 87
8274
+ // nodes of 10 types this map does not carry).
8275
+ //
8276
+ // `normalized` for a reason worth stating, since the props bag survives the
8277
+ // Zod parse UNCHANGED and both tiers would otherwise carry the same data: the
8278
+ // ADR-0087 conversion layer runs inside `normalizeStackInput`, so a converted
8279
+ // alias (`page-header-subtitle-alias` rewrites `properties.description` →
8280
+ // `subtitle`) is already canonical here and is never reported as undeclared —
8281
+ // while a schema error elsewhere in the stack cannot take these findings down
8282
+ // with it.
8283
+ //
8284
+ // Advisory, deliberately, and this is the whole shape of #5068's first step:
8285
+ // wiring the parse is the precondition for enforcement, not the enforcement
8286
+ // (#5020, one surface over). The live corpus violates the declarations in two
8287
+ // places that are open contract questions — inline i18n label maps on three
8288
+ // published platform pages (#5728) and the record picker's declared-but-unread
8289
+ // `displayField` (#5775) — so gating today would fail the platform's own pages
8290
+ // to enforce declarations the platform does not keep. The error upgrade is a
8291
+ // separate step, once the warning-period inventory is empty.
8292
+ {
8293
+ name: "validateComponentProps",
8294
+ tier: "advisory",
8295
+ input: "normalized",
8296
+ commands: ALL,
8297
+ source: "packages/lint/src/validate-component-props.ts",
8298
+ surfaces: CLI_ONLY,
8299
+ surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
8300
+ run: (stack) => validateComponentProps(stack)
8301
+ },
7321
8302
  // ADR-0065 — a styled node's responsiveStyles must be scopable (needs an
7322
8303
  // `id`), name real CSS properties + design tokens, and carry a `large` base.
7323
8304
  {
@@ -7381,18 +8362,32 @@ var AUTHORING_RULES = [
7381
8362
  surfaceReason: 'P2 (#4463): the ONE rule the runtime universe makes strictly stronger \u2014 the advisory hedge ("another installed package may provide it") is decidable against the live capability registry, so it graduates from advisory to gating there rather than merely being ported. That promotion is a severity change on a published rule id and belongs in its own PR, not riding a wiring change.',
7382
8363
  run: (stack) => validateCapabilityReferences(stack)
7383
8364
  },
7384
- // A record-change flow whose start-node objectName matches nothing never
7385
- // fires — silently. Reads the pre-parse tier so an author sees what they
7386
- // wrote. Advisory: the object may come from another installed package.
8365
+ // A flow that LOOKS armed and never launches — silently. Reads the pre-parse
8366
+ // tier so an author sees what they wrote.
8367
+ //
8368
+ // `gating` since #5762, which reviewed the file's rules as one family and
8369
+ // split them on a single question: is THIS STACK enough to know the flow is
8370
+ // dead? Three rules answer yes and now emit `error` — a `config.timeRelative`
8371
+ // the spec's own `TimeRelativeTriggerSchema` refuses, one the engine's routing
8372
+ // predicate cannot route at all, and a `record-*` triggerType outside the
8373
+ // closed token grammar `triggerTypeToHookEvents` maps. None of those verdicts
8374
+ // can be changed by installing a package, so there is no reading under which
8375
+ // the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
8376
+ // (the object may come from another installed package — a hedge this rule
8377
+ // cannot decide), as did `flow-draft-status-ambiguous` (draft flows DO fire;
8378
+ // that one is ambiguity of intent, not a dead flow).
7387
8379
  {
7388
8380
  name: "validateFlowTriggerReadiness",
7389
- tier: "advisory",
8381
+ tier: "gating",
7390
8382
  input: "normalized",
7391
8383
  commands: ALL,
7392
8384
  source: "packages/lint/src/validate-flow-trigger-readiness.ts",
7393
- // Runtime publish gate (#4463): the FLOW family. Advisory at this surface
7394
- // too its findings are logged, not thrown (P1 gates on `error` only; P2
7395
- // puts advisories on the response for Studio to render).
8385
+ // Runtime publish gate (#4463): the FLOW family. Its `error` findings now
8386
+ // REFUSE a `state: 'active'` write (P1 gates on `error` only); the rules that
8387
+ // stayed `warning` keep being logged as advisories. The gate judges a
8388
+ // snapshot whose `flows` holds only the written item and subtracts the
8389
+ // baseline's findings, so this refuses the dead flow's own publish — never
8390
+ // another flow's save on account of a stored one.
7396
8391
  surfaces: CLI_AND_RUNTIME,
7397
8392
  runtimeTypes: ["flow"],
7398
8393
  run: (stack) => validateFlowTriggerReadiness(stack)
@@ -7603,9 +8598,31 @@ var AUTHORING_RULES = [
7603
8598
  hint: f.hint
7604
8599
  }))
7605
8600
  },
7606
- // #3991 — a column carrying BOTH a field-level `unique: true` and a
7607
- // single-column declared unique index has two intents, of which exactly one
7608
- // takes effect (the global index wins; the tenant composite is unreachable).
8601
+ // ADR-0120 D5a — a declared index with bare `unique: true` states no scope
8602
+ // at all (`unique/unscoped-declared-index` the #4986 trap). Fires on the
8603
+ // spelling alone, no tenancy inference; 17.x warns, protocol 18 rejects the
8604
+ // spelling (#5082).
8605
+ {
8606
+ name: "lintUnscopedDeclaredIndexes",
8607
+ tier: "advisory",
8608
+ input: "parsed",
8609
+ commands: ["validate", "build"],
8610
+ source: "packages/lint/src/data-model-rules.ts",
8611
+ surfaces: CLI_ONLY,
8612
+ surfaceReason: RUNTIME_OBJECT_WRITES_P2,
8613
+ scopeReason: "`os lint` already reports this rule through `lintDataModel`, which calls it directly ahead of R10 in its best-practice sweep \u2014 registering it for `lint` as well would report every finding twice. This is coverage recorded, not coverage missing: all three commands report the rule.",
8614
+ run: (stack) => lintUnscopedDeclaredIndexes(Array.isArray(stack.objects) ? stack.objects : []).map((f) => ({
8615
+ severity: f.severity === "suggestion" ? "info" : f.severity,
8616
+ rule: f.rule,
8617
+ where: f.path,
8618
+ path: f.path,
8619
+ message: f.message,
8620
+ hint: f.fix ?? ""
8621
+ }))
8622
+ },
8623
+ // #3991 / ADR-0120 D5b — a column carrying BOTH a field-level `unique` and a
8624
+ // single-column declared unique index states two scopes of which at most one
8625
+ // takes effect (`unique/double-declaration`, the four-quadrant matrix).
7609
8626
  {
7610
8627
  name: "lintUniqueDeclarations",
7611
8628
  tier: "advisory",
@@ -7624,6 +8641,28 @@ var AUTHORING_RULES = [
7624
8641
  hint: f.fix ?? ""
7625
8642
  }))
7626
8643
  },
8644
+ // ADR-0120 D5c — a declared unique listing the organization column IS the
8645
+ // hand-written per-organization composite (S6). Advisory nudge toward the
8646
+ // `'organization'` respelling, which is also what closes its NULL hole
8647
+ // (#5030). Never auto-fixed: opting in is a real D4 tightening.
8648
+ {
8649
+ name: "lintLegacyOrganizationComposites",
8650
+ tier: "advisory",
8651
+ input: "parsed",
8652
+ commands: ["validate", "build"],
8653
+ source: "packages/lint/src/data-model-rules.ts",
8654
+ surfaces: CLI_ONLY,
8655
+ surfaceReason: RUNTIME_OBJECT_WRITES_P2,
8656
+ scopeReason: "`os lint` already reports this rule through `lintDataModel`, which calls it directly alongside R10/R11 in its best-practice sweep \u2014 registering it for `lint` as well would report every finding twice. This is coverage recorded, not coverage missing: all three commands report the rule.",
8657
+ run: (stack) => lintLegacyOrganizationComposites(Array.isArray(stack.objects) ? stack.objects : []).map((f) => ({
8658
+ severity: f.severity === "suggestion" ? "info" : f.severity,
8659
+ rule: f.rule,
8660
+ where: f.path,
8661
+ path: f.path,
8662
+ message: f.message,
8663
+ hint: f.fix ?? ""
8664
+ }))
8665
+ },
7627
8666
  // ADR-0090 D7 — the security-domain publish linter. Every `error` rule mirrors
7628
8667
  // a runtime enforcement point (fail-closed OWD default, canonical enum, anchor
7629
8668
  // binding gate, vocabulary freeze), moving the failure from a runtime deny to
@@ -7651,6 +8690,88 @@ var AUTHORING_RULES = [
7651
8690
  surfaces: CLI_ONLY,
7652
8691
  surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
7653
8692
  run: (stack) => validateOrgAxisRedLines(stack)
8693
+ },
8694
+ // #4698 — the "declared but never read" gate, for the one surface where the
8695
+ // predicate is EXACT rather than inferred. A sharing rule's `condition` has a
8696
+ // single runtime consumer (`bootstrapDeclaredSharingRules`) whose only use of
8697
+ // the key is `compileCelToFilter(condition, { variables: {} })`; a condition
8698
+ // that does not lower means the rule is SKIPPED at boot, so the grant is
8699
+ // declared and does not exist. The lint calls that same compiler, from the
8700
+ // same package, with the same options — the verdict cannot drift from the
8701
+ // consumer's. Gating for the ADR-0078 reason `SharingRuleSchema`'s own
8702
+ // docblock states: the whole authorable surface is enforced, and this was the
8703
+ // one field where that sentence was not yet true.
8704
+ {
8705
+ name: "validateSharingRuleEnforceability",
8706
+ tier: "gating",
8707
+ input: "parsed",
8708
+ commands: ALL,
8709
+ source: "packages/lint/src/validate-sharing-rule-enforceability.ts",
8710
+ surfaces: CLI_ONLY,
8711
+ surfaceReason: "P2 (#4463): a sharing rule is not a `flow`, and P1 gates `flow` alone. The rule itself is snapshot-safe \u2014 it reads ONLY `stack.sharingRules[].condition` and needs no other collection \u2014 so widening it here is a `runtimeTypes: ['sharing_rule']` edit once the gate accepts that type, not new wiring. Recorded as pending rather than done, because a rule that has never run at a door should not claim it.",
8712
+ run: (stack) => validateSharingRuleEnforceability(stack)
8713
+ },
8714
+ // #4983 — the sibling surface of the rule above, and ADR-0056 D4's gate,
8715
+ // which had never been wired to anything: `isSupportedRlsExpression` existed
8716
+ // solely so an authoring command could reject a predicate the runtime drops,
8717
+ // and no authoring command called it. An unlowerable
8718
+ // `rowLevelSecurity[].using` is DROPPED by `RLSCompiler` and — when it is the
8719
+ // only applicable policy — replaced by `RLS_DENY_FILTER`, so the policy reads
8720
+ // as an authorization and behaves as a blanket refusal. Same construction as
8721
+ // the sharing-rule entry: the verdict is the runtime's own function, reached
8722
+ // through `@objectstack/formula` (where #4983 hoisted it), never a model of it.
8723
+ {
8724
+ name: "validateRlsPredicateEnforceability",
8725
+ tier: "gating",
8726
+ input: "parsed",
8727
+ commands: ALL,
8728
+ source: "packages/lint/src/validate-rls-predicate-enforceability.ts",
8729
+ surfaces: CLI_ONLY,
8730
+ surfaceReason: "P2 (#4463): the rule reads `stack.permissions[]`, a stack-wide collection the per-write snapshot does not carry, and P1 gates `flow` alone. It is otherwise snapshot-ready \u2014 it needs no other collection \u2014 so widening it is a `runtimeTypes: ['permission_set']` edit once the gate builds that snapshot, not new wiring. Recorded as pending rather than done, because a rule that has never run at a door should not claim it.",
8731
+ run: (stack) => validateRlsPredicateEnforceability(stack)
8732
+ },
8733
+ // #4762 — the same "declared but enforces nothing" question, for the two
8734
+ // STATIC artifacts an object validation rule carries. A `format` rule's
8735
+ // `regex` that `new RegExp(...)` throws on, and a `json_schema` rule's schema
8736
+ // ajv cannot compile, are both logged and SKIPPED on the write path
8737
+ // (`rule-validator.ts`), so the rule ships, lists, and protects nothing.
8738
+ // Neither needs a record to judge, so the authoring door is the right one:
8739
+ // rejecting a broken regex at RUNTIME instead would reject every write
8740
+ // touching that field for as long as the metadata is deployed (#4762's own
8741
+ // analysis — the runtime-backstop question stays open for the maintainer).
8742
+ // Gating for the `lint-flow-patterns.ts` bar: no reading of the metadata
8743
+ // behaves as written, because the rule does not run at all.
8744
+ {
8745
+ name: "validateRuleCompilability",
8746
+ tier: "gating",
8747
+ input: "parsed",
8748
+ commands: ALL,
8749
+ source: "packages/lint/src/validate-rule-compilability.ts",
8750
+ surfaces: CLI_ONLY,
8751
+ surfaceReason: RUNTIME_OBJECT_WRITES_P2,
8752
+ run: (stack) => validateRuleCompilability(stack)
8753
+ },
8754
+ // #5178 — the residual half of #5029, which registering `ajv-formats` does
8755
+ // NOT close: under `strict: false` a MISSPELLED format name (`emial`) is
8756
+ // logged once and DROPPED, so the rule compiles, ships, runs on every write
8757
+ // and enforces nothing for the keyword its author wrote — and the record is
8758
+ // accepted, which is the silent direction. Deliberately its own entry rather
8759
+ // than a third finding inside the rule above: that one's whole contract is
8760
+ // compiling in the runtime's exact environment, and a typo'd format compiles
8761
+ // there. This judges the format NAME against the registered set (enumerated
8762
+ // from the same ajv instance, never a hardcoded list) and compiles nothing,
8763
+ // so the #4762/#5029 compile parity is untouched — a judgement beside the
8764
+ // compile, not a divergent compile. Gating for the `lint-flow-patterns.ts`
8765
+ // bar: no reading of the metadata behaves as written.
8766
+ {
8767
+ name: "validateRuleSchemaFormats",
8768
+ tier: "gating",
8769
+ input: "parsed",
8770
+ commands: ALL,
8771
+ source: "packages/lint/src/validate-rule-schema-formats.ts",
8772
+ surfaces: CLI_ONLY,
8773
+ surfaceReason: RUNTIME_OBJECT_WRITES_P2,
8774
+ run: (stack) => validateRuleSchemaFormats(stack)
7654
8775
  }
7655
8776
  ];
7656
8777