@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.cjs CHANGED
@@ -28,21 +28,25 @@ __export(runtime_exports, {
28
28
  module.exports = __toCommonJS(runtime_exports);
29
29
 
30
30
  // src/validate-expressions.ts
31
- var import_formula = require("@objectstack/formula");
31
+ var import_formula2 = require("@objectstack/formula");
32
32
  var import_automation = require("@objectstack/spec/automation");
33
33
 
34
+ // src/system-fields.ts
35
+ var import_data = require("@objectstack/spec/data");
36
+ var import_system = require("@objectstack/spec/system");
37
+ var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
38
+ ...import_data.FIELD_GROUP_SYSTEM_FIELDS,
39
+ ...Object.values(import_system.SystemFieldName)
40
+ ]);
41
+ function injectedColumnsFor(objectDef) {
42
+ return (0, import_data.resolveInjectedSystemColumns)(objectDef).names;
43
+ }
44
+
34
45
  // src/validate-null-guards.ts
35
- var import_cel_js = require("@marcbachmann/cel-js");
46
+ var import_formula = require("@objectstack/formula");
36
47
  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.`;
37
48
  var FAULTING_BINARY_OPS = /* @__PURE__ */ new Set(["<", "<=", ">", ">=", "+", "-", "*", "/", "%"]);
38
49
  var DEFAULT_RECORD_ROOTS = ["record", "previous"];
39
- var parseEnv;
40
- function getParseEnv() {
41
- if (!parseEnv) {
42
- parseEnv = new import_cel_js.Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
43
- }
44
- return parseEnv;
45
- }
46
50
  function isNode(v) {
47
51
  return !!v && typeof v === "object" && typeof v.op === "string";
48
52
  }
@@ -175,12 +179,8 @@ function findUnguardedNullableOperands(source, opts) {
175
179
  if (typeof source !== "string" || !source.trim()) return [];
176
180
  if (opts.nullableFields.size === 0) return [];
177
181
  const roots = opts.roots ?? DEFAULT_RECORD_ROOTS;
178
- let ast;
179
- try {
180
- ast = getParseEnv().parse(source).ast;
181
- } catch {
182
- return [];
183
- }
182
+ const ast = (0, import_formula.parseCelToAst)(source);
183
+ if (!ast) return [];
184
184
  const hasOperands = /* @__PURE__ */ new Set();
185
185
  collectHasOperands(ast, roots, hasOperands);
186
186
  const findings = [];
@@ -243,10 +243,14 @@ function findUnguardedNullableOperands(source, opts) {
243
243
  visit(ast, /* @__PURE__ */ new Set());
244
244
  return findings;
245
245
  }
246
- function nullGuardMessage(subject, objectName, finding) {
246
+ var OUTCOME_CLAUSE = {
247
+ "fail-closed": "so the rule enforces nothing and the write is rejected fail-closed (#4649/#4763)",
248
+ "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)"
249
+ };
250
+ function nullGuardMessage(subject, objectName, finding, outcome = "fail-closed") {
247
251
  const owner = objectName ? `'${objectName}'` : "this object";
248
252
  const hasNote = finding.hasOnlyGuard ? ` \`has(${finding.operand})\` does not guard it.` : "";
249
- 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}`;
253
+ 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}`;
250
254
  }
251
255
 
252
256
  // src/validate-expressions.ts
@@ -266,7 +270,7 @@ function buildFieldIndex(objects) {
266
270
  let names = [];
267
271
  if (Array.isArray(fields)) names = fields.map((f) => f.name).filter((n) => typeof n === "string");
268
272
  else if (fields && typeof fields === "object") names = Object.keys(fields);
269
- idx.set(name, names);
273
+ idx.set(name, [.../* @__PURE__ */ new Set([...names, ...injectedColumnsFor(obj)])]);
270
274
  }
271
275
  return idx;
272
276
  }
@@ -326,6 +330,19 @@ function buildNullableFieldIndex(objects) {
326
330
  }
327
331
  return idx;
328
332
  }
333
+ function readsParentRoot(source) {
334
+ const roots = (0, import_formula2.collectCelRootIdentifiers)(source);
335
+ return roots.ok && roots.roots.includes("parent");
336
+ }
337
+ function masterDetailCount(obj) {
338
+ let n = 0;
339
+ for (const [, def] of fieldEntries(obj)) {
340
+ if (def.type !== "master_detail") continue;
341
+ const ref = def.reference;
342
+ if (typeof ref === "string" && ref.trim() !== "") n += 1;
343
+ }
344
+ return n;
345
+ }
329
346
  function celSourceOf(raw) {
330
347
  if (typeof raw === "string") return raw;
331
348
  if (raw && typeof raw === "object") {
@@ -339,7 +356,7 @@ function rulePredicates(rule, path) {
339
356
  const out = [];
340
357
  const name = typeof rule.name === "string" ? rule.name : "?";
341
358
  const here = path ? `${path} \u2192 '${name}'` : `'${name}'`;
342
- const main = rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula;
359
+ const main = rule.condition;
343
360
  if (main != null) out.push({ label: `validation rule ${here}`, raw: main });
344
361
  if (rule.when != null) out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when });
345
362
  for (const branch of ["then", "otherwise"]) {
@@ -356,7 +373,7 @@ function validateStackExpressions(stack) {
356
373
  const fieldIndex = buildFieldIndex(objects);
357
374
  const fieldTypeIndex = buildFieldTypeIndex(objects);
358
375
  const nullableIndex = buildNullableFieldIndex(objects);
359
- const checkNullGuards = (where, subject, raw, objectName) => {
376
+ const checkNullGuards = (where, subject, raw, objectName, outcome = "fail-closed") => {
360
377
  if (!objectName) return;
361
378
  const nullableFields = nullableIndex.get(objectName);
362
379
  if (!nullableFields || nullableFields.size === 0) return;
@@ -365,7 +382,7 @@ function validateStackExpressions(stack) {
365
382
  for (const finding of findUnguardedNullableOperands(source, { nullableFields })) {
366
383
  issues.push({
367
384
  where,
368
- message: nullGuardMessage(subject, objectName, finding),
385
+ message: nullGuardMessage(subject, objectName, finding, outcome),
369
386
  source,
370
387
  severity: "error"
371
388
  });
@@ -375,7 +392,7 @@ function validateStackExpressions(stack) {
375
392
  if (raw == null) return;
376
393
  const fields = objectName ? fieldIndex.get(objectName) : void 0;
377
394
  const fieldTypes = objectName ? fieldTypeIndex.get(objectName) : void 0;
378
- const res = (0, import_formula.validateExpression)(
395
+ const res = (0, import_formula2.validateExpression)(
379
396
  "predicate",
380
397
  raw,
381
398
  objectName ? { objectName, fields, fieldTypes, scope } : { scope }
@@ -385,7 +402,7 @@ function validateStackExpressions(stack) {
385
402
  };
386
403
  const checkDeclaredPredicate = (where, raw) => {
387
404
  if (raw == null) return;
388
- const res = (0, import_formula.validateExpression)("predicate", raw);
405
+ const res = (0, import_formula2.validateExpression)("predicate", raw);
389
406
  for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: "error" });
390
407
  for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: "warning" });
391
408
  };
@@ -434,31 +451,45 @@ function validateStackExpressions(stack) {
434
451
  }
435
452
  for (const obj of objects) {
436
453
  const objectName = typeof obj.name === "string" ? obj.name : void 0;
437
- const validations = obj.validations ?? obj.validationRules;
454
+ const validations = obj.validations;
438
455
  for (const rule of asArray(validations)) {
439
456
  const where = `object '${objectName}' \xB7 validation '${rule.name ?? "?"}'`;
440
- check(where, rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula, objectName, "record");
457
+ check(where, rule.condition, objectName, "record");
441
458
  check(`${where} when`, rule.when, objectName, "record");
442
459
  for (const p of rulePredicates(rule, "")) {
443
460
  checkNullGuards(`object '${objectName}' \xB7 ${p.label}`, p.label, p.raw, objectName);
444
461
  }
445
462
  }
446
463
  const fields = obj.fields;
447
- const fieldList = Array.isArray(fields) ? fields : fields && typeof fields === "object" ? Object.values(fields) : [];
448
- for (const f of fieldList) {
449
- if (f && typeof f === "object") {
450
- const fname = f.name ?? "?";
451
- for (const key of ["requiredWhen", "readonlyWhen", "conditionalRequired", "visibleWhen"]) {
452
- check(`object '${objectName}' \xB7 field '${fname}' ${key}`, f[key], objectName, "record");
453
- }
464
+ 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]) : [];
465
+ const masters = masterDetailCount(obj);
466
+ for (const [fname, f] of fieldList) {
467
+ for (const key of ["requiredWhen", "readonlyWhen", "conditionalRequired", "visibleWhen"]) {
468
+ check(`object '${objectName}' \xB7 field '${fname}' ${key}`, f[key], objectName, "record");
469
+ }
470
+ const roWhenSource = celSourceOf(f.readonlyWhen);
471
+ if (masters !== 1 && roWhenSource && readsParentRoot(roWhenSource)) {
472
+ issues.push({
473
+ where: `object '${objectName}' \xB7 field '${fname}' readonlyWhen`,
474
+ 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\`.`),
475
+ source: roWhenSource,
476
+ severity: "error"
477
+ });
454
478
  }
455
- if (f && typeof f === "object" && f.formula) {
456
- const res = (0, import_formula.validateExpression)(
479
+ checkNullGuards(
480
+ `object '${objectName}' \xB7 field '${fname}' requiredWhen`,
481
+ `field '${fname}' requiredWhen`,
482
+ f.requiredWhen,
483
+ objectName,
484
+ "fail-open"
485
+ );
486
+ if (f.expression) {
487
+ const res = (0, import_formula2.validateExpression)(
457
488
  "value",
458
- f.formula,
489
+ f.expression,
459
490
  objectName ? { objectName, fields: fieldIndex.get(objectName), fieldTypes: fieldTypeIndex.get(objectName), scope: "record" } : { scope: "record" }
460
491
  );
461
- const fieldWhere = `object '${objectName}' \xB7 field '${f.name ?? "?"}' formula`;
492
+ const fieldWhere = `object '${objectName}' \xB7 field '${fname}' expression`;
462
493
  for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: "error" });
463
494
  for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: "warning" });
464
495
  }
@@ -466,7 +497,7 @@ function validateStackExpressions(stack) {
466
497
  }
467
498
  const seenActions = /* @__PURE__ */ new Set();
468
499
  const checkAction = (where, action, objectName) => {
469
- const obj = objectName ?? (typeof action.objectName === "string" ? action.objectName : void 0) ?? (typeof action.object === "string" ? action.object : void 0);
500
+ const obj = objectName ?? (typeof action.objectName === "string" ? action.objectName : void 0);
470
501
  const name = typeof action.name === "string" ? action.name : "?";
471
502
  const key = `${obj ?? ""}:${name}`;
472
503
  if (seenActions.has(key)) return;
@@ -485,10 +516,10 @@ function validateStackExpressions(stack) {
485
516
  checkAction(`object '${objectName}'`, action, objectName);
486
517
  }
487
518
  }
488
- for (const rule of asArray(stack.sharingRules)) {
489
- const ruleObj = typeof rule.object === "string" ? rule.object : void 0;
490
- const where = `sharingRule '${rule.name ?? "?"}'${ruleObj ? ` (${ruleObj})` : ""} condition`;
491
- check(where, rule.condition ?? rule.criteria ?? rule.predicate, ruleObj, "record");
519
+ for (const sharingRule of asArray(stack.sharingRules)) {
520
+ const ruleObj = typeof sharingRule.object === "string" ? sharingRule.object : void 0;
521
+ const where = `sharingRule '${sharingRule.name ?? "?"}'${ruleObj ? ` (${ruleObj})` : ""} condition`;
522
+ check(where, sharingRule.condition, ruleObj, "record");
492
523
  }
493
524
  for (const hook of asArray(stack.hooks)) {
494
525
  const hookName = hook.name ?? "?";
@@ -706,16 +737,6 @@ function validateViewContainers(stack) {
706
737
  // src/validate-widget-bindings.ts
707
738
  var import_data2 = require("@objectstack/spec/data");
708
739
  var import_ui = require("@objectstack/spec/ui");
709
-
710
- // src/system-fields.ts
711
- var import_data = require("@objectstack/spec/data");
712
- var import_system = require("@objectstack/spec/system");
713
- var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
714
- ...import_data.FIELD_GROUP_SYSTEM_FIELDS,
715
- ...Object.values(import_system.SystemFieldName)
716
- ]);
717
-
718
- // src/validate-widget-bindings.ts
719
740
  var WIDGET_DATASET_UNKNOWN = "widget-dataset-unknown";
720
741
  var WIDGET_DIMENSION_UNKNOWN = "widget-dimension-unknown";
721
742
  var WIDGET_MEASURE_UNKNOWN = "widget-measure-unknown";
@@ -1169,18 +1190,6 @@ function validateDashboardActionRefs(stack) {
1169
1190
  `${dashPath}.header.actions[${ai}].actionUrl`
1170
1191
  );
1171
1192
  }
1172
- const widgets = asArray4(dash.widgets);
1173
- for (let wi = 0; wi < widgets.length; wi++) {
1174
- const widget = widgets[wi];
1175
- if (!widget || typeof widget !== "object") continue;
1176
- if (!strName(widget.actionUrl)) continue;
1177
- const widgetId = strName(widget.id) ?? `#${wi}`;
1178
- checkOne(
1179
- { actionType: widget.actionType, actionUrl: widget.actionUrl },
1180
- `dashboard "${dashName}" \xB7 widget "${widgetId}" action`,
1181
- `${dashPath}.widgets[${wi}].actionUrl`
1182
- );
1183
- }
1184
1193
  }
1185
1194
  return findings;
1186
1195
  }
@@ -1670,13 +1679,13 @@ function validateSearchableFields(stack) {
1670
1679
  for (let vi = 0; vi < views.length; vi++) {
1671
1680
  const view = views[vi];
1672
1681
  if (!isRec2(view)) continue;
1673
- const viewLabel = strName3(view.name) ?? strName3(view.objectName) ?? `#${vi}`;
1682
+ const viewLabel2 = strName3(view.name) ?? strName3(view.objectName) ?? `#${vi}`;
1674
1683
  const viewObject = strName3(view.objectName) ?? strName3(view.object);
1675
1684
  if (isRec2(view.list)) {
1676
1685
  check(
1677
1686
  view.list.searchableFields,
1678
1687
  listViewObject(view.list) ?? viewObject,
1679
- `view "${viewLabel}" \u203A list`,
1688
+ `view "${viewLabel2}" \u203A list`,
1680
1689
  `views[${vi}].list.searchableFields`,
1681
1690
  "list-view searchableFields",
1682
1691
  "narrowing"
@@ -1688,7 +1697,7 @@ function validateSearchableFields(stack) {
1688
1697
  check(
1689
1698
  lv.searchableFields,
1690
1699
  listViewObject(lv) ?? viewObject,
1691
- `view "${viewLabel}" \u203A listViews.${key}`,
1700
+ `view "${viewLabel2}" \u203A listViews.${key}`,
1692
1701
  `views[${vi}].listViews.${key}.searchableFields`,
1693
1702
  "list-view searchableFields",
1694
1703
  "narrowing"
@@ -1996,8 +2005,11 @@ function sortFieldRefs(value, basePath) {
1996
2005
  }
1997
2006
  var COMPONENT_FIELD_SPECS = {
1998
2007
  "record:highlights": { props: ["fields"] },
1999
- // `sections`/`hideFields` are not in RecordDetailsProps, but every real page
2000
- // authors them (they survive because `properties` is unvalidated).
2008
+ // `sections` (object form) and `hideFields` are what every real page authors,
2009
+ // and since #5611 they are what `RecordDetailsProps` declares — this model and
2010
+ // the spec agree. (Before that, `sections` was declared as an ID `string[]`
2011
+ // and `hideFields` not at all; both survived only because `properties` is
2012
+ // unvalidated.)
2001
2013
  "record:details": { props: ["fields", "hideFields"], nestedSections: ["sections"] },
2002
2014
  "record:path": { props: ["statusField"] },
2003
2015
  "element:number": { props: ["field"] },
@@ -2060,7 +2072,7 @@ function indexObjectFields(stack) {
2060
2072
  }
2061
2073
  return objectFields;
2062
2074
  }
2063
- function checkFieldRefs(refs, objectName, objectFields, where, consequence = "skipped") {
2075
+ function checkFieldRefs(refs, objectName, objectFields, where, consequence2 = "skipped") {
2064
2076
  const findings = [];
2065
2077
  if (!objectName) return findings;
2066
2078
  const known = objectFields.get(objectName);
@@ -2069,11 +2081,11 @@ function checkFieldRefs(refs, objectName, objectFields, where, consequence = "sk
2069
2081
  if (ref.name.includes(".")) continue;
2070
2082
  if (known.has(ref.name) || SYSTEM_FIELDS.has(ref.name)) continue;
2071
2083
  findings.push({
2072
- severity: consequence === "queried" ? "error" : "warning",
2084
+ severity: consequence2 === "queried" ? "error" : "warning",
2073
2085
  rule: PAGE_FIELD_UNKNOWN,
2074
2086
  where,
2075
2087
  path: ref.path,
2076
- 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."),
2088
+ 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."),
2077
2089
  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(", ")}.` : "")
2078
2090
  });
2079
2091
  }
@@ -2642,6 +2654,13 @@ function collectViewRecord(view, factsFor) {
2642
2654
  const addView = (objectName, name) => {
2643
2655
  if (objectName && name) factsFor(objectName).views.add(name);
2644
2656
  };
2657
+ const addSections = (container, binding) => {
2658
+ if (!binding) return;
2659
+ for (const section of asArray14(container.sections)) {
2660
+ const sectionName = strName10(section.name);
2661
+ if (sectionName) factsFor(binding).sections.add(sectionName);
2662
+ }
2663
+ };
2645
2664
  const listBinding = isRec7(view.list) ? bindingOf(view.list) : void 0;
2646
2665
  if (isRec7(view.list)) addView(listBinding, strName10(view.list.name));
2647
2666
  addView(recordObject ?? listBinding, strName10(view.name));
@@ -2653,21 +2672,11 @@ function collectViewRecord(view, factsFor) {
2653
2672
  const binding = bindingOf(sub) ?? listBinding;
2654
2673
  addView(binding, subKey);
2655
2674
  addView(binding, strName10(sub.name));
2656
- if (binding) {
2657
- for (const section of asArray14(sub.sections)) {
2658
- const sectionName = strName10(section.name);
2659
- if (sectionName) factsFor(binding).sections.add(sectionName);
2660
- }
2661
- }
2662
- }
2663
- }
2664
- const sectionBinding = recordObject ?? listBinding;
2665
- if (sectionBinding) {
2666
- for (const section of asArray14(view.sections)) {
2667
- const sectionName = strName10(section.name);
2668
- if (sectionName) factsFor(sectionBinding).sections.add(sectionName);
2675
+ addSections(sub, binding);
2669
2676
  }
2670
2677
  }
2678
+ if (isRec7(view.form)) addSections(view.form, bindingOf(view.form) ?? listBinding);
2679
+ addSections(view, recordObject ?? listBinding);
2671
2680
  }
2672
2681
  function viewObjectName(view) {
2673
2682
  return strName10(view.objectName) ?? strName10(view.object) ?? (isRec7(view.data) ? strName10(view.data.object) : void 0);
@@ -3032,24 +3041,169 @@ function checkActionParams(findings, ctx) {
3032
3041
  }
3033
3042
  }
3034
3043
 
3035
- // src/flow-walk.ts
3036
- var import_automation2 = require("@objectstack/spec/automation");
3044
+ // src/validate-translatable-sections.ts
3045
+ var TRANSLATION_SECTION_NAME_MISSING = "translation-section-name-missing";
3037
3046
  function isRec8(v) {
3038
3047
  return !!v && typeof v === "object" && !Array.isArray(v);
3039
3048
  }
3040
3049
  function strName11(v) {
3041
3050
  return typeof v === "string" && v.length > 0 ? v : void 0;
3042
3051
  }
3052
+ function viewObjectName2(view) {
3053
+ return strName11(view.objectName) ?? strName11(view.object) ?? (isRec8(view.data) ? strName11(view.data.object) : void 0);
3054
+ }
3055
+ function collectionEntries(v, base) {
3056
+ if (Array.isArray(v)) {
3057
+ const out = [];
3058
+ for (let i = 0; i < v.length; i++) {
3059
+ if (isRec8(v[i])) out.push({ rec: v[i], path: `${base}[${i}]` });
3060
+ }
3061
+ return out;
3062
+ }
3063
+ if (isRec8(v)) {
3064
+ return Object.entries(v).filter(([, def]) => isRec8(def)).map(([name, def]) => ({ rec: { name, ...def }, path: `${base}.${name}` }));
3065
+ }
3066
+ return [];
3067
+ }
3068
+ function viewLabel(view) {
3069
+ const name = strName11(view.name);
3070
+ return name ? `view "${name}"` : "";
3071
+ }
3072
+ function joinWhere(...parts) {
3073
+ return parts.filter((p) => p.length > 0).join(" \xB7 ");
3074
+ }
3075
+ function collectViewSites(view, basePath, label2, sites) {
3076
+ const recordObject = viewObjectName2(view);
3077
+ const listBinding = isRec8(view.list) ? viewObjectName2(view.list) ?? recordObject : void 0;
3078
+ const bindingOf = (container) => viewObjectName2(container) ?? recordObject;
3079
+ sites.push({
3080
+ path: `${basePath}.sections`,
3081
+ surface: label2,
3082
+ objectName: recordObject ?? listBinding,
3083
+ sections: view.sections
3084
+ });
3085
+ if (isRec8(view.form)) {
3086
+ sites.push({
3087
+ path: `${basePath}.form.sections`,
3088
+ surface: joinWhere(label2, "form"),
3089
+ objectName: bindingOf(view.form) ?? listBinding,
3090
+ sections: view.form.sections
3091
+ });
3092
+ }
3093
+ for (const key of ["listViews", "formViews"]) {
3094
+ const container = view[key];
3095
+ if (!isRec8(container)) continue;
3096
+ for (const [subKey, sub] of Object.entries(container)) {
3097
+ if (!isRec8(sub)) continue;
3098
+ sites.push({
3099
+ path: `${basePath}.${key}.${subKey}.sections`,
3100
+ surface: joinWhere(label2, `${key}.${subKey}`),
3101
+ objectName: bindingOf(sub) ?? listBinding,
3102
+ sections: sub.sections
3103
+ });
3104
+ }
3105
+ }
3106
+ }
3107
+ function translatedObjectNames(stack) {
3108
+ const out = /* @__PURE__ */ new Set();
3109
+ const bundles = Array.isArray(stack.translations) ? stack.translations : [];
3110
+ for (const bundle of bundles) {
3111
+ if (!isRec8(bundle)) continue;
3112
+ for (const data of Object.values(bundle)) {
3113
+ if (!isRec8(data) || !isRec8(data.objects)) continue;
3114
+ for (const [objectName, node] of Object.entries(data.objects)) {
3115
+ if (isRec8(node)) out.add(objectName);
3116
+ }
3117
+ }
3118
+ }
3119
+ return out;
3120
+ }
3121
+ function suggestedName(label2) {
3122
+ const slug = label2.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
3123
+ return slug.length > 0 ? slug : void 0;
3124
+ }
3125
+ function validateTranslatableSections(stack) {
3126
+ const findings = [];
3127
+ if (!isRec8(stack)) return findings;
3128
+ const translated = translatedObjectNames(stack);
3129
+ if (translated.size === 0) return findings;
3130
+ const sites = [];
3131
+ for (const { rec: obj, path: objPath } of collectionEntries(stack.objects, "objects")) {
3132
+ const objectName = strName11(obj.name);
3133
+ if (!objectName) continue;
3134
+ for (const { rec: view, path } of collectionEntries(obj.views, `${objPath}.views`)) {
3135
+ collectViewSites(
3136
+ { ...view, object: strName11(view.object) ?? objectName },
3137
+ path,
3138
+ viewLabel(view),
3139
+ sites
3140
+ );
3141
+ }
3142
+ if (isRec8(obj.listViews)) {
3143
+ collectViewSites({ object: objectName, listViews: obj.listViews }, objPath, "", sites);
3144
+ }
3145
+ }
3146
+ for (const { rec: view, path } of collectionEntries(stack.views, "views")) {
3147
+ collectViewSites(view, path, viewLabel(view), sites);
3148
+ }
3149
+ for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, "pages")) {
3150
+ const pageName = strName11(page.name);
3151
+ const pageLabel = pageName ? `page "${pageName}"` : "";
3152
+ for (const walked of walkPageComponents(page, pagePath)) {
3153
+ if (!walked.objectName) continue;
3154
+ const props = isRec8(walked.component.properties) ? walked.component.properties : void 0;
3155
+ if (!props) continue;
3156
+ const type = strName11(walked.component.type) ?? "component";
3157
+ sites.push({
3158
+ path: `${walked.path}.properties.sections`,
3159
+ surface: joinWhere(pageLabel, type),
3160
+ objectName: walked.objectName,
3161
+ sections: props.sections
3162
+ });
3163
+ }
3164
+ }
3165
+ for (const site of sites) {
3166
+ const objectName = site.objectName;
3167
+ if (!objectName || !translated.has(objectName)) continue;
3168
+ if (!Array.isArray(site.sections)) continue;
3169
+ for (let i = 0; i < site.sections.length; i++) {
3170
+ const section = site.sections[i];
3171
+ if (!isRec8(section)) continue;
3172
+ if (strName11(section.name)) continue;
3173
+ const heading = strName11(section.label) ?? strName11(section.title);
3174
+ if (!heading) continue;
3175
+ const slug = suggestedName(heading);
3176
+ findings.push({
3177
+ severity: "warning",
3178
+ rule: TRANSLATION_SECTION_NAME_MISSING,
3179
+ where: joinWhere(`object "${objectName}"`, site.surface, `section "${heading}"`),
3180
+ path: `${site.path}[${i}]`,
3181
+ 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.`,
3182
+ 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.`
3183
+ });
3184
+ }
3185
+ }
3186
+ return findings;
3187
+ }
3188
+
3189
+ // src/flow-walk.ts
3190
+ var import_automation2 = require("@objectstack/spec/automation");
3191
+ function isRec9(v) {
3192
+ return !!v && typeof v === "object" && !Array.isArray(v);
3193
+ }
3194
+ function strName12(v) {
3195
+ return typeof v === "string" && v.length > 0 ? v : void 0;
3196
+ }
3043
3197
  var REGION_SLOTS = new Map(
3044
3198
  [...import_automation2.FLOW_REGION_SLOTS_BY_TYPE].map(([type, slots]) => [type, slots.map((s) => s.key)])
3045
3199
  );
3046
3200
  var REGION_CONFIG_KEYS = import_automation2.FLOW_REGION_CONFIG_KEYS;
3047
3201
  var MAX_REGION_DEPTH = 16;
3048
3202
  function flowNodeLabel(node, index) {
3049
- return strName11(node.label) ?? strName11(node.id) ?? `#${index}`;
3203
+ return strName12(node.label) ?? strName12(node.id) ?? `#${index}`;
3050
3204
  }
3051
3205
  function stripRegions(config) {
3052
- if (!isRec8(config)) return void 0;
3206
+ if (!isRec9(config)) return void 0;
3053
3207
  let out;
3054
3208
  for (const key of Object.keys(config)) {
3055
3209
  if (!REGION_CONFIG_KEYS.has(key)) continue;
@@ -3060,11 +3214,11 @@ function stripRegions(config) {
3060
3214
  }
3061
3215
  function walkFlowNodes(flow, flowPath) {
3062
3216
  const out = [];
3063
- if (!isRec8(flow)) return out;
3217
+ if (!isRec9(flow)) return out;
3064
3218
  const visitList = (nodes, basePath, trail, depth) => {
3065
3219
  if (!Array.isArray(nodes) || depth > MAX_REGION_DEPTH) return;
3066
3220
  nodes.forEach((raw, index) => {
3067
- if (!isRec8(raw)) return;
3221
+ if (!isRec9(raw)) return;
3068
3222
  const path = `${basePath}[${index}]`;
3069
3223
  out.push({
3070
3224
  node: raw,
@@ -3073,9 +3227,9 @@ function walkFlowNodes(flow, flowPath) {
3073
3227
  regionTrail: trail,
3074
3228
  depth
3075
3229
  });
3076
- const type = strName11(raw.type);
3230
+ const type = strName12(raw.type);
3077
3231
  const slots = type ? REGION_SLOTS.get(type) : void 0;
3078
- if (!slots || !isRec8(raw.config)) return;
3232
+ if (!slots || !isRec9(raw.config)) return;
3079
3233
  const config = raw.config;
3080
3234
  const here = `${type} "${flowNodeLabel(raw, index)}"`;
3081
3235
  for (const slot of slots) {
@@ -3083,8 +3237,8 @@ function walkFlowNodes(flow, flowPath) {
3083
3237
  if (slot === "branches") {
3084
3238
  if (!Array.isArray(value)) continue;
3085
3239
  value.forEach((branch, b) => {
3086
- if (!isRec8(branch)) return;
3087
- const branchName = strName11(branch.name) ?? `#${b}`;
3240
+ if (!isRec9(branch)) return;
3241
+ const branchName = strName12(branch.name) ?? `#${b}`;
3088
3242
  visitList(
3089
3243
  branch.nodes,
3090
3244
  `${path}.config.branches[${b}].nodes`,
@@ -3094,7 +3248,7 @@ function walkFlowNodes(flow, flowPath) {
3094
3248
  });
3095
3249
  continue;
3096
3250
  }
3097
- if (!isRec8(value)) continue;
3251
+ if (!isRec9(value)) continue;
3098
3252
  visitList(
3099
3253
  value.nodes,
3100
3254
  `${path}.config.${slot}.nodes`,
@@ -3319,7 +3473,7 @@ function asArray16(v) {
3319
3473
  }
3320
3474
  return [];
3321
3475
  }
3322
- function strName12(v) {
3476
+ function strName13(v) {
3323
3477
  return typeof v === "string" && v.length > 0 ? v : void 0;
3324
3478
  }
3325
3479
  function surfaceOf(v) {
@@ -3330,17 +3484,17 @@ function validateAiSurfaceAffinity(stack) {
3330
3484
  if (!stack || typeof stack !== "object") return findings;
3331
3485
  const skillsByName = /* @__PURE__ */ new Map();
3332
3486
  for (const skill of asArray16(stack.skills)) {
3333
- const n = strName12(skill.name);
3487
+ const n = strName13(skill.name);
3334
3488
  if (n) skillsByName.set(n, skill);
3335
3489
  }
3336
3490
  const agents = asArray16(stack.agents);
3337
3491
  for (let ai = 0; ai < agents.length; ai++) {
3338
3492
  const agent = agents[ai];
3339
- const agentName = strName12(agent.name) ?? `#${ai}`;
3493
+ const agentName = strName13(agent.name) ?? `#${ai}`;
3340
3494
  const agentSurface = surfaceOf(agent.surface);
3341
3495
  const skillRefs = Array.isArray(agent.skills) ? agent.skills : [];
3342
3496
  for (let si = 0; si < skillRefs.length; si++) {
3343
- const ref = strName12(skillRefs[si]);
3497
+ const ref = strName13(skillRefs[si]);
3344
3498
  if (!ref) continue;
3345
3499
  const skill = skillsByName.get(ref);
3346
3500
  if (!skill) continue;
@@ -3369,7 +3523,7 @@ function asArray17(v) {
3369
3523
  }
3370
3524
  return [];
3371
3525
  }
3372
- function strName13(v) {
3526
+ function strName14(v) {
3373
3527
  return typeof v === "string" && v.length > 0 ? v : void 0;
3374
3528
  }
3375
3529
  function distance6(a, b) {
@@ -3410,8 +3564,8 @@ function materialisesAsTool(action) {
3410
3564
  if (!ai || typeof ai !== "object") return false;
3411
3565
  const aiRec = ai;
3412
3566
  if (aiRec.exposed !== true) return false;
3413
- if (!strName13(aiRec.description)) return false;
3414
- const type = strName13(action.type);
3567
+ if (!strName14(aiRec.description)) return false;
3568
+ const type = strName14(action.type);
3415
3569
  if (!type || !HEADLESS_ACTION_TYPES.has(type)) return false;
3416
3570
  if (type === "script") return Boolean(action.target || action.body);
3417
3571
  return Boolean(action.target);
@@ -3419,12 +3573,12 @@ function materialisesAsTool(action) {
3419
3573
  function collectToolUniverse(stack) {
3420
3574
  const universe = new Set(import_system5.PLATFORM_PROVIDED_TOOL_NAMES);
3421
3575
  for (const tool of asArray17(stack.tools)) {
3422
- const n = strName13(tool.name);
3576
+ const n = strName14(tool.name);
3423
3577
  if (n) universe.add(n);
3424
3578
  }
3425
3579
  const addActionFamily = (actions) => {
3426
3580
  for (const action of asArray17(actions)) {
3427
- const n = strName13(action.name);
3581
+ const n = strName14(action.name);
3428
3582
  if (n && materialisesAsTool(action)) universe.add(`action_${n}`);
3429
3583
  }
3430
3584
  };
@@ -3438,7 +3592,7 @@ function collectUnexposedActionNames(stack) {
3438
3592
  const names = /* @__PURE__ */ new Set();
3439
3593
  const scan = (actions) => {
3440
3594
  for (const action of asArray17(actions)) {
3441
- const n = strName13(action.name);
3595
+ const n = strName14(action.name);
3442
3596
  if (n && !materialisesAsTool(action)) names.add(n);
3443
3597
  }
3444
3598
  };
@@ -3464,10 +3618,10 @@ function validateAiToolReferences(stack) {
3464
3618
  const skills = asArray17(stack.skills);
3465
3619
  for (let si = 0; si < skills.length; si++) {
3466
3620
  const skill = skills[si];
3467
- const skillName = strName13(skill.name) ?? `#${si}`;
3621
+ const skillName = strName14(skill.name) ?? `#${si}`;
3468
3622
  const refs = Array.isArray(skill.tools) ? skill.tools : [];
3469
3623
  for (let ti = 0; ti < refs.length; ti++) {
3470
- const ref = strName13(refs[ti]);
3624
+ const ref = strName14(refs[ti]);
3471
3625
  if (!ref || resolves(ref)) continue;
3472
3626
  const isPattern = ref.endsWith("*");
3473
3627
  const unexposed = !isPattern && ref.startsWith("action_") && unexposedActions.has(ref.slice("action_".length)) ? ref.slice("action_".length) : void 0;
@@ -3493,7 +3647,7 @@ function asArray18(v) {
3493
3647
  }
3494
3648
  return [];
3495
3649
  }
3496
- function strName14(v) {
3650
+ function strName15(v) {
3497
3651
  return typeof v === "string" && v.length > 0 ? v : void 0;
3498
3652
  }
3499
3653
  var PLATFORM_AGENT_NAMES = /* @__PURE__ */ new Set(["ask", "build", "data_chat", "metadata_assistant"]);
@@ -3503,7 +3657,7 @@ function validateAiAgentAuthoring(stack) {
3503
3657
  const agents = asArray18(stack.agents);
3504
3658
  for (let ai = 0; ai < agents.length; ai++) {
3505
3659
  const agent = agents[ai];
3506
- const name = strName14(agent.name) ?? `#${ai}`;
3660
+ const name = strName15(agent.name) ?? `#${ai}`;
3507
3661
  const isPlatformName = PLATFORM_AGENT_NAMES.has(name);
3508
3662
  const skillCount = Array.isArray(agent.skills) ? agent.skills.length : 0;
3509
3663
  findings.push({
@@ -3602,13 +3756,13 @@ var IMPLICIT_FIELDS2 = /* @__PURE__ */ new Set([
3602
3756
  "owner",
3603
3757
  "record_type"
3604
3758
  ]);
3605
- var isRec9 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3759
+ var isRec10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3606
3760
  function asArray19(v) {
3607
- if (Array.isArray(v)) return v.filter((x) => isRec9(x));
3608
- if (isRec9(v)) {
3761
+ if (Array.isArray(v)) return v.filter((x) => isRec10(x));
3762
+ if (isRec10(v)) {
3609
3763
  return Object.entries(v).map(([name, def]) => ({
3610
3764
  name,
3611
- ...isRec9(def) ? def : {}
3765
+ ...isRec10(def) ? def : {}
3612
3766
  }));
3613
3767
  }
3614
3768
  return [];
@@ -3754,7 +3908,7 @@ function validateHookBodyWrites(stack) {
3754
3908
  let objectFields = null;
3755
3909
  hooks.forEach((hook, hookIndex) => {
3756
3910
  const body = hook.body;
3757
- if (!isRec9(body) || body.language !== "js") return;
3911
+ if (!isRec10(body) || body.language !== "js") return;
3758
3912
  const source = body.source;
3759
3913
  if (typeof source !== "string" || source.trim() === "") return;
3760
3914
  const writes = extractHookBodyWrites(source).filter((w) => HOOK_APPLICABLE_IDS.has(w.patternId));
@@ -3825,13 +3979,13 @@ var ACTION_BODY_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_B
3825
3979
  var ACTION_RECORD_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_RECORD_WRITE_PATTERN_IDS.includes(p.id));
3826
3980
  var APPLICABLE_IDS = new Set(ACTION_BODY_WRITE_PATTERN_IDS);
3827
3981
  var RECORD_WRITE_IDS = new Set(ACTION_RECORD_WRITE_PATTERN_IDS);
3828
- var isRec10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3982
+ var isRec11 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3829
3983
  function asArray20(v) {
3830
- if (Array.isArray(v)) return v.filter((x) => isRec10(x));
3831
- if (isRec10(v)) {
3984
+ if (Array.isArray(v)) return v.filter((x) => isRec11(x));
3985
+ if (isRec11(v)) {
3832
3986
  return Object.entries(v).map(([name, def]) => ({
3833
3987
  name,
3834
- ...isRec10(def) ? def : {}
3988
+ ...isRec11(def) ? def : {}
3835
3989
  }));
3836
3990
  }
3837
3991
  return [];
@@ -3849,7 +4003,7 @@ function collectActionBodies(stack) {
3849
4003
  const type = typeof action.type === "string" ? action.type : "script";
3850
4004
  if (type !== "script") return;
3851
4005
  const body = action.body;
3852
- if (!isRec10(body) || body.language !== "js") return;
4006
+ if (!isRec11(body) || body.language !== "js") return;
3853
4007
  const source = body.source;
3854
4008
  if (typeof source !== "string" || source.trim() === "") return;
3855
4009
  const name = typeof action.name === "string" && action.name ? action.name : `#${index}`;
@@ -3868,7 +4022,7 @@ function collectActionBodies(stack) {
3868
4022
  }
3869
4023
  function validateActionBodyWrites(stack) {
3870
4024
  const findings = [];
3871
- if (!isRec10(stack)) return findings;
4025
+ if (!isRec11(stack)) return findings;
3872
4026
  const sites = collectActionBodies(stack);
3873
4027
  if (sites.length === 0) return findings;
3874
4028
  let objectFields = null;
@@ -3926,13 +4080,13 @@ function fixHint2(field, declared) {
3926
4080
  var import_shared3 = require("@objectstack/spec/shared");
3927
4081
  var FLOW_NODE_WRITE_UNKNOWN_FIELD = "flow-node-write-unknown-field";
3928
4082
  var FLOW_WRITE_NODE_TYPES = ["update_record", "create_record"];
3929
- var isRec11 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4083
+ var isRec12 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3930
4084
  function asArray21(v) {
3931
- if (Array.isArray(v)) return v.filter((x) => isRec11(x));
3932
- if (isRec11(v)) {
4085
+ if (Array.isArray(v)) return v.filter((x) => isRec12(x));
4086
+ if (isRec12(v)) {
3933
4087
  return Object.entries(v).map(([name, def]) => ({
3934
4088
  name,
3935
- ...isRec11(def) ? def : {}
4089
+ ...isRec12(def) ? def : {}
3936
4090
  }));
3937
4091
  }
3938
4092
  return [];
@@ -3945,7 +4099,7 @@ function readLiteralObjectName(config) {
3945
4099
  var COVERED_TYPES = new Set(FLOW_WRITE_NODE_TYPES);
3946
4100
  function validateFlowNodeWrites(stack) {
3947
4101
  const findings = [];
3948
- if (!isRec11(stack)) return findings;
4102
+ if (!isRec12(stack)) return findings;
3949
4103
  const flows = asArray21(stack.flows);
3950
4104
  if (flows.length === 0) return findings;
3951
4105
  let objectFields = null;
@@ -3954,10 +4108,10 @@ function validateFlowNodeWrites(stack) {
3954
4108
  const walked = walkFlowNodes(flow, `flows[${flowIndex}]`);
3955
4109
  walked.forEach(({ node, path: nodePath, regionTrail }, walkIndex) => {
3956
4110
  if (typeof node.type !== "string" || !COVERED_TYPES.has(node.type)) return;
3957
- const config = isRec11(node.config) ? node.config : void 0;
4111
+ const config = isRec12(node.config) ? node.config : void 0;
3958
4112
  if (!config) return;
3959
4113
  const fields = config.fields;
3960
- if (!isRec11(fields)) return;
4114
+ if (!isRec12(fields)) return;
3961
4115
  const written = Object.keys(fields);
3962
4116
  if (written.length === 0) return;
3963
4117
  const objectName = readLiteralObjectName(config);
@@ -4083,6 +4237,44 @@ function validateReadonlyFlowWrites(stack) {
4083
4237
  var import_node_module2 = require("module");
4084
4238
  var import_ui2 = require("@objectstack/spec/ui");
4085
4239
  var import_data5 = require("@objectstack/spec/data");
4240
+
4241
+ // src/zod-issue-format.ts
4242
+ var isRec13 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4243
+ var valueAtPath = (root, path) => {
4244
+ let cur = root;
4245
+ for (const key of path) {
4246
+ if (!isRec13(cur) && !Array.isArray(cur)) return void 0;
4247
+ cur = cur[key];
4248
+ }
4249
+ return cur;
4250
+ };
4251
+ var preview = (value) => {
4252
+ let text;
4253
+ try {
4254
+ text = JSON.stringify(value) ?? String(value);
4255
+ } catch {
4256
+ text = String(value);
4257
+ }
4258
+ return text.length > 80 ? `${text.slice(0, 77)}\u2026` : text;
4259
+ };
4260
+ function describeIssue(issue, root, depth = 0) {
4261
+ const value = depth === 0 ? valueAtPath(root, issue.path) : void 0;
4262
+ const seen = depth > 0 || issue.code === "custom" || issue.message.includes("received ") ? "" : value === void 0 ? " (nothing is set there)" : ` (received ${preview(value)})`;
4263
+ const armIssues = issue.code === "invalid_union" ? issue.errors : void 0;
4264
+ if (!armIssues || armIssues.length === 0) {
4265
+ return `${issue.message}${seen}`;
4266
+ }
4267
+ const arms = armIssues.map(
4268
+ (arm) => arm.map((inner) => {
4269
+ const where = inner.path.length ? `${inner.path.join(".")} \u2014 ` : "";
4270
+ return `${where}${describeIssue(inner, root, depth + 1)}`;
4271
+ }).join("; ")
4272
+ ).filter((text) => text.length > 0);
4273
+ if (arms.length === 0) return `${issue.message}${seen}`;
4274
+ return `${issue.message}${seen} \u2014 no accepted form matched: ` + arms.map((text, i) => `(${i + 1}) ${text}`).join(" ");
4275
+ }
4276
+
4277
+ // src/validate-react-page-props.ts
4086
4278
  var import_meta2 = {};
4087
4279
  var cachedTs2 = null;
4088
4280
  function loadTypeScript2() {
@@ -4187,35 +4379,78 @@ function filterAttrValue(tsc, sf, attr) {
4187
4379
  var REACT_CHART_FIELD_UNKNOWN = "react-chart-field-unknown";
4188
4380
  var REACT_CHART_AGGREGATE_INVALID = "react-chart-aggregate-invalid";
4189
4381
  var REACT_CHART_AXIS_UNKNOWN = "react-chart-axis-unknown";
4190
- var CHART_FUNCTIONS = ["count", "sum", "avg", "min", "max"];
4191
- var isRec12 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4382
+ var REACT_CHART_DRILLDOWN_INVALID = "react-chart-drilldown-invalid";
4383
+ function checkChartDrillDown(raw, push2) {
4384
+ if (raw === void 0 || raw === NOT_STATIC) return;
4385
+ if (!isRec14(raw)) {
4386
+ push2(
4387
+ "error",
4388
+ REACT_CHART_DRILLDOWN_INVALID,
4389
+ `drillDown must be a configuration object, not ${Array.isArray(raw) ? "an array" : typeof raw}.`,
4390
+ "Write drillDown={{ \u2026 }} \u2014 or, to turn the drill on with all defaults, drillDown={{}}. Omit the prop entirely to leave drill off."
4391
+ );
4392
+ return;
4393
+ }
4394
+ const parsed = import_ui2.ChartDrillDownSchema.safeParse(raw);
4395
+ if (parsed.success) return;
4396
+ for (const issue of parsed.error.issues) {
4397
+ const at = issue.path.length ? `drillDown.${issue.path.join(".")}` : "drillDown";
4398
+ push2(
4399
+ "error",
4400
+ REACT_CHART_DRILLDOWN_INVALID,
4401
+ `${at}: ${issue.message}`,
4402
+ "The drill config is declared by ChartDrillDownSchema (@objectstack/spec/ui) \u2014 the rejection above carries the fix."
4403
+ );
4404
+ }
4405
+ }
4406
+ function checkChartAggregate(raw, push2) {
4407
+ if (raw === void 0 || raw === NOT_STATIC) return;
4408
+ if (!isRec14(raw)) {
4409
+ push2(
4410
+ "error",
4411
+ REACT_CHART_AGGREGATE_INVALID,
4412
+ `aggregate must be a configuration object, not ${Array.isArray(raw) ? "an array" : typeof raw}.`,
4413
+ 'Write aggregate={{ function: "count", groupBy: "<field>" }} \u2014 or bind data={\u2026} instead to chart precomputed rows.'
4414
+ );
4415
+ return;
4416
+ }
4417
+ const groupByAbsent = raw.groupBy === void 0;
4418
+ if (groupByAbsent) {
4419
+ push2(
4420
+ "warning",
4421
+ REACT_CHART_AGGREGATE_INVALID,
4422
+ "aggregate.groupBy is not set, so the aggregate returns ONE ungrouped row and the chart plots a single point.",
4423
+ "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."
4424
+ );
4425
+ }
4426
+ const parsed = import_ui2.ChartAggregateSchema.safeParse(raw);
4427
+ if (parsed.success) return;
4428
+ for (const issue of parsed.error.issues) {
4429
+ if (groupByAbsent && issue.path[0] === "groupBy") continue;
4430
+ const at = issue.path.length ? `aggregate.${issue.path.join(".")}` : "aggregate";
4431
+ push2(
4432
+ "error",
4433
+ REACT_CHART_AGGREGATE_INVALID,
4434
+ `${at}: ${describeIssue(issue, raw)}`,
4435
+ "The aggregate is declared by ChartAggregateSchema (@objectstack/spec/ui) \u2014 the rejection above carries the fix."
4436
+ );
4437
+ }
4438
+ }
4439
+ var isRec14 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4192
4440
  var strOf = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
4193
4441
  function checkObjectChart(attrs, objectFields, findings) {
4194
4442
  const { values, where, path } = attrs;
4195
4443
  const push2 = (severity, rule, message, hint) => findings.push({ severity, rule, where, path, message, hint });
4444
+ checkChartDrillDown(values.get("drillDown"), push2);
4196
4445
  if (values.has("data")) return;
4197
4446
  const aggregate = values.get("aggregate");
4447
+ checkChartAggregate(aggregate, push2);
4198
4448
  if (aggregate === void 0 || aggregate === NOT_STATIC) return;
4199
- if (!isRec12(aggregate)) return;
4449
+ if (!isRec14(aggregate)) return;
4200
4450
  const fn = strOf(aggregate.function);
4201
4451
  const field = strOf(aggregate.field);
4202
4452
  const groupBy = aggregate.groupBy;
4203
- const groupByField = strOf(groupBy) ?? (isRec12(groupBy) ? strOf(groupBy.field) : void 0);
4204
- if (fn && !CHART_FUNCTIONS.includes(fn)) {
4205
- push2(
4206
- "error",
4207
- REACT_CHART_AGGREGATE_INVALID,
4208
- `aggregate.function "${fn}" is not an aggregation this chart can run.`,
4209
- `Use one of: ${CHART_FUNCTIONS.join(", ")}.`
4210
- );
4211
- } else if (fn && fn !== "count" && !field) {
4212
- push2(
4213
- "error",
4214
- REACT_CHART_AGGREGATE_INVALID,
4215
- `aggregate.function "${fn}" has no "field" to aggregate.`,
4216
- 'Add aggregate.field, or use function "count" (the only one that may omit it).'
4217
- );
4218
- }
4453
+ const groupByField = strOf(groupBy) ?? (isRec14(groupBy) ? strOf(groupBy.field) : void 0);
4219
4454
  const objectName = strOf(values.get("objectName"));
4220
4455
  const known = objectName ? objectFields.get(objectName) : void 0;
4221
4456
  if (objectName && known) {
@@ -4248,18 +4483,18 @@ function checkObjectChart(attrs, objectFields, findings) {
4248
4483
  );
4249
4484
  };
4250
4485
  const xAxisRaw = values.get("xAxis");
4251
- const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (isRec12(xAxisRaw) ? strOf(xAxisRaw.field) : void 0);
4486
+ const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (isRec14(xAxisRaw) ? strOf(xAxisRaw.field) : void 0);
4252
4487
  const categoryProp = values.has("xAxisKey") ? "xAxisKey" : "xAxis.field";
4253
4488
  axisRef(categoryAxis, categoryProp);
4254
4489
  const yAxisRaw = values.get("yAxis");
4255
4490
  const yAxisList = Array.isArray(yAxisRaw) ? yAxisRaw : yAxisRaw !== void 0 ? [yAxisRaw] : [];
4256
4491
  for (const a of yAxisList) {
4257
- axisRef(strOf(a) ?? (isRec12(a) ? strOf(a.field) : void 0), "yAxis[].field");
4492
+ axisRef(strOf(a) ?? (isRec14(a) ? strOf(a.field) : void 0), "yAxis[].field");
4258
4493
  }
4259
4494
  const series = values.get("series");
4260
4495
  if (Array.isArray(series)) {
4261
4496
  for (const s of series) {
4262
- if (!isRec12(s)) continue;
4497
+ if (!isRec14(s)) continue;
4263
4498
  const dataKey = strOf(s.dataKey);
4264
4499
  axisRef(dataKey ?? strOf(s.name), dataKey ? "series[].dataKey" : "series[].name");
4265
4500
  }
@@ -4315,7 +4550,7 @@ function subformFieldRefs(value, basePath) {
4315
4550
  if (!Array.isArray(value)) return { child, parent };
4316
4551
  for (let i = 0; i < value.length; i++) {
4317
4552
  const sub = value[i];
4318
- if (!isRec12(sub)) continue;
4553
+ if (!isRec14(sub)) continue;
4319
4554
  const at = (key) => `${basePath}[${i}].${key}`;
4320
4555
  child.push({
4321
4556
  objectName: strOf(sub.childObject),
@@ -4360,20 +4595,20 @@ function reactFieldRefs(spec, values, basePath) {
4360
4595
  }
4361
4596
  for (const key of spec.nestedFields ?? []) {
4362
4597
  const v = readable(key);
4363
- if (isRec12(v)) own.push(...fieldRefsFrom(v.fields, at(`${key}.fields`)));
4598
+ if (isRec14(v)) own.push(...fieldRefsFrom(v.fields, at(`${key}.fields`)));
4364
4599
  }
4365
4600
  for (const key of spec.sections ?? []) {
4366
4601
  const v = readable(key);
4367
4602
  if (!Array.isArray(v)) continue;
4368
4603
  for (let i = 0; i < v.length; i++) {
4369
4604
  const section = v[i];
4370
- if (!isRec12(section)) continue;
4605
+ if (!isRec14(section)) continue;
4371
4606
  own.push(...fieldRefsFrom(section.fields, at(`${key}[${i}].fields`)));
4372
4607
  }
4373
4608
  }
4374
4609
  for (const key of spec.keyedByField ?? []) {
4375
4610
  const v = readable(key);
4376
- if (!isRec12(v)) continue;
4611
+ if (!isRec14(v)) continue;
4377
4612
  for (const k of Object.keys(v)) own.push({ name: k, path: at(`${key}.${k}`) });
4378
4613
  }
4379
4614
  for (const key of spec.filterArrays ?? []) {
@@ -4563,6 +4798,14 @@ var REFERENCE_INTEGRITY_RULES = [
4563
4798
  // `component` (an unregistered ref renders a named diagnostic, not silence).
4564
4799
  { name: "validateNavTargetRefs", run: validateNavTargetRefs },
4565
4800
  { name: "validateTranslationReferences", run: validateTranslationReferences },
4801
+ // The same family from the other end (#5417). Its sibling above asks "does
4802
+ // this bundle key resolve?"; this one asks "is there a key at all?" — a form
4803
+ // section authored with a `label` and no `name` renders a heading that
4804
+ // `_sections` (keyed by name) can never address, so neither the orphan check
4805
+ // nor the coverage walk can see it. A reference that cannot be written is
4806
+ // still a reference question, and warning-only for the same reason its
4807
+ // sibling is: one heading stays in the source locale, nothing breaks.
4808
+ { name: "validateTranslatableSections", run: validateTranslatableSections },
4566
4809
  { name: "validateFlowTemplatePaths", run: validateFlowTemplatePaths },
4567
4810
  { name: "validateAiSurfaceAffinity", run: validateAiSurfaceAffinity },
4568
4811
  { name: "validateAiToolReferences", run: validateAiToolReferences },
@@ -4647,6 +4890,90 @@ function validateReferenceIntegrity(stack) {
4647
4890
  return findings;
4648
4891
  }
4649
4892
 
4893
+ // src/validate-component-props.ts
4894
+ var import_ui3 = require("@objectstack/spec/ui");
4895
+ var import_spec = require("@objectstack/spec");
4896
+ var COMPONENT_PROPS_UNKNOWN_KEY = "component-props-unknown-key";
4897
+ var COMPONENT_PROPS_INVALID = "component-props-invalid";
4898
+ function isRec15(v) {
4899
+ return !!v && typeof v === "object" && !Array.isArray(v);
4900
+ }
4901
+ function strName16(v) {
4902
+ return typeof v === "string" && v.length > 0 ? v : void 0;
4903
+ }
4904
+ function asArray24(v) {
4905
+ if (Array.isArray(v)) return v;
4906
+ if (v && typeof v === "object") {
4907
+ return Object.entries(v).map(([name, def]) => ({ name, ...def }));
4908
+ }
4909
+ return [];
4910
+ }
4911
+ var PROPS_SCHEMAS = import_ui3.ComponentPropsMap;
4912
+ var DATASOURCE_SUPPLIED_PROP = "object";
4913
+ function suppliedByDataSource(issue, component) {
4914
+ if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false;
4915
+ const dataSource = isRec15(component.dataSource) ? component.dataSource : void 0;
4916
+ return strName16(dataSource?.object) !== void 0;
4917
+ }
4918
+ function validateComponentProps(stack) {
4919
+ const findings = [];
4920
+ if (!isRec15(stack)) return findings;
4921
+ const pages = asArray24(stack.pages);
4922
+ for (let pi = 0; pi < pages.length; pi++) {
4923
+ const page = pages[pi];
4924
+ if (!isRec15(page)) continue;
4925
+ const pageName = strName16(page.name) ?? `#${pi}`;
4926
+ for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
4927
+ const type = strName16(component.type);
4928
+ if (!type) continue;
4929
+ const schema = PROPS_SCHEMAS[type];
4930
+ if (!schema) continue;
4931
+ const props = isRec15(component.properties) ? component.properties : void 0;
4932
+ if (!props) continue;
4933
+ const where = `page "${pageName}" \xB7 ${type}`;
4934
+ const base = `${path}.properties`;
4935
+ for (const f of (0, import_spec.lintUnknownKeysAgainstSchema)(schema, props, type, base)) {
4936
+ findings.push({
4937
+ severity: "warning",
4938
+ rule: COMPONENT_PROPS_UNKNOWN_KEY,
4939
+ where,
4940
+ path: f.path,
4941
+ 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}\`?` : ""),
4942
+ 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.`)
4943
+ });
4944
+ }
4945
+ const parsed = schema.safeParse(props);
4946
+ if (parsed.success) continue;
4947
+ for (const issue of parsed.error?.issues ?? []) {
4948
+ if (suppliedByDataSource(issue, component)) continue;
4949
+ const at = issue.path.length ? `${base}.${issue.path.join(".")}` : base;
4950
+ if (issue.code === "unrecognized_keys") {
4951
+ for (const key of issue.keys ?? []) {
4952
+ findings.push({
4953
+ severity: "warning",
4954
+ rule: COMPONENT_PROPS_UNKNOWN_KEY,
4955
+ where,
4956
+ path: `${at}.${key}`,
4957
+ message: `\`${key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui): ${issue.message}`,
4958
+ hint: `Remove \`${key}\`, or declare it on \`${type}\`'s props schema if the component honours it.`
4959
+ });
4960
+ }
4961
+ continue;
4962
+ }
4963
+ findings.push({
4964
+ severity: "warning",
4965
+ rule: COMPONENT_PROPS_INVALID,
4966
+ where,
4967
+ path: at,
4968
+ message: `${at.slice(base.length + 1) || "properties"}: ${describeIssue(issue, props)}`,
4969
+ 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).`
4970
+ });
4971
+ }
4972
+ }
4973
+ }
4974
+ return findings;
4975
+ }
4976
+
4650
4977
  // src/validate-responsive-styles.ts
4651
4978
  var STYLE_NODE_MISSING_ID = "style-node-missing-id";
4652
4979
  var STYLE_CLASSNAME_TAILWIND = "style-classname-tailwind";
@@ -4850,7 +5177,7 @@ function looksLikeTailwind(className) {
4850
5177
  return false;
4851
5178
  });
4852
5179
  }
4853
- function asArray24(v) {
5180
+ function asArray25(v) {
4854
5181
  if (Array.isArray(v)) return v;
4855
5182
  if (v && typeof v === "object") {
4856
5183
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -4943,13 +5270,13 @@ function checkNode(node, pageName, path, findings) {
4943
5270
  }
4944
5271
  function validateResponsiveStyles(stack) {
4945
5272
  const findings = [];
4946
- const pages = asArray24(stack.pages);
5273
+ const pages = asArray25(stack.pages);
4947
5274
  for (let p = 0; p < pages.length; p++) {
4948
5275
  const page = pages[p];
4949
5276
  const pageName = typeof page.name === "string" ? page.name : `pages[${p}]`;
4950
- const regions = asArray24(page.regions);
5277
+ const regions = asArray25(page.regions);
4951
5278
  for (let r = 0; r < regions.length; r++) {
4952
- const components = asArray24(regions[r].components);
5279
+ const components = asArray25(regions[r].components);
4953
5280
  for (let c = 0; c < components.length; c++) {
4954
5281
  checkNode(components[c], pageName, `pages[${p}].regions[${r}].components[${c}]`, findings);
4955
5282
  }
@@ -4960,10 +5287,10 @@ function validateResponsiveStyles(stack) {
4960
5287
 
4961
5288
  // src/validate-jsx-pages.ts
4962
5289
  var import_sdui_parser = require("@objectstack/sdui-parser");
4963
- var asArray25 = (v) => Array.isArray(v) ? v : [];
5290
+ var asArray26 = (v) => Array.isArray(v) ? v : [];
4964
5291
  function validateJsxPages(stack, opts = {}) {
4965
5292
  const findings = [];
4966
- const pages = asArray25(stack.pages);
5293
+ const pages = asArray26(stack.pages);
4967
5294
  for (let p = 0; p < pages.length; p++) {
4968
5295
  const page = pages[p];
4969
5296
  if (!page || page.kind !== "html" && page.kind !== "jsx") continue;
@@ -5011,10 +5338,10 @@ function loadSucraseTransform() {
5011
5338
  }
5012
5339
  return cachedTransform;
5013
5340
  }
5014
- var asArray26 = (v) => Array.isArray(v) ? v : [];
5341
+ var asArray27 = (v) => Array.isArray(v) ? v : [];
5015
5342
  function validateReactPages(stack) {
5016
5343
  const findings = [];
5017
- const pages = asArray26(stack.pages);
5344
+ const pages = asArray27(stack.pages);
5018
5345
  for (let p = 0; p < pages.length; p++) {
5019
5346
  const page = pages[p];
5020
5347
  if (!page || page.kind !== "react") continue;
@@ -5051,11 +5378,11 @@ function validateReactPages(stack) {
5051
5378
 
5052
5379
  // src/validate-page-source-styling.ts
5053
5380
  var PAGE_SOURCE_CLASSNAME = "page-source-className-tailwind";
5054
- var asArray27 = (v) => Array.isArray(v) ? v : [];
5381
+ var asArray28 = (v) => Array.isArray(v) ? v : [];
5055
5382
  var CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g;
5056
5383
  function validatePageSourceStyling(stack) {
5057
5384
  const findings = [];
5058
- const pages = asArray27(stack.pages);
5385
+ const pages = asArray28(stack.pages);
5059
5386
  for (let p = 0; p < pages.length; p++) {
5060
5387
  const page = pages[p];
5061
5388
  if (!page) continue;
@@ -5083,7 +5410,7 @@ function validatePageSourceStyling(stack) {
5083
5410
  // src/validate-capability-references.ts
5084
5411
  var import_security = require("@objectstack/spec/security");
5085
5412
  var CAPABILITY_REFERENCE_UNKNOWN = "capability-reference-unknown";
5086
- function asArray28(v) {
5413
+ function asArray29(v) {
5087
5414
  if (Array.isArray(v)) return v;
5088
5415
  if (v && typeof v === "object") {
5089
5416
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5108,13 +5435,13 @@ function validateCapabilityReferences(stack) {
5108
5435
  const findings = [];
5109
5436
  if (!stack || typeof stack !== "object") return findings;
5110
5437
  const known = new Set(import_security.PLATFORM_CAPABILITY_NAMES);
5111
- for (const cap of asArray28(stack.capabilities)) {
5438
+ for (const cap of asArray29(stack.capabilities)) {
5112
5439
  if (typeof cap.name === "string" && cap.name.length > 0) known.add(cap.name);
5113
5440
  }
5114
- for (const ps of asArray28(stack.permissions)) {
5441
+ for (const ps of asArray29(stack.permissions)) {
5115
5442
  for (const cap of asCapArray(ps.systemPermissions)) known.add(cap);
5116
5443
  }
5117
- for (const seed of asArray28(stack.data)) {
5444
+ for (const seed of asArray29(stack.data)) {
5118
5445
  if (seed.object !== "sys_capability") continue;
5119
5446
  for (const rec of Array.isArray(seed.records) ? seed.records : []) {
5120
5447
  const name = rec?.name;
@@ -5133,7 +5460,7 @@ function validateCapabilityReferences(stack) {
5133
5460
  hint
5134
5461
  });
5135
5462
  };
5136
- const objects = asArray28(stack.objects);
5463
+ const objects = asArray29(stack.objects);
5137
5464
  for (let i = 0; i < objects.length; i++) {
5138
5465
  const obj = objects[i];
5139
5466
  if (!obj || typeof obj !== "object") continue;
@@ -5142,27 +5469,27 @@ function validateCapabilityReferences(stack) {
5142
5469
  for (const { cap, key } of flattenObjectRequired(obj.requiredPermissions)) {
5143
5470
  flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ""}`);
5144
5471
  }
5145
- const fields = asArray28(obj.fields);
5472
+ const fields = asArray29(obj.fields);
5146
5473
  for (const f of fields) {
5147
5474
  const fname = typeof f.name === "string" ? f.name : "(field)";
5148
5475
  for (const cap of asCapArray(f.requiredPermissions)) {
5149
5476
  flag(cap, `field "${objName}.${fname}"`, `${objPath}.fields.${fname}.requiredPermissions`);
5150
5477
  }
5151
5478
  }
5152
- for (const [ai, action] of asArray28(obj.actions).entries()) {
5479
+ for (const [ai, action] of asArray29(obj.actions).entries()) {
5153
5480
  const aName = typeof action.name === "string" ? action.name : `(action ${ai})`;
5154
5481
  for (const cap of asCapArray(action.requiredPermissions)) {
5155
5482
  flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`);
5156
5483
  }
5157
5484
  }
5158
5485
  }
5159
- for (const [i, action] of asArray28(stack.actions).entries()) {
5486
+ for (const [i, action] of asArray29(stack.actions).entries()) {
5160
5487
  const aName = typeof action.name === "string" ? action.name : `(action ${i})`;
5161
5488
  for (const cap of asCapArray(action.requiredPermissions)) {
5162
5489
  flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`);
5163
5490
  }
5164
5491
  }
5165
- const apps = asArray28(stack.apps);
5492
+ const apps = asArray29(stack.apps);
5166
5493
  for (let i = 0; i < apps.length; i++) {
5167
5494
  const app = apps[i];
5168
5495
  if (!app || typeof app !== "object") continue;
@@ -5189,11 +5516,14 @@ function validateCapabilityReferences(stack) {
5189
5516
  }
5190
5517
 
5191
5518
  // src/validate-flow-trigger-readiness.ts
5519
+ var import_automation3 = require("@objectstack/spec/automation");
5192
5520
  var FLOW_TRIGGER_UNKNOWN_OBJECT = "flow-trigger-unknown-object";
5193
5521
  var FLOW_DRAFT_STATUS_AMBIGUOUS = "flow-draft-status-ambiguous";
5194
5522
  var FLOW_TRIGGER_UNKNOWN_EVENT = "flow-trigger-unknown-event";
5523
+ var FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID = "flow-time-relative-descriptor-invalid";
5524
+ var FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = "flow-time-relative-descriptor-unroutable";
5195
5525
  var VALID_RECORD_TRIGGER = /^record-(?:before|after)-(?:create|insert|update|delete|write)$/;
5196
- function asArray29(v) {
5526
+ function asArray30(v) {
5197
5527
  if (Array.isArray(v)) return v;
5198
5528
  if (v && typeof v === "object") {
5199
5529
  return Object.entries(v).map(([name, def]) => ({
@@ -5203,6 +5533,12 @@ function asArray29(v) {
5203
5533
  }
5204
5534
  return [];
5205
5535
  }
5536
+ function renderNonObject(v) {
5537
+ const t = typeof v;
5538
+ if (t === "string" || t === "number" || t === "boolean") return `${JSON.stringify(v)} (a ${t})`;
5539
+ if (t === "bigint") return `${String(v)}n (a bigint)`;
5540
+ return `a ${t}`;
5541
+ }
5206
5542
  function startNodeOf(flow) {
5207
5543
  const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
5208
5544
  const index = nodes.findIndex((n) => n?.type === "start");
@@ -5210,10 +5546,10 @@ function startNodeOf(flow) {
5210
5546
  }
5211
5547
  function validateFlowTriggerReadiness(stack) {
5212
5548
  const findings = [];
5213
- const flows = asArray29(stack.flows);
5549
+ const flows = asArray30(stack.flows);
5214
5550
  if (flows.length === 0) return findings;
5215
5551
  const objectNames = new Set(
5216
- asArray29(stack.objects).map((o) => typeof o.name === "string" ? o.name : void 0).filter((n) => !!n)
5552
+ asArray30(stack.objects).map((o) => typeof o.name === "string" ? o.name : void 0).filter((n) => !!n)
5217
5553
  );
5218
5554
  flows.forEach((flow, flowIndex) => {
5219
5555
  const flowName = typeof flow.name === "string" ? flow.name : `#${flowIndex}`;
@@ -5250,10 +5586,35 @@ function validateFlowTriggerReadiness(stack) {
5250
5586
  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.`
5251
5587
  });
5252
5588
  }
5589
+ const parsed = import_automation3.TimeRelativeTriggerSchema.safeParse(tr);
5590
+ if (!parsed.success) {
5591
+ const problems = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message.replace(/\s+/g, " ").trim()}`).join("; ");
5592
+ findings.push({
5593
+ // `error` (#5762): the verdict is `TimeRelativeTriggerSchema`'s, and it
5594
+ // is the same schema the trigger safeParses at bind time. A descriptor
5595
+ // it refuses is refused at bind too — the sweep is never installed, on
5596
+ // every deployment, with no installed package able to change the
5597
+ // answer. Nothing is left for the author to weigh.
5598
+ severity: "error",
5599
+ rule: FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
5600
+ where: `flow "${flowName}" \u203A start node`,
5601
+ path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`,
5602
+ 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}`,
5603
+ 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.`
5604
+ });
5605
+ }
5253
5606
  }
5254
5607
  if (start && isRecordTriggered2 && !VALID_RECORD_TRIGGER.test((triggerType ?? "").trim())) {
5255
5608
  findings.push({
5256
- severity: "warning",
5609
+ // `error` (#5762). The token grammar is CLOSED and local: the engine
5610
+ // routes any `record-`-prefixed string to the record-change trigger by a
5611
+ // hardcoded prefix test (no registry lookup, so installing a package
5612
+ // cannot claim a new `record-*` token), and that trigger maps the token
5613
+ // with `triggerTypeToHookEvents` — the same regex this file's
5614
+ // `VALID_RECORD_TRIGGER` mirrors. Off-grammar means zero hook events,
5615
+ // which means bound-to-nothing on every deployment. Unlike an object
5616
+ // name, there is no other-package reading that rescues it.
5617
+ severity: "error",
5257
5618
  rule: FLOW_TRIGGER_UNKNOWN_EVENT,
5258
5619
  where: `flow "${flowName}" \u203A start node`,
5259
5620
  path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
@@ -5263,7 +5624,14 @@ function validateFlowTriggerReadiness(stack) {
5263
5624
  }
5264
5625
  if (start && isArrayRecordTriggered) {
5265
5626
  findings.push({
5266
- severity: "warning",
5627
+ // `error` (#5762), same id and same reason as 1c: an array maps to no
5628
+ // hook event either. The engine routes it to the record-change trigger
5629
+ // for the express purpose of making it loud, and its own comment names
5630
+ // THIS rule as the primary catch — a primary catch that only warns is
5631
+ // the "declared ≠ enforced" shape the registry's tier exists to close.
5632
+ // Multi-event arrays are deferred, not unsupported-by-accident (#3457),
5633
+ // so if they land the grammar widens here in the same commit.
5634
+ severity: "error",
5267
5635
  rule: FLOW_TRIGGER_UNKNOWN_EVENT,
5268
5636
  where: `flow "${flowName}" \u203A start node`,
5269
5637
  path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
@@ -5271,6 +5639,25 @@ function validateFlowTriggerReadiness(stack) {
5271
5639
  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).`
5272
5640
  });
5273
5641
  }
5642
+ if (start && config.timeRelative != null && typeof config.timeRelative !== "object") {
5643
+ 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;
5644
+ 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.`;
5645
+ findings.push({
5646
+ // `error` (#5762). The criterion IS the engine's routing predicate, so a
5647
+ // value that fails it is not routed to the time-relative trigger by any
5648
+ // deployment — the strongest verdict in this file, and the one case with
5649
+ // no runtime channel to fall back on (not even the bind-time warn 1b-ii
5650
+ // moves earlier). Note the two consequences below are both defects: one
5651
+ // never fires, the other silently drops the descriptor. Neither is a
5652
+ // shape the author can have meant, so both gate.
5653
+ severity: "error",
5654
+ rule: FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
5655
+ where: `flow "${flowName}" \u203A start node`,
5656
+ path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`,
5657
+ 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}`,
5658
+ 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.`
5659
+ });
5660
+ }
5274
5661
  if (isAutoTriggered && (flow.status == null || flow.status === "draft")) {
5275
5662
  findings.push({
5276
5663
  severity: "warning",
@@ -5286,9 +5673,9 @@ function validateFlowTriggerReadiness(stack) {
5286
5673
  }
5287
5674
 
5288
5675
  // src/validate-approval-approvers.ts
5289
- var import_automation3 = require("@objectstack/spec/automation");
5290
- var import_spec = require("@objectstack/spec");
5291
- var import_formula2 = require("@objectstack/formula");
5676
+ var import_automation4 = require("@objectstack/spec/automation");
5677
+ var import_spec2 = require("@objectstack/spec");
5678
+ var import_formula3 = require("@objectstack/formula");
5292
5679
  var APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = "approval-approver-not-membership-tier";
5293
5680
  var APPROVAL_APPROVER_TYPE_DEPRECATED = "approval-approver-type-deprecated";
5294
5681
  var APPROVAL_APPROVER_TYPE_UNKNOWN = "approval-approver-type-unknown";
@@ -5302,13 +5689,13 @@ var APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED = "approval-approver-cross-org-unsup
5302
5689
  var EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
5303
5690
  var RESERVED_OUTPUT_KEYS = /* @__PURE__ */ new Set(["decision", "requestId"]);
5304
5691
  var GROUP_ROUTED_TYPES = /* @__PURE__ */ new Set(["position", "team", "department"]);
5305
- var MEMBERSHIP_TIERS = new Set(import_spec.BUILTIN_MEMBERSHIP_ROLES);
5306
- var MEMBERSHIP_TIER_LIST = import_spec.BUILTIN_MEMBERSHIP_ROLES.join("/");
5692
+ var MEMBERSHIP_TIERS = new Set(import_spec2.BUILTIN_MEMBERSHIP_ROLES);
5693
+ var MEMBERSHIP_TIER_LIST = import_spec2.BUILTIN_MEMBERSHIP_ROLES.join("/");
5307
5694
  var TYPE_FIX = {
5308
5695
  business_unit: "department",
5309
5696
  bu: "department"
5310
5697
  };
5311
- function asArray30(v) {
5698
+ function asArray31(v) {
5312
5699
  if (Array.isArray(v)) return v;
5313
5700
  if (v && typeof v === "object") {
5314
5701
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5318,8 +5705,8 @@ function asArray30(v) {
5318
5705
  function validateApprovalApprovers(stack) {
5319
5706
  const findings = [];
5320
5707
  if (!stack || typeof stack !== "object") return findings;
5321
- const flows = asArray30(stack.flows);
5322
- const validTypes = new Set(import_automation3.ApproverType.options);
5708
+ const flows = asArray31(stack.flows);
5709
+ const validTypes = new Set(import_automation4.ApproverType.options);
5323
5710
  for (let fi = 0; fi < flows.length; fi++) {
5324
5711
  const flow = flows[fi];
5325
5712
  if (!flow || typeof flow !== "object") continue;
@@ -5327,7 +5714,7 @@ function validateApprovalApprovers(stack) {
5327
5714
  const walked = walkFlowNodes(flow, `flows[${fi}]`);
5328
5715
  for (let ni = 0; ni < walked.length; ni++) {
5329
5716
  const { node, path: nodePath } = walked[ni];
5330
- if (!node || node.type !== import_automation3.APPROVAL_NODE_TYPE) continue;
5717
+ if (!node || node.type !== import_automation4.APPROVAL_NODE_TYPE) continue;
5331
5718
  const nodeId = typeof node.id === "string" ? node.id : `(node ${ni})`;
5332
5719
  const cfg = node.config ?? {};
5333
5720
  const approvers = Array.isArray(cfg.approvers) ? cfg.approvers : [];
@@ -5345,12 +5732,12 @@ function validateApprovalApprovers(stack) {
5345
5732
  rule: APPROVAL_APPROVER_TYPE_UNKNOWN,
5346
5733
  where,
5347
5734
  path: `${path}.type`,
5348
- message: `approver type '${type}' is not an ApproverType (${import_automation3.ApproverType.options.join(" | ")}).`,
5735
+ message: `approver type '${type}' is not an ApproverType (${import_automation4.ApproverType.options.join(" | ")}).`,
5349
5736
  hint: fix ? `Use the spec value: { type: '${fix}', value: '${value}' }.` : `Pick one of the spec values; unmapped types degrade to an inert '${type}:${value}' literal at runtime.`
5350
5737
  });
5351
5738
  continue;
5352
5739
  }
5353
- const canonical = (0, import_automation3.canonicalApproverType)(type);
5740
+ const canonical = (0, import_automation4.canonicalApproverType)(type);
5354
5741
  if (canonical === "expression") {
5355
5742
  const source = value.trim();
5356
5743
  if (!source) {
@@ -5363,7 +5750,7 @@ function validateApprovalApprovers(stack) {
5363
5750
  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.`
5364
5751
  });
5365
5752
  } else {
5366
- const parsed = (0, import_formula2.collectCelRootIdentifiers)(source);
5753
+ const parsed = (0, import_formula3.collectCelRootIdentifiers)(source);
5367
5754
  if (!parsed.ok) {
5368
5755
  findings.push({
5369
5756
  severity: "error",
@@ -5407,8 +5794,8 @@ function validateApprovalApprovers(stack) {
5407
5794
  message: `approver { type: '${type}', value: '${value}' } resolves against the better-auth org-membership tier (sys_member.role: ${MEMBERSHIP_TIER_LIST}) \u2014 '${value}' is not a membership tier, so this approver matches nobody and the request stalls.`,
5408
5795
  hint: `If '${value}' is an org position, author { type: 'position', value: '${value}' } (resolved via sys_user_position, ADR-0090 D3). Keep type 'org_membership_level' only for membership tiers (${MEMBERSHIP_TIER_LIST}) \u2014 the vocabulary is closed (ADR-0108), so a business role is always a position.`
5409
5796
  });
5410
- } else if (type in import_automation3.DEPRECATED_APPROVER_TYPES) {
5411
- const fix = (0, import_automation3.canonicalApproverType)(type);
5797
+ } else if (type in import_automation4.DEPRECATED_APPROVER_TYPES) {
5798
+ const fix = (0, import_automation4.canonicalApproverType)(type);
5412
5799
  findings.push({
5413
5800
  severity: "warning",
5414
5801
  rule: APPROVAL_APPROVER_TYPE_DEPRECATED,
@@ -5417,7 +5804,7 @@ function validateApprovalApprovers(stack) {
5417
5804
  message: `approver type '${type}' is the deprecated spelling of '${fix}' (ADR-0090 D3) and is removed in the next major.`,
5418
5805
  hint: `Author { type: '${fix}', value: '${value}' }. It resolves identically today.`
5419
5806
  });
5420
- } else if (import_automation3.APPROVER_VALUE_BINDINGS[canonical]?.source === "unsupported") {
5807
+ } else if (import_automation4.APPROVER_VALUE_BINDINGS[canonical]?.source === "unsupported") {
5421
5808
  findings.push({
5422
5809
  severity: "warning",
5423
5810
  rule: APPROVAL_APPROVER_TYPE_UNSUPPORTED,
@@ -5428,7 +5815,7 @@ function validateApprovalApprovers(stack) {
5428
5815
  });
5429
5816
  }
5430
5817
  const declaredOrg = a.organization;
5431
- if (typeof declaredOrg === "string" && declaredOrg.trim() !== "" && import_automation3.ApproverType.options.includes(canonical) && !(0, import_automation3.approverTypeIsOrgScoped)(canonical)) {
5818
+ if (typeof declaredOrg === "string" && declaredOrg.trim() !== "" && import_automation4.ApproverType.options.includes(canonical) && !(0, import_automation4.approverTypeIsOrgScoped)(canonical)) {
5432
5819
  findings.push({
5433
5820
  severity: "error",
5434
5821
  rule: APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
@@ -5442,7 +5829,7 @@ function validateApprovalApprovers(stack) {
5442
5829
  const routable = approvers.filter(
5443
5830
  (a) => a && typeof a === "object" && typeof a.type === "string"
5444
5831
  );
5445
- if (routable.length > 0 && routable.every((a) => GROUP_ROUTED_TYPES.has((0, import_automation3.canonicalApproverType)(String(a.type))))) {
5832
+ if (routable.length > 0 && routable.every((a) => GROUP_ROUTED_TYPES.has((0, import_automation4.canonicalApproverType)(String(a.type))))) {
5446
5833
  const locks = cfg.lockRecord !== false;
5447
5834
  findings.push({
5448
5835
  severity: "info",
@@ -5454,7 +5841,7 @@ function validateApprovalApprovers(stack) {
5454
5841
  });
5455
5842
  }
5456
5843
  const hasExpression = approvers.some(
5457
- (a) => a && typeof a === "object" && (0, import_automation3.canonicalApproverType)(String(a.type ?? "")) === "expression"
5844
+ (a) => a && typeof a === "object" && (0, import_automation4.canonicalApproverType)(String(a.type ?? "")) === "expression"
5458
5845
  );
5459
5846
  if (hasExpression && cfg.onEmptyApprovers == null) {
5460
5847
  findings.push({
@@ -5466,7 +5853,7 @@ function validateApprovalApprovers(stack) {
5466
5853
  hint: `Declare the empty-slate policy explicitly: onEmptyApprovers: 'admin_rescue' (hold for admin takeover), 'fail' (fail the node \u2014 config bug), or 'auto_approve' (wave through, output.autoApproved = true).`
5467
5854
  });
5468
5855
  }
5469
- const declaredOutputs = (0, import_automation3.normalizeDecisionOutputs)(cfg.decisionOutputs).map((d) => d.key);
5856
+ const declaredOutputs = (0, import_automation4.normalizeDecisionOutputs)(cfg.decisionOutputs).map((d) => d.key);
5470
5857
  const reserved = declaredOutputs.filter((k) => RESERVED_OUTPUT_KEYS.has(k));
5471
5858
  if (reserved.length) {
5472
5859
  findings.push({
@@ -5501,7 +5888,7 @@ function validateApprovalApprovers(stack) {
5501
5888
  var import_data6 = require("@objectstack/spec/data");
5502
5889
  var TITLE_FORMAT_RETIRED = "title-format-retired";
5503
5890
  var TITLE_UNRESOLVABLE = "title-unresolvable";
5504
- function asArray31(v) {
5891
+ function asArray32(v) {
5505
5892
  if (Array.isArray(v)) return v;
5506
5893
  if (v && typeof v === "object") {
5507
5894
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5510,7 +5897,7 @@ function asArray31(v) {
5510
5897
  }
5511
5898
  function validateRecordTitle(stack) {
5512
5899
  const findings = [];
5513
- const objects = asArray31(stack.objects);
5900
+ const objects = asArray32(stack.objects);
5514
5901
  for (let i = 0; i < objects.length; i++) {
5515
5902
  const obj = objects[i];
5516
5903
  const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
@@ -5546,7 +5933,7 @@ var FIELD_GROUP_UNDECLARED = "field-group-undeclared";
5546
5933
  var FIELD_GROUP_EMPTY = "field-group-empty";
5547
5934
  var FIELD_GROUP_SHADOWED = "field-group-shadowed";
5548
5935
  var SEMANTIC_ROLE_FIELD_UNKNOWN = "semantic-role-field-unknown";
5549
- function asArray32(v) {
5936
+ function asArray33(v) {
5550
5937
  if (Array.isArray(v)) return v;
5551
5938
  if (v && typeof v === "object") {
5552
5939
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5555,7 +5942,7 @@ function asArray32(v) {
5555
5942
  }
5556
5943
  function validateSemanticRoles(stack) {
5557
5944
  const findings = [];
5558
- const objects = asArray32(stack.objects);
5945
+ const objects = asArray33(stack.objects);
5559
5946
  for (let i = 0; i < objects.length; i++) {
5560
5947
  const obj = objects[i];
5561
5948
  if (!obj || typeof obj !== "object") continue;
@@ -5563,7 +5950,7 @@ function validateSemanticRoles(stack) {
5563
5950
  const where = `object "${objName}"`;
5564
5951
  const path = `objects[${i}]`;
5565
5952
  const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? obj.fields : {};
5566
- const fieldNames = new Set(Object.keys(fields));
5953
+ const fieldNames = /* @__PURE__ */ new Set([...Object.keys(fields), ...injectedColumnsFor(obj)]);
5567
5954
  const declaredGroups = new Set(
5568
5955
  (Array.isArray(obj.fieldGroups) ? obj.fieldGroups : []).filter((g) => !!g && typeof g === "object").map((g) => g.key).filter((k) => typeof k === "string" && k.length > 0)
5569
5956
  );
@@ -5650,7 +6037,7 @@ function validateSemanticRoles(stack) {
5650
6037
  // src/validate-form-layout.ts
5651
6038
  var FORM_FIELD_UNKNOWN = "form-field-unknown";
5652
6039
  var FORM_COLSPAN_ABSOLUTE = "absolute-colspan-discouraged";
5653
- function asArray33(v) {
6040
+ function asArray34(v) {
5654
6041
  if (Array.isArray(v)) return v;
5655
6042
  if (v && typeof v === "object") {
5656
6043
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5675,13 +6062,13 @@ function boundObject(view) {
5675
6062
  function validateFormLayout(stack) {
5676
6063
  const findings = [];
5677
6064
  const objectFields = /* @__PURE__ */ new Map();
5678
- for (const obj of asArray33(stack.objects)) {
6065
+ for (const obj of asArray34(stack.objects)) {
5679
6066
  const name = typeof obj.name === "string" ? obj.name : void 0;
5680
6067
  if (!name) continue;
5681
6068
  const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? Object.keys(obj.fields) : [];
5682
6069
  objectFields.set(name, new Set(fields));
5683
6070
  }
5684
- const views = asArray33(stack.views);
6071
+ const views = asArray34(stack.views);
5685
6072
  for (let i = 0; i < views.length; i++) {
5686
6073
  const view = views[i];
5687
6074
  if (!view || typeof view !== "object") continue;
@@ -5821,7 +6208,7 @@ var VISIBILITY_ALIAS_DEPRECATED = "visibility-alias-deprecated";
5821
6208
  var VISIBILITY_ROOT_MISLAYERED = "visibility-root-mislayered";
5822
6209
  var CANONICAL = "visibleWhen";
5823
6210
  var ALIASES = ["visibleOn", "visibility"];
5824
- function asArray34(v) {
6211
+ function asArray35(v) {
5825
6212
  if (Array.isArray(v)) return v;
5826
6213
  if (v && typeof v === "object") {
5827
6214
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5883,7 +6270,7 @@ function isFieldObject(entry) {
5883
6270
  function validateVisibilityPredicates(stack, opts = {}) {
5884
6271
  const layer = opts.layer ?? "runtime";
5885
6272
  const findings = [];
5886
- const views = asArray34(stack.views);
6273
+ const views = asArray35(stack.views);
5887
6274
  for (let i = 0; i < views.length; i++) {
5888
6275
  const view = views[i];
5889
6276
  if (!view || typeof view !== "object") continue;
@@ -5906,7 +6293,7 @@ function validateVisibilityPredicates(stack, opts = {}) {
5906
6293
  }
5907
6294
  }
5908
6295
  }
5909
- const pages = asArray34(stack.pages);
6296
+ const pages = asArray35(stack.pages);
5910
6297
  for (let i = 0; i < pages.length; i++) {
5911
6298
  const page = pages[i];
5912
6299
  if (!page || typeof page !== "object") continue;
@@ -5953,7 +6340,7 @@ var OWD_WIDTH = {
5953
6340
  public_read: 1,
5954
6341
  public_read_write: 2
5955
6342
  };
5956
- function asArray35(v) {
6343
+ function asArray36(v) {
5957
6344
  if (Array.isArray(v)) return v;
5958
6345
  if (v && typeof v === "object") {
5959
6346
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -5961,7 +6348,7 @@ function asArray35(v) {
5961
6348
  return [];
5962
6349
  }
5963
6350
  function owdOf(obj) {
5964
- return obj.sharingModel ?? obj.security?.sharingModel;
6351
+ return obj.sharingModel;
5965
6352
  }
5966
6353
  function isSystemObject(obj) {
5967
6354
  return obj.isSystem === true || String(obj.name ?? "").startsWith("sys_");
@@ -5975,11 +6362,11 @@ function labelHasRoleWord(label2) {
5975
6362
  return /\brole(s)?\b/i.test(label2);
5976
6363
  }
5977
6364
  function refOf(def) {
5978
- const r = def.reference ?? def.reference_to;
6365
+ const r = def.reference;
5979
6366
  return typeof r === "string" && r ? r : void 0;
5980
6367
  }
5981
6368
  function firstMasterDetailField(obj) {
5982
- for (const f of asArray35(obj.fields)) {
6369
+ for (const f of asArray36(obj.fields)) {
5983
6370
  if (f.type === "master_detail") {
5984
6371
  return { name: String(f.name ?? "?"), parent: refOf(f) };
5985
6372
  }
@@ -5992,8 +6379,8 @@ function grantsObjectAccess(p) {
5992
6379
  function validateSecurityPosture(stack, opts) {
5993
6380
  const findings = [];
5994
6381
  if (!stack || typeof stack !== "object") return findings;
5995
- const objects = asArray35(stack.objects);
5996
- const permissionSets = asArray35(stack.permissions);
6382
+ const objects = asArray36(stack.objects);
6383
+ const permissionSets = asArray36(stack.permissions);
5997
6384
  for (let i = 0; i < objects.length; i++) {
5998
6385
  const obj = objects[i];
5999
6386
  if (!obj || typeof obj !== "object") continue;
@@ -6122,10 +6509,10 @@ function validateSecurityPosture(stack, opts) {
6122
6509
  if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
6123
6510
  const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
6124
6511
  flagRole("object", obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`);
6125
- for (const f of asArray35(obj.fields)) {
6512
+ for (const f of asArray36(obj.fields)) {
6126
6513
  flagRole("field", f.name, f.label, `field "${objName}.${String(f.name ?? "?")}"`, `objects[${i}].fields.${String(f.name ?? "?")}.name`);
6127
6514
  }
6128
- for (const [ai, action] of asArray35(obj.actions).entries()) {
6515
+ for (const [ai, action] of asArray36(obj.actions).entries()) {
6129
6516
  flagRole("action", action.name, action.label, `action "${objName}.${String(action.name ?? "?")}"`, `objects[${i}].actions[${ai}].name`);
6130
6517
  }
6131
6518
  }
@@ -6134,19 +6521,19 @@ function validateSecurityPosture(stack, opts) {
6134
6521
  if (!ps || typeof ps !== "object") continue;
6135
6522
  flagRole("permission set", ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`);
6136
6523
  }
6137
- for (const [i, pos] of asArray35(stack.positions).entries()) {
6524
+ for (const [i, pos] of asArray36(stack.positions).entries()) {
6138
6525
  flagRole("position", pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`);
6139
6526
  }
6140
- for (const [i, app] of asArray35(stack.apps).entries()) {
6527
+ for (const [i, app] of asArray36(stack.apps).entries()) {
6141
6528
  flagRole("app", app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
6142
6529
  }
6143
- for (const [i, book] of asArray35(stack.books).entries()) {
6530
+ for (const [i, book] of asArray36(stack.books).entries()) {
6144
6531
  flagRole("book", book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
6145
6532
  }
6146
6533
  const stackSetNames = new Set(
6147
6534
  permissionSets.map((ps) => typeof ps.name === "string" ? ps.name : void 0).filter((n) => !!n)
6148
6535
  );
6149
- for (const [i, book] of asArray35(stack.books).entries()) {
6536
+ for (const [i, book] of asArray36(stack.books).entries()) {
6150
6537
  const audience = book.audience;
6151
6538
  if (!audience || typeof audience !== "object") continue;
6152
6539
  const setName = audience.permissionSet;
@@ -6224,7 +6611,7 @@ function validateSecurityPosture(stack, opts) {
6224
6611
  }
6225
6612
  const GRANT_SEED_OBJECTS = /* @__PURE__ */ new Set(["sys_user_position", "sys_user_permission_set"]);
6226
6613
  const nowMs = opts?.nowMs ?? Date.now();
6227
- for (const [i, seed] of asArray35(stack.data).entries()) {
6614
+ for (const [i, seed] of asArray36(stack.data).entries()) {
6228
6615
  const seedObject = typeof seed.object === "string" ? seed.object : "";
6229
6616
  if (!GRANT_SEED_OBJECTS.has(seedObject)) continue;
6230
6617
  const records = Array.isArray(seed.records) ? seed.records : [];
@@ -6268,7 +6655,8 @@ function validateSecurityPosture(stack, opts) {
6268
6655
  var ORG_AXIS_PERMISSION_INHERITANCE = "org-axis-permission-inheritance";
6269
6656
  var ORG_AXIS_CROSS_ORG_BU_GRANT = "org-axis-cross-org-bu-grant";
6270
6657
  var ORG_PARENT_FIELD = "parent_organization_id";
6271
- function asArray36(v) {
6658
+ var BU_TREE_RECIPIENT_TYPES = /* @__PURE__ */ new Set(["business_unit", "unit_and_subordinates"]);
6659
+ function asArray37(v) {
6272
6660
  if (Array.isArray(v)) return v;
6273
6661
  if (v && typeof v === "object") {
6274
6662
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -6278,6 +6666,15 @@ function asArray36(v) {
6278
6666
  function str(v) {
6279
6667
  return typeof v === "string" ? v : "";
6280
6668
  }
6669
+ function expressionText(v) {
6670
+ if (typeof v === "string") return v;
6671
+ if (v && typeof v === "object") {
6672
+ const rec = v;
6673
+ if (typeof rec.source === "string") return rec.source;
6674
+ if (rec.ast !== void 0) return JSON.stringify(rec.ast) ?? "";
6675
+ }
6676
+ return "";
6677
+ }
6281
6678
  function isTenancyDisabled(object) {
6282
6679
  const tenancy = object.tenancy;
6283
6680
  if (tenancy && typeof tenancy === "object" && tenancy.enabled === false) return true;
@@ -6289,9 +6686,9 @@ var INHERITANCE_HINT = `Remove the ${ORG_PARENT_FIELD} reference. Cross-organiza
6289
6686
  function validateOrgAxisRedLines(stack) {
6290
6687
  const findings = [];
6291
6688
  const cfg = stack ?? {};
6292
- const permissionSets = asArray36(cfg.permissions ?? cfg.permissionSets);
6689
+ const permissionSets = asArray37(cfg.permissions);
6293
6690
  permissionSets.forEach((ps, psIndex) => {
6294
- asArray36(ps.rowLevelSecurity).forEach((policy, pIndex) => {
6691
+ asArray37(ps.rowLevelSecurity).forEach((policy, pIndex) => {
6295
6692
  for (const clause of ["using", "check"]) {
6296
6693
  if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
6297
6694
  findings.push({
@@ -6305,68 +6702,435 @@ function validateOrgAxisRedLines(stack) {
6305
6702
  }
6306
6703
  });
6307
6704
  });
6308
- const objects = asArray36(cfg.objects);
6309
- objects.forEach((object, oIndex) => {
6310
- const objectName = str(object.name) || String(oIndex);
6311
- asArray36(object.rowLevelSecurity ?? object.rls).forEach((policy, pIndex) => {
6312
- for (const clause of ["using", "check"]) {
6313
- if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
6314
- findings.push({
6315
- severity: "error",
6316
- rule: ORG_AXIS_PERMISSION_INHERITANCE,
6317
- where: `object "${objectName}" policy "${str(policy.name) || pIndex}"`,
6318
- path: `objects[${oIndex}].rowLevelSecurity[${pIndex}].${clause}`,
6319
- 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.`,
6320
- hint: INHERITANCE_HINT
6321
- });
6322
- }
6323
- });
6324
- });
6325
- asArray36(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
6326
- const criteria = JSON.stringify(rule.criteria ?? rule.filter ?? "");
6327
- const sharedTo = JSON.stringify(rule.sharedTo ?? rule.recipient ?? "");
6328
- if (criteria.includes(ORG_PARENT_FIELD) || sharedTo.includes(ORG_PARENT_FIELD)) {
6705
+ asArray37(cfg.sharingRules).forEach((rule, rIndex) => {
6706
+ const slots = [
6707
+ { key: "condition", text: expressionText(rule.condition) },
6708
+ { key: "sharedWith", text: JSON.stringify(rule.sharedWith ?? "") ?? "" }
6709
+ ];
6710
+ for (const slot of slots) {
6711
+ if (!slot.text.includes(ORG_PARENT_FIELD)) continue;
6329
6712
  findings.push({
6330
6713
  severity: "error",
6331
6714
  rule: ORG_AXIS_PERMISSION_INHERITANCE,
6332
6715
  where: `sharing rule "${str(rule.name) || rIndex}"`,
6333
- path: `sharingRules[${rIndex}]`,
6334
- message: `Sharing rule reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization tree. ADR-0105 D6 forbids permission inheritance along the org axis.`,
6716
+ path: `sharingRules[${rIndex}].${slot.key}`,
6717
+ 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.`,
6335
6718
  hint: INHERITANCE_HINT
6336
6719
  });
6337
6720
  }
6338
6721
  });
6339
6722
  const tenancyDisabledObjects = new Set(
6340
- objects.filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
6723
+ asArray37(cfg.objects).filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
6341
6724
  );
6342
- asArray36(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
6343
- const target = str(rule.object ?? rule.objectName);
6725
+ asArray37(cfg.sharingRules).forEach((rule, rIndex) => {
6726
+ const target = str(rule.object);
6344
6727
  if (!target || !tenancyDisabledObjects.has(target)) return;
6345
- const sharedTo = rule.sharedTo ?? rule.recipient;
6346
- const recipientType = str(sharedTo?.type);
6347
- if (recipientType !== "business_unit") return;
6728
+ const sharedWith = rule.sharedWith;
6729
+ const recipientType = str(sharedWith?.type);
6730
+ if (!BU_TREE_RECIPIENT_TYPES.has(recipientType)) return;
6731
+ const reach = recipientType === "unit_and_subordinates" ? "a business unit AND every descendant unit" : "a business unit";
6348
6732
  findings.push({
6349
6733
  severity: "error",
6350
6734
  rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
6351
6735
  where: `sharing rule "${str(rule.name) || rIndex}" on object "${target}"`,
6352
- path: `sharingRules[${rIndex}].sharedTo`,
6353
- 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).`,
6354
- 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.`
6736
+ path: `sharingRules[${rIndex}].sharedWith`,
6737
+ 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).`,
6738
+ 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.`
6355
6739
  });
6356
6740
  });
6357
6741
  return findings;
6358
6742
  }
6359
6743
 
6744
+ // src/validate-sharing-rule-enforceability.ts
6745
+ var import_formula4 = require("@objectstack/formula");
6746
+ var SHARING_RULE_UNLOWERABLE_CONDITION = "sharing-rule-unlowerable-condition";
6747
+ var SHARING_RULE_RUNTIME_VARIABLE_CONDITION = "sharing-rule-runtime-variable-condition";
6748
+ function asArray38(v) {
6749
+ if (Array.isArray(v)) return v;
6750
+ if (v && typeof v === "object") {
6751
+ return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6752
+ }
6753
+ return [];
6754
+ }
6755
+ function str2(v) {
6756
+ return typeof v === "string" ? v : "";
6757
+ }
6758
+ function toCompilerInput(condition) {
6759
+ if (typeof condition === "string") return condition.trim() ? condition : null;
6760
+ if (condition && typeof condition === "object") {
6761
+ const source = condition.source;
6762
+ if (typeof source === "string" && source.trim()) return { source };
6763
+ }
6764
+ return null;
6765
+ }
6766
+ function sourceOf(condition) {
6767
+ const input = toCompilerInput(condition);
6768
+ if (typeof input === "string") return input;
6769
+ return str2(input?.source);
6770
+ }
6771
+ 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).";
6772
+ function validateSharingRuleEnforceability(stack) {
6773
+ const findings = [];
6774
+ const cfg = stack ?? {};
6775
+ asArray38(cfg.sharingRules).forEach((rule, index) => {
6776
+ const input = toCompilerInput(rule.condition);
6777
+ if (input === null) return;
6778
+ const result = (0, import_formula4.compileCelToFilter)(input, { variables: {} });
6779
+ if (result.ok) return;
6780
+ if (result.reason === "parse-error") return;
6781
+ const name = str2(rule.name) || String(index);
6782
+ const object = str2(rule.object);
6783
+ const where = `sharing rule "${name}"${object ? ` on object "${object}"` : ""}`;
6784
+ const path = `sharingRules[${index}].condition`;
6785
+ const source = sourceOf(rule.condition);
6786
+ 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).";
6787
+ if (result.reason === "unresolved-variable") {
6788
+ findings.push({
6789
+ severity: "error",
6790
+ rule: SHARING_RULE_RUNTIME_VARIABLE_CONDITION,
6791
+ where,
6792
+ path,
6793
+ message: `Sharing-rule condition \`${source}\` reads a runtime variable (${result.detail}), ` + skipped,
6794
+ 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`."
6795
+ });
6796
+ return;
6797
+ }
6798
+ findings.push({
6799
+ severity: "error",
6800
+ rule: SHARING_RULE_UNLOWERABLE_CONDITION,
6801
+ where,
6802
+ path,
6803
+ message: `Sharing-rule condition \`${source}\` is outside the pushdown subset the runtime can compile (${result.detail}), ` + skipped,
6804
+ 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."
6805
+ });
6806
+ });
6807
+ return findings;
6808
+ }
6809
+
6810
+ // src/validate-rls-predicate-enforceability.ts
6811
+ var import_formula5 = require("@objectstack/formula");
6812
+ var RLS_PREDICATE_UNENFORCEABLE = "rls-predicate-unenforceable";
6813
+ var RLS_PREDICATE_UNPARSEABLE = "rls-predicate-unparseable";
6814
+ function asArray39(v) {
6815
+ if (Array.isArray(v)) return v;
6816
+ if (v && typeof v === "object") {
6817
+ return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6818
+ }
6819
+ return [];
6820
+ }
6821
+ function str3(v) {
6822
+ return typeof v === "string" ? v : "";
6823
+ }
6824
+ 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.";
6825
+ function consequence(clause) {
6826
+ 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). ';
6827
+ 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.";
6828
+ }
6829
+ function validateRlsPredicateEnforceability(stack) {
6830
+ const findings = [];
6831
+ const cfg = stack ?? {};
6832
+ asArray39(cfg.permissions).forEach((ps, psIndex) => {
6833
+ asArray39(ps.rowLevelSecurity).forEach((policy, pIndex) => {
6834
+ for (const clause of ["using", "check"]) {
6835
+ const source = str3(policy[clause]);
6836
+ if (!source.trim()) continue;
6837
+ if ((0, import_formula5.isSupportedRlsExpression)(source)) continue;
6838
+ const why = (0, import_formula5.isPushdownableCel)((0, import_formula5.sqlPredicateToCel)(source));
6839
+ const detail = why.ok ? "" : why.detail;
6840
+ const parseError = !why.ok && why.reason === "parse-error";
6841
+ const psName = str3(ps.name) || String(psIndex);
6842
+ const policyName = str3(policy.name) || String(pIndex);
6843
+ const object = str3(policy.object);
6844
+ const where = `permission set "${psName}" policy "${policyName}"` + (object ? ` on object "${object}"` : "");
6845
+ const path = `permissions[${psIndex}].rowLevelSecurity[${pIndex}].${clause}`;
6846
+ if (parseError) {
6847
+ findings.push({
6848
+ severity: "error",
6849
+ rule: RLS_PREDICATE_UNPARSEABLE,
6850
+ where,
6851
+ path,
6852
+ 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),
6853
+ 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."
6854
+ });
6855
+ continue;
6856
+ }
6857
+ findings.push({
6858
+ severity: "error",
6859
+ rule: RLS_PREDICATE_UNENFORCEABLE,
6860
+ where,
6861
+ path,
6862
+ message: `RLS ${clause} \`${source}\` is outside the pushdown subset the runtime can compile (${detail}), ` + consequence(clause),
6863
+ 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."
6864
+ });
6865
+ }
6866
+ });
6867
+ });
6868
+ return findings;
6869
+ }
6870
+
6871
+ // src/validate-rule-compilability.ts
6872
+ var import_node_module4 = require("module");
6873
+ var import_meta4 = {};
6874
+ var VALIDATION_RULE_REGEX_UNCOMPILABLE = "validation-rule-regex-uncompilable";
6875
+ var VALIDATION_RULE_SCHEMA_UNCOMPILABLE = "validation-rule-json-schema-uncompilable";
6876
+ var RUNTIME_AJV_OPTIONS = { allErrors: true, strict: false };
6877
+ var isRec16 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
6878
+ function asArray40(v) {
6879
+ if (Array.isArray(v)) return v.filter(isRec16);
6880
+ if (isRec16(v)) {
6881
+ return Object.entries(v).filter(([, def]) => isRec16(def)).map(([name, def]) => ({ name, ...def }));
6882
+ }
6883
+ return [];
6884
+ }
6885
+ var cachedAjv = null;
6886
+ var cachedAddFormats = null;
6887
+ function loadAjv() {
6888
+ if (cachedAjv) return cachedAjv;
6889
+ const anchor = typeof import_meta4 !== "undefined" && import_meta4.url ? import_meta4.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
6890
+ let mod;
6891
+ try {
6892
+ mod = (0, import_node_module4.createRequire)(anchor)("ajv");
6893
+ } catch (err) {
6894
+ throw new Error(
6895
+ `@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.`
6896
+ );
6897
+ }
6898
+ const ctor = isRec16(mod) && "default" in mod ? mod.default : mod;
6899
+ cachedAjv = ctor;
6900
+ return ctor;
6901
+ }
6902
+ function loadAddFormats() {
6903
+ if (cachedAddFormats) return cachedAddFormats;
6904
+ const anchor = typeof import_meta4 !== "undefined" && import_meta4.url ? import_meta4.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
6905
+ let mod;
6906
+ try {
6907
+ mod = (0, import_node_module4.createRequire)(anchor)("ajv-formats");
6908
+ } catch (err) {
6909
+ throw new Error(
6910
+ `@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.`
6911
+ );
6912
+ }
6913
+ const plugin = isRec16(mod) && "default" in mod ? mod.default : mod;
6914
+ cachedAddFormats = plugin;
6915
+ return plugin;
6916
+ }
6917
+ function createRuntimeAjv() {
6918
+ const instance = new (loadAjv())(RUNTIME_AJV_OPTIONS);
6919
+ loadAddFormats()(instance);
6920
+ return instance;
6921
+ }
6922
+ function registeredFormatNames() {
6923
+ const names = Object.keys(createRuntimeAjv().formats).sort();
6924
+ if (names.length === 0) {
6925
+ throw new Error(
6926
+ `@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.`
6927
+ );
6928
+ }
6929
+ return names;
6930
+ }
6931
+ function errorText(err) {
6932
+ return err instanceof Error ? err.message : String(err);
6933
+ }
6934
+ var MAX_RULE_NESTING_DEPTH = 16;
6935
+ function flattenRules(rule, labelTrail, pathTrail, depth = 0) {
6936
+ const name = typeof rule.name === "string" && rule.name ? rule.name : "?";
6937
+ const label2 = labelTrail ? `${labelTrail} \u2192 '${name}'` : `'${name}'`;
6938
+ const path = pathTrail ? `${pathTrail}.${name}` : name;
6939
+ const out = [{ rule, label: label2, path }];
6940
+ if (depth >= MAX_RULE_NESTING_DEPTH) return out;
6941
+ for (const branch of ["then", "otherwise"]) {
6942
+ const nested = rule[branch];
6943
+ if (isRec16(nested)) out.push(...flattenRules(nested, label2, `${path}.${branch}`, depth + 1));
6944
+ }
6945
+ return out;
6946
+ }
6947
+ function walkObjectValidationRules(stack) {
6948
+ const walked = [];
6949
+ if (!isRec16(stack)) return walked;
6950
+ for (const obj of asArray40(stack.objects)) {
6951
+ const objectName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
6952
+ const validations = obj.validations;
6953
+ for (const authored of asArray40(validations)) {
6954
+ for (const { rule, label: label2, path } of flattenRules(authored, "", "")) {
6955
+ walked.push({
6956
+ rule,
6957
+ objectName,
6958
+ label: label2,
6959
+ where: `object '${objectName}' \xB7 validation ${label2}`,
6960
+ basePath: `objects.${objectName}.validations.${path}`
6961
+ });
6962
+ }
6963
+ }
6964
+ }
6965
+ return walked;
6966
+ }
6967
+ function validateRuleCompilability(stack) {
6968
+ const findings = [];
6969
+ for (const { rule, objectName, label: label2, where, basePath } of walkObjectValidationRules(stack)) {
6970
+ if (rule.type === "format" && typeof rule.regex === "string" && rule.regex !== "") {
6971
+ try {
6972
+ new RegExp(rule.regex);
6973
+ } catch (err) {
6974
+ findings.push({
6975
+ severity: "error",
6976
+ rule: VALIDATION_RULE_REGEX_UNCOMPILABLE,
6977
+ where,
6978
+ path: `${basePath}.regex`,
6979
+ 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.`,
6980
+ 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').`
6981
+ });
6982
+ }
6983
+ }
6984
+ if (rule.type === "json_schema" && isRec16(rule.schema)) {
6985
+ try {
6986
+ createRuntimeAjv().compile(rule.schema);
6987
+ } catch (err) {
6988
+ findings.push({
6989
+ severity: "error",
6990
+ rule: VALIDATION_RULE_SCHEMA_UNCOMPILABLE,
6991
+ where,
6992
+ path: `${basePath}.schema`,
6993
+ 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.`,
6994
+ 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.`
6995
+ });
6996
+ }
6997
+ }
6998
+ }
6999
+ return findings;
7000
+ }
7001
+
7002
+ // src/validate-rule-schema-formats.ts
7003
+ var VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT = "validation-rule-json-schema-unknown-format";
7004
+ var isRec17 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
7005
+ var SUBSCHEMA_KEYS = [
7006
+ "additionalItems",
7007
+ "additionalProperties",
7008
+ "contains",
7009
+ "propertyNames",
7010
+ "if",
7011
+ "then",
7012
+ "else",
7013
+ "not",
7014
+ "unevaluatedItems",
7015
+ "unevaluatedProperties"
7016
+ ];
7017
+ var SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
7018
+ var SUBSCHEMA_MAP_KEYS = [
7019
+ "properties",
7020
+ "patternProperties",
7021
+ "$defs",
7022
+ "definitions",
7023
+ "dependentSchemas"
7024
+ ];
7025
+ var MAX_SCHEMA_WALK_DEPTH = 32;
7026
+ var escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
7027
+ function collectFormatUses(schema, pointer, out, depth) {
7028
+ if (!isRec17(schema)) return;
7029
+ if (typeof schema.format === "string") {
7030
+ out.push({ pointer: `${pointer}/format`, name: schema.format });
7031
+ }
7032
+ if (depth >= MAX_SCHEMA_WALK_DEPTH) return;
7033
+ for (const key of SUBSCHEMA_KEYS) {
7034
+ if (key in schema) {
7035
+ collectFormatUses(schema[key], `${pointer}/${escapePointerSegment(key)}`, out, depth + 1);
7036
+ }
7037
+ }
7038
+ for (const key of SUBSCHEMA_LIST_KEYS) {
7039
+ const value = schema[key];
7040
+ if (!Array.isArray(value)) continue;
7041
+ value.forEach((entry, index) => {
7042
+ collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${index}`, out, depth + 1);
7043
+ });
7044
+ }
7045
+ for (const key of SUBSCHEMA_MAP_KEYS) {
7046
+ const value = schema[key];
7047
+ if (!isRec17(value)) continue;
7048
+ for (const [name, entry] of Object.entries(value)) {
7049
+ collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${escapePointerSegment(name)}`, out, depth + 1);
7050
+ }
7051
+ }
7052
+ const items = schema.items;
7053
+ if (Array.isArray(items)) {
7054
+ items.forEach((entry, index) => collectFormatUses(entry, `${pointer}/items/${index}`, out, depth + 1));
7055
+ } else if (isRec17(items)) {
7056
+ collectFormatUses(items, `${pointer}/items`, out, depth + 1);
7057
+ }
7058
+ const dependencies = schema.dependencies;
7059
+ if (isRec17(dependencies)) {
7060
+ for (const [name, entry] of Object.entries(dependencies)) {
7061
+ if (!isRec17(entry)) continue;
7062
+ collectFormatUses(entry, `${pointer}/dependencies/${escapePointerSegment(name)}`, out, depth + 1);
7063
+ }
7064
+ }
7065
+ }
7066
+ function editDistance2(a, b) {
7067
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
7068
+ for (let i = 1; i <= a.length; i++) {
7069
+ const current = [i];
7070
+ for (let j = 1; j <= b.length; j++) {
7071
+ current[j] = Math.min(
7072
+ previous[j] + 1,
7073
+ current[j - 1] + 1,
7074
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
7075
+ );
7076
+ }
7077
+ previous = current;
7078
+ }
7079
+ return previous[b.length];
7080
+ }
7081
+ function nearestRegisteredFormat(name, registered) {
7082
+ const budget = Math.min(3, Math.floor(name.length / 2));
7083
+ if (budget < 1) return null;
7084
+ const authored = name.toLowerCase();
7085
+ let best = null;
7086
+ let bestDistance = Number.POSITIVE_INFINITY;
7087
+ for (const candidate of [...registered].sort()) {
7088
+ const distance7 = editDistance2(authored, candidate);
7089
+ if (distance7 < bestDistance) {
7090
+ bestDistance = distance7;
7091
+ best = candidate;
7092
+ }
7093
+ }
7094
+ return bestDistance <= budget ? best : null;
7095
+ }
7096
+ function validateRuleSchemaFormats(stack) {
7097
+ const findings = [];
7098
+ const pending = [];
7099
+ for (const { rule, objectName, label: label2, where, basePath } of walkObjectValidationRules(stack)) {
7100
+ if (rule.type !== "json_schema" || !isRec17(rule.schema)) continue;
7101
+ const uses = [];
7102
+ collectFormatUses(rule.schema, "", uses, 0);
7103
+ for (const use of uses) pending.push({ use, where, label: label2, objectName, basePath });
7104
+ }
7105
+ if (pending.length === 0) return findings;
7106
+ const registered = registeredFormatNames();
7107
+ const known = new Set(registered);
7108
+ for (const { use, where, label: label2, objectName, basePath } of pending) {
7109
+ if (known.has(use.name)) continue;
7110
+ const suggestion = nearestRegisteredFormat(use.name, registered);
7111
+ const pointer = `#${use.pointer}`;
7112
+ findings.push({
7113
+ severity: "error",
7114
+ rule: VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT,
7115
+ where,
7116
+ path: `${basePath}.schema${pointer}`,
7117
+ 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.`,
7118
+ 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.`
7119
+ });
7120
+ }
7121
+ return findings;
7122
+ }
7123
+
6360
7124
  // src/validate-action-locations.ts
6361
7125
  var ACTION_NO_PLACEMENT = "action-no-placement";
6362
- function asArray37(v) {
7126
+ function asArray41(v) {
6363
7127
  if (Array.isArray(v)) return v;
6364
7128
  if (v && typeof v === "object") {
6365
7129
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6366
7130
  }
6367
7131
  return [];
6368
7132
  }
6369
- function strName15(v) {
7133
+ function strName17(v) {
6370
7134
  return typeof v === "string" && v.length > 0 ? v : void 0;
6371
7135
  }
6372
7136
  function strList3(v) {
@@ -6380,8 +7144,8 @@ function collectNamePlacedActions(stack) {
6380
7144
  for (const key of ["rowActions", "bulkActions"]) {
6381
7145
  for (const n of strList3(list3[key])) placed.add(n);
6382
7146
  }
6383
- for (const def of asArray37(list3.bulkActionDefs)) {
6384
- const n = strName15(def?.name);
7147
+ for (const def of asArray41(list3.bulkActionDefs)) {
7148
+ const n = strName17(def?.name);
6385
7149
  if (n) placed.add(n);
6386
7150
  }
6387
7151
  };
@@ -6389,12 +7153,12 @@ function collectNamePlacedActions(stack) {
6389
7153
  if (!listViews || typeof listViews !== "object" || Array.isArray(listViews)) return;
6390
7154
  for (const lv of Object.values(listViews)) harvest(lv);
6391
7155
  };
6392
- for (const view of asArray37(stack.views)) {
7156
+ for (const view of asArray41(stack.views)) {
6393
7157
  if (!view || typeof view !== "object") continue;
6394
7158
  harvest(view.list);
6395
7159
  harvestListViews(view.listViews);
6396
7160
  }
6397
- for (const obj of asArray37(stack.objects)) {
7161
+ for (const obj of asArray41(stack.objects)) {
6398
7162
  if (!obj || typeof obj !== "object") continue;
6399
7163
  harvestListViews(obj.listViews);
6400
7164
  }
@@ -6407,7 +7171,7 @@ function validateActionLocations(stack) {
6407
7171
  const check = (action, path) => {
6408
7172
  if (!action || typeof action !== "object") return;
6409
7173
  if ("locations" in action) return;
6410
- const name = strName15(action.name);
7174
+ const name = strName17(action.name);
6411
7175
  if (!name) return;
6412
7176
  if (namePlaced.has(name)) return;
6413
7177
  findings.push({
@@ -6419,20 +7183,21 @@ function validateActionLocations(stack) {
6419
7183
  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."
6420
7184
  });
6421
7185
  };
6422
- const actions = asArray37(stack.actions);
7186
+ const actions = asArray41(stack.actions);
6423
7187
  for (let i = 0; i < actions.length; i++) check(actions[i], `actions[${i}]`);
6424
- const objects = asArray37(stack.objects);
7188
+ const objects = asArray41(stack.objects);
6425
7189
  for (let oi = 0; oi < objects.length; oi++) {
6426
7190
  const obj = objects[oi];
6427
7191
  if (!obj || typeof obj !== "object") continue;
6428
- const own = asArray37(obj.actions);
7192
+ const own = asArray41(obj.actions);
6429
7193
  for (let ai = 0; ai < own.length; ai++) check(own[ai], `objects[${oi}].actions[${ai}]`);
6430
7194
  }
6431
7195
  return findings;
6432
7196
  }
6433
7197
 
6434
7198
  // src/lint-flow-patterns.ts
6435
- function asArray38(v) {
7199
+ var import_automation5 = require("@objectstack/spec/automation");
7200
+ function asArray42(v) {
6436
7201
  if (Array.isArray(v)) return v;
6437
7202
  if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6438
7203
  return [];
@@ -6455,6 +7220,7 @@ var FLOW_BARE_DOLLAR_REF = "flow-bare-dollar-reference";
6455
7220
  var FLOW_APPROVAL_REVISE_DEAD_END = "flow-approval-revise-dead-end";
6456
7221
  var FLOW_APPROVAL_REVISE_UNMARKED_BACKEDGE = "flow-approval-revise-unmarked-backedge";
6457
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";
6458
7224
  var FLOW_RUNAS_UNSCOPED = "flow-runas-unscoped";
6459
7225
  var FLOW_ERROR_LABEL_NOT_FAULT = "flow-error-label-not-fault";
6460
7226
  var FLOW_BRANCH_LABEL_UNMATCHED = "flow-branch-label-unmatched";
@@ -6462,6 +7228,7 @@ var FLOW_DECISION_UNCONDITIONAL_BRANCH = "flow-decision-unconditional-branch";
6462
7228
  var FLOW_DEFAULT_EDGE_WITH_CONDITION = "flow-default-edge-with-condition";
6463
7229
  var FLOW_MULTIPLE_DEFAULT_EDGES = "flow-multiple-default-edges";
6464
7230
  var FLOW_INERT_NODE_CONDITION = "flow-inert-node-condition";
7231
+ var FLOW_MULTI_WRITE_UNFILTERED = "flow-multi-write-unfiltered";
6465
7232
  var INERT_CONDITION_NODE_TYPES = /* @__PURE__ */ new Set([
6466
7233
  "decision",
6467
7234
  "assignment",
@@ -6484,6 +7251,36 @@ var INERT_CONDITION_NODE_TYPES = /* @__PURE__ */ new Set([
6484
7251
  "end"
6485
7252
  ]);
6486
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 (0, import_automation5.collectFlowGraphs)({
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
+ ]);
6487
7284
  var ERROR_LABELS = /* @__PURE__ */ new Set(["error", "fault", "failure", "failed", "catch", "on_error", "onerror", "on error"]);
6488
7285
  var BRANCH_LABEL_NODE_TYPES = /* @__PURE__ */ new Set(["decision", "approval", "screen", "try_catch"]);
6489
7286
  function isScheduleTriggered(flow, startCfg) {
@@ -6568,7 +7365,7 @@ function collectTemplateStrings(value, key, out) {
6568
7365
  function edgeLabelOf(e) {
6569
7366
  return typeof e.label === "string" ? e.label.trim().toLowerCase() : "";
6570
7367
  }
6571
- function scanErrorLabelledEdges(flowName, nodes, edges, findings) {
7368
+ function scanErrorLabelledEdges(at, nodes, edges, findings) {
6572
7369
  const typeById = /* @__PURE__ */ new Map();
6573
7370
  for (const n of nodes) {
6574
7371
  if (typeof n.id === "string") typeById.set(n.id, typeof n.type === "string" ? n.type : "");
@@ -6581,14 +7378,14 @@ function scanErrorLabelledEdges(flowName, nodes, edges, findings) {
6581
7378
  const src = typeof e.source === "string" ? e.source : "";
6582
7379
  if (BRANCH_LABEL_NODE_TYPES.has(typeById.get(src) ?? "")) continue;
6583
7380
  findings.push({
6584
- where: `flow '${flowName}' \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
7381
+ where: `${at} \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
6585
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.`,
6586
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)`,
6587
7384
  rule: FLOW_ERROR_LABEL_NOT_FAULT
6588
7385
  });
6589
7386
  }
6590
7387
  }
6591
- function scanBranchRouting(flowName, nodes, edges, findings) {
7388
+ function scanBranchRouting(at, nodes, edges, findings) {
6592
7389
  const outEdgesBySource = /* @__PURE__ */ new Map();
6593
7390
  for (const e of edges) {
6594
7391
  if (e.type === "fault") continue;
@@ -6601,7 +7398,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6601
7398
  for (const e of outs) {
6602
7399
  if (e.isDefault === true && e.condition) {
6603
7400
  findings.push({
6604
- where: `flow '${flowName}' \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
7401
+ where: `${at} \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
6605
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.`,
6606
7403
  hint: `Drop one: keep \`condition\` for a guarded branch, or drop it and keep \`isDefault: true\` for the "otherwise" path. (#4414)`,
6607
7404
  rule: FLOW_DEFAULT_EDGE_WITH_CONDITION,
@@ -6614,7 +7411,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6614
7411
  const defaults = outs.filter((e) => e.isDefault === true && !e.condition);
6615
7412
  if (defaults.length > 1) {
6616
7413
  findings.push({
6617
- where: `flow '${flowName}' \xB7 node '${src}'`,
7414
+ where: `${at} \xB7 node '${src}'`,
6618
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".`,
6619
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)`,
6620
7417
  rule: FLOW_MULTIPLE_DEFAULT_EDGES
@@ -6627,7 +7424,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6627
7424
  const cfg = node.config ?? {};
6628
7425
  if (cfg.condition == null || conditionSource(cfg.condition).trim() === "") continue;
6629
7426
  findings.push({
6630
- where: `flow '${flowName}' \xB7 node '${String(node.id)}' (${nodeType})`,
7427
+ where: `${at} \xB7 node '${String(node.id)}' (${nodeType})`,
6631
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.)`,
6632
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)`,
6633
7430
  rule: FLOW_INERT_NODE_CONDITION
@@ -6647,7 +7444,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6647
7444
  const unclaimed = [...declaredLabels].filter((l) => !edgeLabels.has(l));
6648
7445
  if (unclaimed.length > 0) {
6649
7446
  findings.push({
6650
- where: `flow '${flowName}' \xB7 decision '${nid}'`,
7447
+ where: `${at} \xB7 decision '${nid}'`,
6651
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.`,
6652
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)`,
6653
7450
  rule: FLOW_BRANCH_LABEL_UNMATCHED,
@@ -6663,7 +7460,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6663
7460
  );
6664
7461
  if (ungated.length > 0) {
6665
7462
  findings.push({
6666
- where: `flow '${flowName}' \xB7 decision '${nid}'`,
7463
+ where: `${at} \xB7 decision '${nid}'`,
6667
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\`.`,
6668
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)`,
6669
7466
  rule: FLOW_DECISION_UNCONDITIONAL_BRANCH
@@ -6671,10 +7468,39 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
6671
7468
  }
6672
7469
  }
6673
7470
  }
6674
- function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
6675
- 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 === import_automation5.APPROVAL_NODE_TYPE);
6676
7499
  if (approvals.length === 0) return;
6677
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
+ );
6678
7504
  const outEdges = /* @__PURE__ */ new Map();
6679
7505
  for (const e of edges) {
6680
7506
  const src = typeof e.source === "string" ? e.source : "";
@@ -6687,7 +7513,18 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
6687
7513
  if (!aid) continue;
6688
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));
6689
7515
  if (reviseTargets.length === 0) continue;
6690
- 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 === import_automation5.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 '${import_automation5.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: '${import_automation5.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
+ }
6691
7528
  const cfg = a.config ?? {};
6692
7529
  if (cfg.maxRevisions === 0) {
6693
7530
  findings.push({
@@ -6715,7 +7552,7 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
6715
7552
  findings.push({
6716
7553
  where,
6717
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.)`,
6718
- 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 '${import_automation5.APPROVAL_REVISE_NODE_TYPE}' node whose resubmit edge returns to '${aid}' marked \`type: 'back'\` (ADR-0044). See examples/app-showcase showcase_budget_approval.`,
6719
7556
  rule: FLOW_APPROVAL_REVISE_DEAD_END
6720
7557
  });
6721
7558
  } else if (!returnEdges.some((e) => e.type === "back")) {
@@ -6730,7 +7567,7 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
6730
7567
  }
6731
7568
  function lintFlowPatterns(stack) {
6732
7569
  const findings = [];
6733
- for (const flow of asArray38(stack.flows)) {
7570
+ for (const flow of asArray42(stack.flows)) {
6734
7571
  const flowName = typeof flow.name === "string" ? flow.name : "(unnamed flow)";
6735
7572
  const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
6736
7573
  const edges = Array.isArray(flow.edges) ? flow.edges : [];
@@ -6751,75 +7588,91 @@ function lintFlowPatterns(stack) {
6751
7588
  const runAs = typeof flow.runAs === "string" ? flow.runAs : "user";
6752
7589
  const userLessKind = userLessTriggerKind(flow, startCfg);
6753
7590
  if (userLessKind && runAs !== "system") {
6754
- const dataNode = nodes.find((n) => DATA_NODE_TYPES.has(typeof n.type === "string" ? n.type : ""));
7591
+ const dataNode = findDataNodeAnywhere(nodes, edges);
6755
7592
  if (dataNode) {
6756
- const declared = typeof flow.runAs === "string" ? `\`runAs:'${runAs}'\`` : `the default \`runAs:'user'\``;
7593
+ const at = dataNode.scope ? `, in ${dataNode.scope},` : "";
6757
7594
  findings.push({
6758
7595
  where: `flow '${flowName}' \xB7 runAs`,
6759
- 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.`,
6760
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)`,
6761
7598
  rule: FLOW_RUNAS_UNSCOPED,
6762
7599
  severity: "error"
6763
7600
  });
6764
7601
  }
6765
7602
  }
6766
- for (const node of nodes) {
6767
- const nodeWhere = `flow '${flowName}' \xB7 node '${node.id}' (${node.type})`;
6768
- const cfg = node.config ?? {};
6769
- if (cfg.filter) scanFilterForDateEquality(cfg.filter, `${nodeWhere} filter`, findings);
6770
- for (const key of Object.keys(cfg)) {
6771
- if (PHANTOM_AGG_KEYS.has(key)) {
6772
- findings.push({
6773
- where: nodeWhere,
6774
- 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.`,
6775
- 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)`,
6776
- rule: FLOW_PHANTOM_AGGREGATION
6777
- });
6778
- }
6779
- }
6780
- const strings = [];
6781
- collectTemplateStrings(node.config, void 0, strings);
6782
- for (const str2 of strings) {
6783
- if (DOUBLE_BRACE.test(str2)) {
6784
- findings.push({
6785
- where: nodeWhere,
6786
- message: `double-brace interpolation \`${str2.trim().slice(0, 80)}\` \u2014 flow node values use SINGLE braces.`,
6787
- hint: `Use \`{var}\` (e.g. \`{record.title}\`). Double-brace \`{{ }}\` is the formula/template-field dialect, not flow node values. (#1315)`,
6788
- rule: FLOW_DOUBLE_BRACE_INTERP
6789
- });
7603
+ for (const graph of (0, import_automation5.collectFlowGraphs)({
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
+ }
6790
7630
  }
6791
- if (BARE_DOLLAR_REF.test(str2)) {
6792
- findings.push({
6793
- where: nodeWhere,
6794
- message: `\`${str2.trim().slice(0, 80)}\` looks like a reference written as a literal \u2014 a bare \`$ref.field\` is NOT interpolated.`,
6795
- hint: `Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user). (#1315)`,
6796
- rule: FLOW_BARE_DOLLAR_REF
6797
- });
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
+ }
6798
7650
  }
6799
7651
  }
7652
+ scanApprovalReviseLoops(at, graphNodes, graphEdges, findings);
7653
+ scanErrorLabelledEdges(at, graphNodes, graphEdges, findings);
7654
+ scanBranchRouting(at, graphNodes, graphEdges, findings);
7655
+ scanUnboundedBulkWrites(at, graphNodes, findings);
6800
7656
  }
6801
- scanApprovalReviseLoops(flowName, nodes, edges, findings);
6802
- scanErrorLabelledEdges(flowName, nodes, edges, findings);
6803
- scanBranchRouting(flowName, nodes, edges, findings);
6804
7657
  }
6805
7658
  return findings;
6806
7659
  }
6807
7660
 
6808
7661
  // src/lint-liveness-properties.ts
6809
- var import_node_module4 = require("module");
7662
+ var import_node_module5 = require("module");
6810
7663
  var import_node_path = require("path");
6811
7664
  var import_node_fs = require("fs");
6812
- var import_meta4 = {};
7665
+ var import_meta5 = {};
6813
7666
  var LIVENESS_DEAD_PROPERTY = "liveness-dead-property";
6814
7667
  var LIVENESS_EXPERIMENTAL_PROPERTY = "liveness-experimental-property";
6815
- function asArray39(v) {
7668
+ function asArray43(v) {
6816
7669
  if (Array.isArray(v)) return v;
6817
7670
  if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
6818
7671
  return [];
6819
7672
  }
6820
7673
  function resolveLivenessDir() {
6821
7674
  try {
6822
- const require2 = (0, import_node_module4.createRequire)(import_meta4.url);
7675
+ const require2 = (0, import_node_module5.createRequire)(import_meta5.url);
6823
7676
  const pkgJson = require2.resolve("@objectstack/spec/package.json");
6824
7677
  const dir = (0, import_node_path.join)((0, import_node_path.dirname)(pkgJson), "liveness");
6825
7678
  return (0, import_node_fs.existsSync)(dir) ? dir : null;
@@ -6925,7 +7778,16 @@ var TYPE_COLLECTIONS = [
6925
7778
  { type: "job", key: "jobs" },
6926
7779
  { type: "email_template", key: "emailTemplates" },
6927
7780
  { type: "mapping", key: "mappings" },
6928
- { type: "translation", key: "translations" }
7781
+ { type: "translation", key: "translations" },
7782
+ // #4956 — dashboard joins the list the moment its ledger first warns on
7783
+ // anything, which is exactly the rule the comment above states. Drilling
7784
+ // `widgets` produced five warned keys (`colorVariant`, `actionUrl`,
7785
+ // `actionType`, `actionIcon`, `aria`), all under `widgets[]`; `getNested`
7786
+ // fans a dotted path out over an array level, so `widgets.colorVariant`
7787
+ // checks every widget on the dashboard. Registering it here is not optional
7788
+ // bookkeeping: without it the ledger would be newly correct and newly
7789
+ // silent, which is the shape this lint exists to prevent.
7790
+ { type: "dashboard", key: "dashboards" }
6929
7791
  ];
6930
7792
  function lintLivenessProperties(stack) {
6931
7793
  const dir = resolveLivenessDir();
@@ -6933,11 +7795,11 @@ function lintLivenessProperties(stack) {
6933
7795
  const findings = [];
6934
7796
  const objectWarn = loadWarnMap(dir, "object");
6935
7797
  const fieldWarn = loadWarnMap(dir, "field");
6936
- for (const obj of asArray39(stack.objects)) {
7798
+ for (const obj of asArray43(stack.objects)) {
6937
7799
  const objName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
6938
7800
  if (objectWarn.size > 0) checkItem("object", obj, `object '${objName}'`, objectWarn, findings);
6939
7801
  if (fieldWarn.size > 0) {
6940
- for (const field of asArray39(obj.fields)) {
7802
+ for (const field of asArray43(obj.fields)) {
6941
7803
  const fieldName = typeof field.name === "string" ? field.name : "(unnamed field)";
6942
7804
  checkItem("field", field, `object '${objName}' \xB7 field '${fieldName}'`, fieldWarn, findings);
6943
7805
  }
@@ -6946,7 +7808,7 @@ function lintLivenessProperties(stack) {
6946
7808
  for (const { type, key } of TYPE_COLLECTIONS) {
6947
7809
  const warnMap = loadWarnMap(dir, type);
6948
7810
  if (warnMap.size === 0) continue;
6949
- for (const item of asArray39(stack[key])) {
7811
+ for (const item of asArray43(stack[key])) {
6950
7812
  const name = typeof item.name === "string" ? item.name : typeof item.object === "string" ? item.object : `(unnamed ${type})`;
6951
7813
  checkItem(type, item, `${type} '${name}'`, warnMap, findings);
6952
7814
  }
@@ -6960,7 +7822,7 @@ var AUTONUMBER_UNKNOWN_FIELD = "autonumber-references-unknown-field";
6960
7822
  var AUTONUMBER_OPTIONAL_FIELD = "autonumber-references-optional-field";
6961
7823
  var AUTONUMBER_SELF_REFERENCE = "autonumber-references-self";
6962
7824
  var AUTONUMBER_LITERAL_TOKEN = "autonumber-unrecognized-token";
6963
- function asArray40(v) {
7825
+ function asArray44(v) {
6964
7826
  if (Array.isArray(v)) return v;
6965
7827
  if (v && typeof v === "object") {
6966
7828
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -6969,9 +7831,9 @@ function asArray40(v) {
6969
7831
  }
6970
7832
  function lintAutonumberFormats(stack) {
6971
7833
  const findings = [];
6972
- for (const obj of asArray40(stack.objects)) {
7834
+ for (const obj of asArray44(stack.objects)) {
6973
7835
  const objectName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
6974
- const fields = asArray40(obj.fields);
7836
+ const fields = asArray44(obj.fields);
6975
7837
  const fieldMeta = /* @__PURE__ */ new Map();
6976
7838
  for (const f of fields) {
6977
7839
  if (typeof f.name === "string") fieldMeta.set(f.name, { required: f.required === true });
@@ -7036,8 +7898,8 @@ function lintAutonumberFormats(stack) {
7036
7898
  }
7037
7899
 
7038
7900
  // src/lint-view-refs.ts
7039
- var import_spec2 = require("@objectstack/spec");
7040
- function asArray41(v) {
7901
+ var import_spec3 = require("@objectstack/spec");
7902
+ function asArray45(v) {
7041
7903
  if (Array.isArray(v)) return v;
7042
7904
  if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
7043
7905
  return [];
@@ -7065,16 +7927,16 @@ function lintViewRefs(stack) {
7065
7927
  s.add(kind);
7066
7928
  };
7067
7929
  const containers = [];
7068
- for (const v of asArray41(stack.views)) {
7930
+ for (const v of asArray45(stack.views)) {
7069
7931
  if (v.viewKind) {
7070
7932
  if (typeof v.name === "string") indexKind(v.name, v.viewKind === "form" ? "form" : "list");
7071
7933
  continue;
7072
7934
  }
7073
- if (!(0, import_spec2.isAggregatedViewContainer)(v)) continue;
7935
+ if (!(0, import_spec3.isAggregatedViewContainer)(v)) continue;
7074
7936
  const object = viewContainerObjectName(v);
7075
7937
  if (object) containers.push({ object, container: v });
7076
7938
  }
7077
- for (const obj of asArray41(stack.objects)) {
7939
+ for (const obj of asArray45(stack.objects)) {
7078
7940
  const object = typeof obj.name === "string" ? obj.name : void 0;
7079
7941
  if (!object) continue;
7080
7942
  if (obj.list || obj.form || obj.listViews || obj.formViews) {
@@ -7082,7 +7944,7 @@ function lintViewRefs(stack) {
7082
7944
  }
7083
7945
  }
7084
7946
  for (const { object, container } of containers) {
7085
- const { items, collisions } = (0, import_spec2.expandViewContainerWithDiagnostics)(object, container);
7947
+ const { items, collisions } = (0, import_spec3.expandViewContainerWithDiagnostics)(object, container);
7086
7948
  for (const it of items) indexKind(it.name, it.viewKind);
7087
7949
  for (const col of collisions) {
7088
7950
  findings.push({
@@ -7128,11 +7990,11 @@ function lintViewRefs(stack) {
7128
7990
  });
7129
7991
  }
7130
7992
  };
7131
- for (const obj of asArray41(stack.objects)) {
7993
+ for (const obj of asArray45(stack.objects)) {
7132
7994
  const object = typeof obj.name === "string" ? obj.name : void 0;
7133
- for (const action of asArray41(obj.actions)) checkAction(action, object);
7995
+ for (const action of asArray45(obj.actions)) checkAction(action, object);
7134
7996
  }
7135
- for (const action of asArray41(stack.actions)) checkAction(action);
7997
+ for (const action of asArray45(stack.actions)) checkAction(action);
7136
7998
  return findings;
7137
7999
  }
7138
8000
 
@@ -7145,8 +8007,43 @@ function fieldEntries2(fields) {
7145
8007
  return Object.entries(fields).map(([name, def]) => ({ name, def }));
7146
8008
  }
7147
8009
  var UNIQUE_DOUBLE_DECLARATION = "unique/double-declaration";
8010
+ var UNIQUE_UNSCOPED_DECLARED_INDEX = "unique/unscoped-declared-index";
8011
+ var UNIQUE_LEGACY_ORGANIZATION_COMPOSITE = "unique/legacy-organization-composite";
8012
+ function authoredTenantColumn(obj) {
8013
+ const declared = obj?.tenancy?.tenantField;
8014
+ return typeof declared === "string" && declared.trim() ? declared.trim() : "organization_id";
8015
+ }
7148
8016
  function uniqueDeclared(u) {
7149
- return u === true || u === "global";
8017
+ return u === true || u === "global" || u === "organization";
8018
+ }
8019
+ function fieldUniqueScope(u) {
8020
+ return u === "global" ? "global" : "organization";
8021
+ }
8022
+ function indexUniqueScope(u) {
8023
+ return u === "organization" ? "organization" : "global";
8024
+ }
8025
+ function lintUnscopedDeclaredIndexes(objects) {
8026
+ const issues = [];
8027
+ if (!Array.isArray(objects) || objects.length === 0) return issues;
8028
+ for (let i = 0; i < objects.length; i++) {
8029
+ const obj = objects[i];
8030
+ if (!obj?.name) continue;
8031
+ const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
8032
+ for (let j = 0; j < declaredIndexes.length; j++) {
8033
+ const idx = declaredIndexes[j];
8034
+ if (idx?.unique !== true) continue;
8035
+ const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string").join(", ") : "";
8036
+ const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
8037
+ issues.push({
8038
+ severity: "warning",
8039
+ rule: UNIQUE_UNSCOPED_DECLARED_INDEX,
8040
+ 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).`,
8041
+ path: `objects[${i}].indexes[${j}]`,
8042
+ 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).`
8043
+ });
8044
+ }
8045
+ }
8046
+ return issues;
7150
8047
  }
7151
8048
  function lintUniqueDeclarations(objects) {
7152
8049
  const issues = [];
@@ -7166,16 +8063,59 @@ function lintUniqueDeclarations(objects) {
7166
8063
  if (singleColumnUniqueIndexes.size === 0) continue;
7167
8064
  for (const { name, def } of fieldEntries2(obj.fields)) {
7168
8065
  if (!uniqueDeclared(def?.unique)) continue;
7169
- if (def.unique === "global") continue;
7170
8066
  const idx = singleColumnUniqueIndexes.get(name);
7171
8067
  if (!idx) continue;
8068
+ const fScope = fieldUniqueScope(def.unique);
8069
+ const iScope = indexUniqueScope(idx.unique);
7172
8070
  const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
8071
+ const fieldSpelling = `\`unique: ${typeof def.unique === "string" ? `'${def.unique}'` : def.unique}\``;
8072
+ const indexSpelling = `\`unique: ${typeof idx.unique === "string" ? `'${idx.unique}'` : idx.unique}\``;
8073
+ let message;
8074
+ let fix;
8075
+ if (fScope === iScope) {
8076
+ const boundary = fScope === "global" ? "installation-wide" : "per-organization";
8077
+ 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.`;
8078
+ 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.`;
8079
+ } else {
8080
+ const globalSide = fScope === "global" ? `field-level ${fieldSpelling}` : `declared index${indexLabel} (${indexSpelling})`;
8081
+ const orgSide = fScope === "global" ? `declared index${indexLabel} (${indexSpelling})` : `field-level ${fieldSpelling}`;
8082
+ 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.`;
8083
+ 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.`;
8084
+ }
7173
8085
  issues.push({
7174
8086
  severity: "warning",
7175
8087
  rule: UNIQUE_DOUBLE_DECLARATION,
7176
- 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.`,
8088
+ message,
7177
8089
  path: `objects[${i}]`,
7178
- 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.`
8090
+ fix
8091
+ });
8092
+ }
8093
+ }
8094
+ return issues;
8095
+ }
8096
+ function lintLegacyOrganizationComposites(objects) {
8097
+ const issues = [];
8098
+ if (!Array.isArray(objects) || objects.length === 0) return issues;
8099
+ for (let i = 0; i < objects.length; i++) {
8100
+ const obj = objects[i];
8101
+ if (!obj?.name) continue;
8102
+ const tenantColumn = authoredTenantColumn(obj);
8103
+ const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
8104
+ for (let j = 0; j < declaredIndexes.length; j++) {
8105
+ const idx = declaredIndexes[j];
8106
+ if (!uniqueDeclared(idx?.unique) || idx.unique === "organization") continue;
8107
+ const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string") : [];
8108
+ if (cols.length < 2) continue;
8109
+ if (!cols.includes(tenantColumn)) continue;
8110
+ const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
8111
+ const spelling = `\`unique: ${typeof idx.unique === "string" ? `'${idx.unique}'` : idx.unique}\``;
8112
+ const rest = cols.filter((c) => c !== tenantColumn);
8113
+ issues.push({
8114
+ severity: "warning",
8115
+ rule: UNIQUE_LEGACY_ORGANIZATION_COMPOSITE,
8116
+ 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.`,
8117
+ path: `objects[${i}].indexes[${j}]`,
8118
+ 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.`
7179
8119
  });
7180
8120
  }
7181
8121
  }
@@ -7324,6 +8264,42 @@ var AUTHORING_RULES = [
7324
8264
  runtimeTypes: ["flow"],
7325
8265
  run: (stack) => validateReferenceIntegrity(stack)
7326
8266
  },
8267
+ // ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
8268
+ // is `z.record(z.string(), z.unknown())` and ADR-0089 D3a strictness does not
8269
+ // recurse into it, so until this entry existed the 31 typed prop schemas in
8270
+ // `ComponentPropsMap` were parsed by NOTHING (#4001 批 17's `no gate`
8271
+ // verdict): an undeclared or wrongly-typed prop parsed clean, was retained,
8272
+ // and reached objectui's renderer to be ignored there. This dispatches on
8273
+ // `type` and judges the bag; unregistered types are skipped, which is a
8274
+ // required semantic (`type` is an open union — the example corpus authors 87
8275
+ // nodes of 10 types this map does not carry).
8276
+ //
8277
+ // `normalized` for a reason worth stating, since the props bag survives the
8278
+ // Zod parse UNCHANGED and both tiers would otherwise carry the same data: the
8279
+ // ADR-0087 conversion layer runs inside `normalizeStackInput`, so a converted
8280
+ // alias (`page-header-subtitle-alias` rewrites `properties.description` →
8281
+ // `subtitle`) is already canonical here and is never reported as undeclared —
8282
+ // while a schema error elsewhere in the stack cannot take these findings down
8283
+ // with it.
8284
+ //
8285
+ // Advisory, deliberately, and this is the whole shape of #5068's first step:
8286
+ // wiring the parse is the precondition for enforcement, not the enforcement
8287
+ // (#5020, one surface over). The live corpus violates the declarations in two
8288
+ // places that are open contract questions — inline i18n label maps on three
8289
+ // published platform pages (#5728) and the record picker's declared-but-unread
8290
+ // `displayField` (#5775) — so gating today would fail the platform's own pages
8291
+ // to enforce declarations the platform does not keep. The error upgrade is a
8292
+ // separate step, once the warning-period inventory is empty.
8293
+ {
8294
+ name: "validateComponentProps",
8295
+ tier: "advisory",
8296
+ input: "normalized",
8297
+ commands: ALL,
8298
+ source: "packages/lint/src/validate-component-props.ts",
8299
+ surfaces: CLI_ONLY,
8300
+ surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
8301
+ run: (stack) => validateComponentProps(stack)
8302
+ },
7327
8303
  // ADR-0065 — a styled node's responsiveStyles must be scopable (needs an
7328
8304
  // `id`), name real CSS properties + design tokens, and carry a `large` base.
7329
8305
  {
@@ -7387,18 +8363,32 @@ var AUTHORING_RULES = [
7387
8363
  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.',
7388
8364
  run: (stack) => validateCapabilityReferences(stack)
7389
8365
  },
7390
- // A record-change flow whose start-node objectName matches nothing never
7391
- // fires — silently. Reads the pre-parse tier so an author sees what they
7392
- // wrote. Advisory: the object may come from another installed package.
8366
+ // A flow that LOOKS armed and never launches — silently. Reads the pre-parse
8367
+ // tier so an author sees what they wrote.
8368
+ //
8369
+ // `gating` since #5762, which reviewed the file's rules as one family and
8370
+ // split them on a single question: is THIS STACK enough to know the flow is
8371
+ // dead? Three rules answer yes and now emit `error` — a `config.timeRelative`
8372
+ // the spec's own `TimeRelativeTriggerSchema` refuses, one the engine's routing
8373
+ // predicate cannot route at all, and a `record-*` triggerType outside the
8374
+ // closed token grammar `triggerTypeToHookEvents` maps. None of those verdicts
8375
+ // can be changed by installing a package, so there is no reading under which
8376
+ // the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
8377
+ // (the object may come from another installed package — a hedge this rule
8378
+ // cannot decide), as did `flow-draft-status-ambiguous` (draft flows DO fire;
8379
+ // that one is ambiguity of intent, not a dead flow).
7393
8380
  {
7394
8381
  name: "validateFlowTriggerReadiness",
7395
- tier: "advisory",
8382
+ tier: "gating",
7396
8383
  input: "normalized",
7397
8384
  commands: ALL,
7398
8385
  source: "packages/lint/src/validate-flow-trigger-readiness.ts",
7399
- // Runtime publish gate (#4463): the FLOW family. Advisory at this surface
7400
- // too its findings are logged, not thrown (P1 gates on `error` only; P2
7401
- // puts advisories on the response for Studio to render).
8386
+ // Runtime publish gate (#4463): the FLOW family. Its `error` findings now
8387
+ // REFUSE a `state: 'active'` write (P1 gates on `error` only); the rules that
8388
+ // stayed `warning` keep being logged as advisories. The gate judges a
8389
+ // snapshot whose `flows` holds only the written item and subtracts the
8390
+ // baseline's findings, so this refuses the dead flow's own publish — never
8391
+ // another flow's save on account of a stored one.
7402
8392
  surfaces: CLI_AND_RUNTIME,
7403
8393
  runtimeTypes: ["flow"],
7404
8394
  run: (stack) => validateFlowTriggerReadiness(stack)
@@ -7609,9 +8599,31 @@ var AUTHORING_RULES = [
7609
8599
  hint: f.hint
7610
8600
  }))
7611
8601
  },
7612
- // #3991 — a column carrying BOTH a field-level `unique: true` and a
7613
- // single-column declared unique index has two intents, of which exactly one
7614
- // takes effect (the global index wins; the tenant composite is unreachable).
8602
+ // ADR-0120 D5a — a declared index with bare `unique: true` states no scope
8603
+ // at all (`unique/unscoped-declared-index` the #4986 trap). Fires on the
8604
+ // spelling alone, no tenancy inference; 17.x warns, protocol 18 rejects the
8605
+ // spelling (#5082).
8606
+ {
8607
+ name: "lintUnscopedDeclaredIndexes",
8608
+ tier: "advisory",
8609
+ input: "parsed",
8610
+ commands: ["validate", "build"],
8611
+ source: "packages/lint/src/data-model-rules.ts",
8612
+ surfaces: CLI_ONLY,
8613
+ surfaceReason: RUNTIME_OBJECT_WRITES_P2,
8614
+ 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.",
8615
+ run: (stack) => lintUnscopedDeclaredIndexes(Array.isArray(stack.objects) ? stack.objects : []).map((f) => ({
8616
+ severity: f.severity === "suggestion" ? "info" : f.severity,
8617
+ rule: f.rule,
8618
+ where: f.path,
8619
+ path: f.path,
8620
+ message: f.message,
8621
+ hint: f.fix ?? ""
8622
+ }))
8623
+ },
8624
+ // #3991 / ADR-0120 D5b — a column carrying BOTH a field-level `unique` and a
8625
+ // single-column declared unique index states two scopes of which at most one
8626
+ // takes effect (`unique/double-declaration`, the four-quadrant matrix).
7615
8627
  {
7616
8628
  name: "lintUniqueDeclarations",
7617
8629
  tier: "advisory",
@@ -7630,6 +8642,28 @@ var AUTHORING_RULES = [
7630
8642
  hint: f.fix ?? ""
7631
8643
  }))
7632
8644
  },
8645
+ // ADR-0120 D5c — a declared unique listing the organization column IS the
8646
+ // hand-written per-organization composite (S6). Advisory nudge toward the
8647
+ // `'organization'` respelling, which is also what closes its NULL hole
8648
+ // (#5030). Never auto-fixed: opting in is a real D4 tightening.
8649
+ {
8650
+ name: "lintLegacyOrganizationComposites",
8651
+ tier: "advisory",
8652
+ input: "parsed",
8653
+ commands: ["validate", "build"],
8654
+ source: "packages/lint/src/data-model-rules.ts",
8655
+ surfaces: CLI_ONLY,
8656
+ surfaceReason: RUNTIME_OBJECT_WRITES_P2,
8657
+ 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.",
8658
+ run: (stack) => lintLegacyOrganizationComposites(Array.isArray(stack.objects) ? stack.objects : []).map((f) => ({
8659
+ severity: f.severity === "suggestion" ? "info" : f.severity,
8660
+ rule: f.rule,
8661
+ where: f.path,
8662
+ path: f.path,
8663
+ message: f.message,
8664
+ hint: f.fix ?? ""
8665
+ }))
8666
+ },
7633
8667
  // ADR-0090 D7 — the security-domain publish linter. Every `error` rule mirrors
7634
8668
  // a runtime enforcement point (fail-closed OWD default, canonical enum, anchor
7635
8669
  // binding gate, vocabulary freeze), moving the failure from a runtime deny to
@@ -7657,6 +8691,88 @@ var AUTHORING_RULES = [
7657
8691
  surfaces: CLI_ONLY,
7658
8692
  surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
7659
8693
  run: (stack) => validateOrgAxisRedLines(stack)
8694
+ },
8695
+ // #4698 — the "declared but never read" gate, for the one surface where the
8696
+ // predicate is EXACT rather than inferred. A sharing rule's `condition` has a
8697
+ // single runtime consumer (`bootstrapDeclaredSharingRules`) whose only use of
8698
+ // the key is `compileCelToFilter(condition, { variables: {} })`; a condition
8699
+ // that does not lower means the rule is SKIPPED at boot, so the grant is
8700
+ // declared and does not exist. The lint calls that same compiler, from the
8701
+ // same package, with the same options — the verdict cannot drift from the
8702
+ // consumer's. Gating for the ADR-0078 reason `SharingRuleSchema`'s own
8703
+ // docblock states: the whole authorable surface is enforced, and this was the
8704
+ // one field where that sentence was not yet true.
8705
+ {
8706
+ name: "validateSharingRuleEnforceability",
8707
+ tier: "gating",
8708
+ input: "parsed",
8709
+ commands: ALL,
8710
+ source: "packages/lint/src/validate-sharing-rule-enforceability.ts",
8711
+ surfaces: CLI_ONLY,
8712
+ 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.",
8713
+ run: (stack) => validateSharingRuleEnforceability(stack)
8714
+ },
8715
+ // #4983 — the sibling surface of the rule above, and ADR-0056 D4's gate,
8716
+ // which had never been wired to anything: `isSupportedRlsExpression` existed
8717
+ // solely so an authoring command could reject a predicate the runtime drops,
8718
+ // and no authoring command called it. An unlowerable
8719
+ // `rowLevelSecurity[].using` is DROPPED by `RLSCompiler` and — when it is the
8720
+ // only applicable policy — replaced by `RLS_DENY_FILTER`, so the policy reads
8721
+ // as an authorization and behaves as a blanket refusal. Same construction as
8722
+ // the sharing-rule entry: the verdict is the runtime's own function, reached
8723
+ // through `@objectstack/formula` (where #4983 hoisted it), never a model of it.
8724
+ {
8725
+ name: "validateRlsPredicateEnforceability",
8726
+ tier: "gating",
8727
+ input: "parsed",
8728
+ commands: ALL,
8729
+ source: "packages/lint/src/validate-rls-predicate-enforceability.ts",
8730
+ surfaces: CLI_ONLY,
8731
+ 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.",
8732
+ run: (stack) => validateRlsPredicateEnforceability(stack)
8733
+ },
8734
+ // #4762 — the same "declared but enforces nothing" question, for the two
8735
+ // STATIC artifacts an object validation rule carries. A `format` rule's
8736
+ // `regex` that `new RegExp(...)` throws on, and a `json_schema` rule's schema
8737
+ // ajv cannot compile, are both logged and SKIPPED on the write path
8738
+ // (`rule-validator.ts`), so the rule ships, lists, and protects nothing.
8739
+ // Neither needs a record to judge, so the authoring door is the right one:
8740
+ // rejecting a broken regex at RUNTIME instead would reject every write
8741
+ // touching that field for as long as the metadata is deployed (#4762's own
8742
+ // analysis — the runtime-backstop question stays open for the maintainer).
8743
+ // Gating for the `lint-flow-patterns.ts` bar: no reading of the metadata
8744
+ // behaves as written, because the rule does not run at all.
8745
+ {
8746
+ name: "validateRuleCompilability",
8747
+ tier: "gating",
8748
+ input: "parsed",
8749
+ commands: ALL,
8750
+ source: "packages/lint/src/validate-rule-compilability.ts",
8751
+ surfaces: CLI_ONLY,
8752
+ surfaceReason: RUNTIME_OBJECT_WRITES_P2,
8753
+ run: (stack) => validateRuleCompilability(stack)
8754
+ },
8755
+ // #5178 — the residual half of #5029, which registering `ajv-formats` does
8756
+ // NOT close: under `strict: false` a MISSPELLED format name (`emial`) is
8757
+ // logged once and DROPPED, so the rule compiles, ships, runs on every write
8758
+ // and enforces nothing for the keyword its author wrote — and the record is
8759
+ // accepted, which is the silent direction. Deliberately its own entry rather
8760
+ // than a third finding inside the rule above: that one's whole contract is
8761
+ // compiling in the runtime's exact environment, and a typo'd format compiles
8762
+ // there. This judges the format NAME against the registered set (enumerated
8763
+ // from the same ajv instance, never a hardcoded list) and compiles nothing,
8764
+ // so the #4762/#5029 compile parity is untouched — a judgement beside the
8765
+ // compile, not a divergent compile. Gating for the `lint-flow-patterns.ts`
8766
+ // bar: no reading of the metadata behaves as written.
8767
+ {
8768
+ name: "validateRuleSchemaFormats",
8769
+ tier: "gating",
8770
+ input: "parsed",
8771
+ commands: ALL,
8772
+ source: "packages/lint/src/validate-rule-schema-formats.ts",
8773
+ surfaces: CLI_ONLY,
8774
+ surfaceReason: RUNTIME_OBJECT_WRITES_P2,
8775
+ run: (stack) => validateRuleSchemaFormats(stack)
7660
8776
  }
7661
8777
  ];
7662
8778