@malloy-publisher/server 0.8.0 → 0.8.1

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.
Files changed (21) hide show
  1. package/dist/app/api-doc.yaml +105 -70
  2. package/dist/app/assets/{EnvironmentPage-Be-JAMS6.js → EnvironmentPage-dgZIJhMw.js} +1 -1
  3. package/dist/app/assets/{HomePage-DsdQpZuT.js → HomePage-D2FGM4cV.js} +1 -1
  4. package/dist/app/assets/{LightMode-DyjqECoH.js → LightMode-emBuHCdz.js} +1 -1
  5. package/dist/app/assets/{MainPage-DAaQnh6y.js → MainPage-BVqdH0TB.js} +1 -1
  6. package/dist/app/assets/{ModelPage-B7yaRDmM.js → ModelPage-DAJxkTmk.js} +2 -2
  7. package/dist/app/assets/{PackagePage-7b0klbWy.js → PackagePage-BGGcyDeJ.js} +1 -1
  8. package/dist/app/assets/{RenderedResult-CYyhQekT.es-DJ3BRrLl.js → RenderedResult-CDA9mN_6.es-C4f_ZzvB.js} +2 -2
  9. package/dist/app/assets/{RouteError-kdHl1qpH.js → RouteError-CtRFTa_q.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-DIt45eWv.js → ThemeEditorPage-Bnd5dgI0.js} +1 -1
  11. package/dist/app/assets/{ToggleButtonGroup-Dl_oTC8r.js → ToggleButtonGroup-CxU1OIGN.js} +1 -1
  12. package/dist/app/assets/{index-t9-rAE4l.js → index-BWGMOIxQ.js} +1 -1
  13. package/dist/app/assets/{index-C1qGlR2a.js → index-CNQ_NxFx.js} +4 -4
  14. package/dist/app/assets/{index-T1Cl-rQv.js → index-OsUlLBi_.js} +1 -1
  15. package/dist/app/assets/{index-C5hv_y6G.js → index-aXk5ykLp.js} +1 -1
  16. package/dist/app/assets/{index-Bzqs2UuN.js → index-xgxo45Xn.js} +1 -1
  17. package/dist/app/assets/{index.es-BlAu_Rj1.js → index.es-BCvB2VgL.js} +22 -22
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +1328 -776
  20. package/dist/server.mjs +2469 -397
  21. package/package.json +1 -1
package/dist/server.mjs CHANGED
@@ -176449,6 +176449,8 @@ function internalErrorToHttpError(error) {
176449
176449
  return httpError(422, error.message);
176450
176450
  } else if (error instanceof ModelCompilationError) {
176451
176451
  return httpError(424, error.message);
176452
+ } else if (error instanceof PackageManifestError) {
176453
+ return httpError(424, error.message);
176452
176454
  } else if (error instanceof ConnectionError) {
176453
176455
  if (error.callerSafe) {
176454
176456
  return httpError(502, error.message);
@@ -176489,7 +176491,7 @@ function httpError(code, message, reason) {
176489
176491
  }
176490
176492
  };
176491
176493
  }
176492
- var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, MaterializationEligibilityError, PublisherConfigError, FrozenConfigError, AccessDeniedError, NotQueryableError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
176494
+ var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, PublisherConfigError, FrozenConfigError, AccessDeniedError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
176493
176495
  var init_errors = __esm(() => {
176494
176496
  init_constants();
176495
176497
  init_logger();
@@ -176564,6 +176566,12 @@ var init_errors = __esm(() => {
176564
176566
  super(error.message);
176565
176567
  }
176566
176568
  };
176569
+ PackageManifestError = class PackageManifestError extends Error {
176570
+ constructor(message) {
176571
+ super(message);
176572
+ this.name = "PackageManifestError";
176573
+ }
176574
+ };
176567
176575
  MaterializationEligibilityError = class MaterializationEligibilityError extends Error {
176568
176576
  reason;
176569
176577
  constructor(error) {
@@ -176596,6 +176604,12 @@ var init_errors = __esm(() => {
176596
176604
  this.name = "NotQueryableError";
176597
176605
  }
176598
176606
  };
176607
+ OffSurfaceError = class OffSurfaceError extends NotQueryableError {
176608
+ constructor(message) {
176609
+ super(message);
176610
+ this.name = "OffSurfaceError";
176611
+ }
176612
+ };
176599
176613
  MaterializationNotFoundError = class MaterializationNotFoundError extends Error {
176600
176614
  constructor(message) {
176601
176615
  super(message);
@@ -270749,6 +270763,12 @@ function deserializeError(serialized) {
270749
270763
  wrapped.stack = serialized.stack;
270750
270764
  return wrapped;
270751
270765
  }
270766
+ if (serialized.isManifestError) {
270767
+ const manifestError = new PackageManifestError(serialized.message);
270768
+ if (serialized.stack)
270769
+ manifestError.stack = serialized.stack;
270770
+ return manifestError;
270771
+ }
270752
270772
  return err;
270753
270773
  }
270754
270774
  function getPackageLoadPool() {
@@ -270801,11 +270821,11 @@ function recordAuthorizeBypass(entryPoint) {
270801
270821
  });
270802
270822
  bypassCounter.add(1, { entry_point: entryPoint });
270803
270823
  }
270804
- function recordRowLevelGateDecision(decision) {
270824
+ function recordRowLevelGateDecision(decision, site = "entry_point") {
270805
270825
  rowLevelDecisionCounter ??= publisherMeter().createCounter("publisher_authorize_row_level_total", {
270806
- description: "How a row-level `#(access_filter)` gate resolved a request. Label: decision ('denied_by_gate'|'empty_after_filter'). 'denied_by_gate' is the fail-closed refusal when the gate could not be applied; 'empty_after_filter' is a successful response with zero rows after the filter matched none, which is NOT an error."
270826
+ description: "How a row-level `#(access_filter)` gate resolved a request. Labels: decision ('denied_by_gate'|'empty_after_filter'), site ('entry_point'|'caller_join'). 'denied_by_gate' is the fail-closed refusal when the gate could not be applied; 'empty_after_filter' is a successful response with zero rows after the filter matched none, which is NOT an error. site says whether the gate was the query's own run target or a source the caller's text joined."
270807
270827
  });
270808
- rowLevelDecisionCounter.add(1, { decision });
270828
+ rowLevelDecisionCounter.add(1, { decision, site });
270809
270829
  }
270810
270830
  function recordRowLevelGateRejected(cause) {
270811
270831
  rowLevelRejectionCounter ??= publisherMeter().createCounter("publisher_authorize_row_level_rejected_total", {
@@ -270819,11 +270839,11 @@ function recordAuthorizeAdmitAllGate(route) {
270819
270839
  });
270820
270840
  admitAllCounter.add(1, { route });
270821
270841
  }
270822
- function recordLockDecision(decision) {
270842
+ function recordLockDecision(decision, site = "entry_point") {
270823
270843
  lockDecisionCounter ??= publisherMeter().createCounter("publisher_authorize_lock_total", {
270824
- description: "How a `#(authorize)` lock resolved a request. Label: decision ('admitted'|'denied_by_lock'|'denied_unresolvable'). Both denials are a 403; 'denied_by_lock' is the gate refusing a caller it does not admit (routine), 'denied_unresolvable' is the fail-closed refusal when the gate could not be decided at all."
270844
+ description: "How a `#(authorize)` lock resolved a request. Labels: decision ('admitted'|'denied_by_lock'|'denied_unresolvable'), site ('entry_point'|'caller_join'). Both denials are a 403; 'denied_by_lock' is the gate refusing a caller it does not admit (routine), 'denied_unresolvable' is the fail-closed refusal when the gate could not be decided at all. site says whether the lock was on the query's own run target or on a source the caller's text joined."
270825
270845
  });
270826
- lockDecisionCounter.add(1, { decision });
270846
+ lockDecisionCounter.add(1, { decision, site });
270827
270847
  }
270828
270848
  var guardRejectionCounter = null, bypassCounter = null, rowLevelDecisionCounter = null, rowLevelRejectionCounter = null, admitAllCounter = null, lockDecisionCounter = null;
270829
270849
  var init_authorize_metrics = __esm(() => {
@@ -274478,18 +274498,20 @@ function buildPredicate(filter2, value) {
274478
274498
  }
274479
274499
  }
274480
274500
  }
274501
+ function filterHasValue(filter2, params) {
274502
+ const value = params[filter2.name];
274503
+ return value !== undefined && value !== null && (Array.isArray(value) ? value.length > 0 : value !== "");
274504
+ }
274481
274505
  function buildFilterClause(filters, params) {
274482
274506
  const predicates2 = [];
274483
274507
  for (const filter2 of filters) {
274484
- const value = params[filter2.name];
274485
- const hasValue = value !== undefined && value !== null && (Array.isArray(value) ? value.length > 0 : value !== "");
274486
- if (!hasValue) {
274508
+ if (!filterHasValue(filter2, params)) {
274487
274509
  if (filter2.required) {
274488
274510
  throw new FilterValidationError(`Required filter "${filter2.name}" (dimension: ${filter2.dimension}) was not provided`);
274489
274511
  }
274490
274512
  continue;
274491
274513
  }
274492
- predicates2.push(buildPredicate(filter2, value));
274514
+ predicates2.push(buildPredicate(filter2, params[filter2.name]));
274493
274515
  }
274494
274516
  if (predicates2.length === 0) {
274495
274517
  return "";
@@ -274605,85 +274627,1051 @@ var init_json_utils = __esm(() => {
274605
274627
  });
274606
274628
 
274607
274629
  // src/service/query_text.ts
274630
+ import { ParseUtil } from "@malloydata/malloy-tag";
274608
274631
  function extractRunTargetSourceName(query) {
274632
+ return extractRunTargetSourceNames(query).at(-1);
274633
+ }
274634
+ function extractRunTargetSourceNames(query) {
274609
274635
  if (!query)
274610
- return;
274611
- const runMatch = query.match(/run\s*:\s*(?:`([^`]+)`|(\w+))/);
274612
- const arrowMatch = query.match(/^\s*(?:`([^`]+)`|(\w+))\s*->/m);
274613
- return runMatch?.[1] ?? runMatch?.[2] ?? arrowMatch?.[1] ?? arrowMatch?.[2];
274636
+ return [];
274637
+ const tokens = tokenize2(stripMalloyCommentsAndLiterals(query));
274638
+ const targets = [];
274639
+ for (let k = 0;k + 1 < tokens.count; k++) {
274640
+ if (!isWord(tokens, k, "run") || !textIs(tokens, k + 1, ":"))
274641
+ continue;
274642
+ let j = k + 2;
274643
+ while (textIs(tokens, j, "(") && tokens.kind[j] === PUNCT)
274644
+ j++;
274645
+ if (isName(tokens, j))
274646
+ targets.push(textOf(tokens, j));
274647
+ }
274648
+ if (targets.length > 0)
274649
+ return targets;
274650
+ for (let k = 0;k + 2 < tokens.count; k++) {
274651
+ if (isName(tokens, k) && textIs(tokens, k + 1, "-") && textIs(tokens, k + 2, ">") && tokens.start[k + 2] === tokens.start[k + 1] + 1 && opensLine(tokens, k)) {
274652
+ return [textOf(tokens, k)];
274653
+ }
274654
+ }
274655
+ return [];
274656
+ }
274657
+ function opensLine(tokens, k) {
274658
+ if (k === 0)
274659
+ return true;
274660
+ const from = tokens.end[k - 1];
274661
+ const to = tokens.start[k];
274662
+ for (let at = from;at < to; at++) {
274663
+ const code = tokens.source.charCodeAt(at);
274664
+ if (code === 10 || code === 13)
274665
+ return true;
274666
+ }
274667
+ return false;
274668
+ }
274669
+ function malloyIdentifiers(text) {
274670
+ return [...scanIdentifiers(stripMalloyCommentsAndLiterals(text))];
274614
274671
  }
274615
274672
  function buildSourceAliasMap(query) {
274616
274673
  const aliasOf = new Map;
274617
- const text = stripMalloyCommentsAndLiterals(query);
274618
- const ident = String.raw`(?:\x60([^\x60]+)\x60|([\p{L}\p{N}_]+))`;
274619
- const declRe = new RegExp(String.raw`source\s*:\s*${ident}(?:\s*\([^)]*\))?\s+is\s*\(?\s*${ident}`, "gu");
274620
- let match;
274621
- while ((match = declRe.exec(text)) !== null) {
274622
- aliasOf.set(match[1] ?? match[2], match[3] ?? match[4]);
274623
- }
274674
+ readDefinitions(query, (keyword, name, base) => {
274675
+ if (keyword === "source" && base !== UNREADABLE_BASE) {
274676
+ aliasOf.set(name, base);
274677
+ }
274678
+ });
274624
274679
  return aliasOf;
274625
274680
  }
274626
274681
  function stripMalloyCommentsAndLiterals(text) {
274627
- const out = text.split("");
274682
+ if (typeof text !== "string")
274683
+ throw new TypeError("query text must be a string");
274684
+ const parts = [];
274685
+ let copied = 0;
274628
274686
  const blank = (from, to) => {
274629
- for (let i = from;i < to && i < out.length; i++) {
274630
- if (out[i] !== `
274631
- `)
274632
- out[i] = " ";
274687
+ const end = Math.min(to, text.length);
274688
+ if (from >= end)
274689
+ return;
274690
+ parts.push(text.slice(copied, from));
274691
+ let run = from;
274692
+ for (let k = from;k < end; k++) {
274693
+ if (text.charCodeAt(k) < 32) {
274694
+ let slashes = k;
274695
+ while (slashes > run && text.charCodeAt(slashes - 1) === 92)
274696
+ slashes--;
274697
+ parts.push(" ".repeat(slashes - run), text.slice(slashes, k + 1));
274698
+ run = k + 1;
274699
+ }
274700
+ }
274701
+ let tail = end;
274702
+ if (end < text.length && text.charCodeAt(end) < 32) {
274703
+ while (tail > run && text.charCodeAt(tail - 1) === 92)
274704
+ tail--;
274705
+ }
274706
+ parts.push(" ".repeat(tail - run), text.slice(tail, end));
274707
+ copied = end;
274708
+ };
274709
+ const inert = (from, to) => {
274710
+ parts.push(text.slice(copied, from), "~".repeat(to - from));
274711
+ copied = to;
274712
+ };
274713
+ const scan = new StringScan(text);
274714
+ const sqlResume = [];
274715
+ let depth = 0;
274716
+ const sql = (from) => {
274717
+ const stop = sqlStop(text, from);
274718
+ blank(from, stop.bodyEnd);
274719
+ if (stop.opensCode) {
274720
+ sqlResume.push(depth);
274721
+ depth++;
274633
274722
  }
274723
+ return stop.end;
274634
274724
  };
274635
- for (let i = 0;i < text.length; i++) {
274636
- const two = text.slice(i, i + 2);
274637
- if (two === "--" || two === "//") {
274638
- const nl = text.indexOf(`
274639
- `, i);
274640
- const end = nl === -1 ? text.length : nl;
274641
- blank(i, end);
274642
- i = end;
274643
- continue;
274725
+ let prefixAt = -1;
274726
+ let blankedSince = -1;
274727
+ const marks = [];
274728
+ const trivia = (from, to) => {
274729
+ blank(from, to);
274730
+ if (prefixAt < 0)
274731
+ return;
274732
+ for (let k = Math.min(to, text.length) - 1;k >= from; k--) {
274733
+ if (text.charCodeAt(k) >= 32) {
274734
+ blankedSince = k;
274735
+ break;
274736
+ }
274644
274737
  }
274645
- if (two === "/*") {
274738
+ };
274739
+ let i = 0;
274740
+ while (i < text.length) {
274741
+ const code = text.charCodeAt(i);
274742
+ if (text.startsWith("--", i) || text.startsWith("//", i) || code === 35) {
274743
+ const end = code === 35 ? skipAnnotation(text, i, trivia) : lineTrivia(text, i, trivia);
274744
+ if (end >= 0) {
274745
+ i = end;
274746
+ continue;
274747
+ }
274748
+ } else if (text.startsWith("/*", i)) {
274646
274749
  const close = text.indexOf("*/", i + 2);
274647
274750
  const end = close === -1 ? text.length : close + 2;
274648
- blank(i, end);
274649
- i = end - 1;
274751
+ trivia(i, end);
274752
+ i = end;
274650
274753
  continue;
274651
274754
  }
274652
- const ch = text[i];
274653
- if (ch === "`") {
274654
- const close = text.indexOf("`", i + 1);
274655
- i = close === -1 ? text.length : close;
274755
+ if (prefixAt >= 0 && !isLexerSpace(code)) {
274756
+ if ((code === 39 || code === 34 || code === 96) && blankedSince >= 0) {
274757
+ marks.push(blankedSince);
274758
+ }
274759
+ prefixAt = -1;
274760
+ blankedSince = -1;
274761
+ }
274762
+ if (code === 123) {
274763
+ depth++;
274764
+ i++;
274656
274765
  continue;
274657
274766
  }
274658
- if (ch === "'" || ch === '"') {
274659
- let j = i + 1;
274660
- while (j < text.length && text[j] !== ch) {
274661
- if (text[j] === "\\")
274662
- j++;
274663
- j++;
274767
+ if (code === 125) {
274768
+ depth--;
274769
+ i++;
274770
+ if (sqlResume.length > 0 && sqlResume[sqlResume.length - 1] === depth) {
274771
+ sqlResume.pop();
274772
+ i = sql(i);
274664
274773
  }
274665
- blank(i + 1, j);
274666
- i = j;
274667
274774
  continue;
274668
274775
  }
274776
+ if (text.startsWith('"""', i)) {
274777
+ i = sql(i + 3);
274778
+ continue;
274779
+ }
274780
+ if (code === 39 || code === 34 || code === 96) {
274781
+ const close = scan.stringClose(i);
274782
+ if (close < 0) {
274783
+ i++;
274784
+ continue;
274785
+ }
274786
+ if (code !== 96)
274787
+ blank(i + 1, close);
274788
+ i = close + 1;
274789
+ continue;
274790
+ }
274791
+ const prefixed = scan.prefixedLiteral(i);
274792
+ if (prefixed) {
274793
+ const { open: open2, quote, bodyEnd, end } = prefixed;
274794
+ const tick = text.charCodeAt(open2) === 96;
274795
+ if (tick)
274796
+ inert(open2, open2 + quote);
274797
+ blank(open2 + quote, bodyEnd);
274798
+ if (tick && text.charCodeAt(bodyEnd) === 96)
274799
+ inert(bodyEnd, end);
274800
+ i = end;
274801
+ continue;
274802
+ }
274803
+ const letter = code | 32;
274804
+ if ((letter === 102 || letter === 115) && !continuesToken(text, i)) {
274805
+ prefixAt = i;
274806
+ }
274807
+ i++;
274808
+ }
274809
+ parts.push(text.slice(copied));
274810
+ let out = parts.join("");
274811
+ for (const at of marks)
274812
+ out = `${out.slice(0, at)}~${out.slice(at + 1)}`;
274813
+ return out;
274814
+ }
274815
+ function lineTrivia(text, i, blank) {
274816
+ const end = toEndOfLine(text, i);
274817
+ if (end >= 0)
274818
+ blank(i, end);
274819
+ return end;
274820
+ }
274821
+ function lineEnd(text, i) {
274822
+ let at = i;
274823
+ while (at < text.length) {
274824
+ const code = text.charCodeAt(at);
274825
+ if (code === 10 || code === 13)
274826
+ return at;
274827
+ at++;
274828
+ }
274829
+ return at;
274830
+ }
274831
+ function toEndOfLine(text, i) {
274832
+ const end = lineEnd(text, i);
274833
+ if (text.charCodeAt(end) === 13 && text.charCodeAt(end + 1) !== 10) {
274834
+ return -1;
274835
+ }
274836
+ return end;
274837
+ }
274838
+ function nextLine(text, i) {
274839
+ const end = lineEnd(text, i);
274840
+ if (text.charCodeAt(end) === 13 && text.charCodeAt(end + 1) === 10) {
274841
+ return end + 2;
274842
+ }
274843
+ return Math.min(end + 1, text.length);
274844
+ }
274845
+ function skipAnnotation(text, i, blank) {
274846
+ const end = toEndOfLine(text, i);
274847
+ if (end < 0)
274848
+ return -1;
274849
+ const closer = text.startsWith("##|", i) ? "|##" : text.startsWith("#|", i) ? "|#" : undefined;
274850
+ if (!closer) {
274851
+ blank(i, end);
274852
+ return end;
274853
+ }
274854
+ let column = 0;
274855
+ for (let at2 = text.lastIndexOf(`
274856
+ `, i - 1) + 1;at2 < i; at2++) {
274857
+ if ((text.charCodeAt(at2) & 64512) !== 56320)
274858
+ column++;
274859
+ }
274860
+ let at = nextLine(text, i);
274861
+ blank(i, at);
274862
+ while (at < text.length) {
274863
+ const closes = closesBlock(text, at, column, closer);
274864
+ const after = nextLine(text, at);
274865
+ blank(at, after);
274866
+ at = after;
274867
+ if (closes)
274868
+ break;
274669
274869
  }
274670
- return out.join("");
274870
+ return at;
274871
+ }
274872
+ function closesBlock(text, at, column, closer) {
274873
+ for (let c = 0;c < column; c++) {
274874
+ const code = text.charCodeAt(at + c);
274875
+ if (code !== 32 && code !== 9)
274876
+ return false;
274877
+ }
274878
+ const from = at + column;
274879
+ if (!text.startsWith(closer, from))
274880
+ return false;
274881
+ return closer !== "|#" || text.charCodeAt(from + 2) !== 35;
274882
+ }
274883
+ function sqlStop(text, from) {
274884
+ const reach = [false, false, false, false];
274885
+ let furthest = from;
274886
+ const mark = (at) => {
274887
+ reach[at & 3] = true;
274888
+ furthest = Math.max(furthest, at);
274889
+ };
274890
+ mark(from);
274891
+ let stop = {
274892
+ bodyEnd: text.length,
274893
+ end: text.length,
274894
+ opensCode: false
274895
+ };
274896
+ let found = false;
274897
+ for (let j = from;j < text.length && j <= furthest; j++) {
274898
+ if (!reach[j & 3])
274899
+ continue;
274900
+ reach[j & 3] = false;
274901
+ const code = text.charCodeAt(j);
274902
+ const next = text.charCodeAt(j + 1);
274903
+ if (code === 34 && next === 34 && text.charCodeAt(j + 2) === 34) {
274904
+ if (!found || j + 3 > stop.end) {
274905
+ stop = { bodyEnd: j, end: j + 3, opensCode: false };
274906
+ }
274907
+ found = true;
274908
+ continue;
274909
+ }
274910
+ if (code === 37 && next === 123) {
274911
+ if (!found || j + 2 > stop.end) {
274912
+ stop = { bodyEnd: j, end: j + 2, opensCode: true };
274913
+ }
274914
+ found = true;
274915
+ continue;
274916
+ }
274917
+ if (code === 92) {
274918
+ mark(j + 1);
274919
+ mark(j + 2);
274920
+ } else if (code === 34) {
274921
+ mark(next === 34 ? j + 3 : j + 2);
274922
+ } else if (code === 37) {
274923
+ mark(j + 2);
274924
+ } else {
274925
+ mark(j + 1);
274926
+ }
274927
+ }
274928
+ return stop;
274929
+ }
274930
+ function isLexerSpace(code) {
274931
+ return code === 32 || code === 9 || code === 10 || code === 11 || code === 13 || code === 160;
274932
+ }
274933
+
274934
+ class StringScan {
274935
+ text;
274936
+ stringFailedAt = [-1, -1, -1];
274937
+ tripleUnclosedFrom = [Infinity, Infinity, Infinity];
274938
+ constructor(text) {
274939
+ this.text = text;
274940
+ }
274941
+ stringClose(i) {
274942
+ const text = this.text;
274943
+ const quote = text.charCodeAt(i);
274944
+ const slot = quoteSlot(quote);
274945
+ if (i < this.stringFailedAt[slot])
274946
+ return -1;
274947
+ let j = i + 1;
274948
+ while (j < text.length) {
274949
+ const code = text.charCodeAt(j);
274950
+ if (code === quote)
274951
+ return j;
274952
+ if (code === 92) {
274953
+ if (j + 1 >= text.length || text.charCodeAt(j + 1) === 10)
274954
+ break;
274955
+ j += 2;
274956
+ continue;
274957
+ }
274958
+ if (code < 32 && code !== 9)
274959
+ break;
274960
+ j++;
274961
+ }
274962
+ this.stringFailedAt[slot] = j;
274963
+ return -1;
274964
+ }
274965
+ prefixedLiteral(i) {
274966
+ const text = this.text;
274967
+ const code = text.charCodeAt(i);
274968
+ if (code === 47) {
274969
+ if (text.charCodeAt(i + 1) !== 39)
274970
+ return;
274971
+ return this.raw(i + 1, 39, false);
274972
+ }
274973
+ const letter = code | 32;
274974
+ if (letter !== 102 && letter !== 114 && letter !== 115)
274975
+ return;
274976
+ if (continuesToken(text, i))
274977
+ return;
274978
+ if (letter === 114) {
274979
+ return text.charCodeAt(i + 1) === 39 ? this.raw(i + 1, 39, false) : undefined;
274980
+ }
274981
+ let open2 = i + 1;
274982
+ while (open2 < text.length && isLexerSpace(text.charCodeAt(open2)))
274983
+ open2++;
274984
+ const quote = text.charCodeAt(open2);
274985
+ if (letter === 115) {
274986
+ return quote === 39 || quote === 34 ? this.raw(open2, quote, true) : undefined;
274987
+ }
274988
+ if (quote !== 39 && quote !== 34 && quote !== 96)
274989
+ return;
274990
+ if (text.charCodeAt(open2 + 1) === quote && text.charCodeAt(open2 + 2) === quote) {
274991
+ const close = this.tripleClose(open2 + 3, quote);
274992
+ if (close >= 0) {
274993
+ return { open: open2, quote: 3, bodyEnd: close, end: close + 3 };
274994
+ }
274995
+ }
274996
+ return this.raw(open2, quote, true);
274997
+ }
274998
+ raw(open2, quote, endsAtNewline) {
274999
+ const text = this.text;
275000
+ let j = open2 + 1;
275001
+ while (j < text.length) {
275002
+ const code = text.charCodeAt(j);
275003
+ if (code === 10) {
275004
+ return endsAtNewline ? { open: open2, quote: 1, bodyEnd: j, end: j + 1 } : undefined;
275005
+ }
275006
+ if (code === 92) {
275007
+ if (j + 1 >= text.length || text.charCodeAt(j + 1) === 10) {
275008
+ return;
275009
+ }
275010
+ j += 2;
275011
+ continue;
275012
+ }
275013
+ if (code === quote)
275014
+ return { open: open2, quote: 1, bodyEnd: j, end: j + 1 };
275015
+ j++;
275016
+ }
275017
+ return;
275018
+ }
275019
+ tripleClose(body, quote) {
275020
+ const text = this.text;
275021
+ const slot = quoteSlot(quote);
275022
+ if (body >= this.tripleUnclosedFrom[slot])
275023
+ return -1;
275024
+ let j = body;
275025
+ while (j < text.length) {
275026
+ const code = text.charCodeAt(j);
275027
+ if (code === 92) {
275028
+ j += 2;
275029
+ continue;
275030
+ }
275031
+ if (code === quote && text.charCodeAt(j + 1) === quote && text.charCodeAt(j + 2) === quote) {
275032
+ return j;
275033
+ }
275034
+ j++;
275035
+ }
275036
+ this.tripleUnclosedFrom[slot] = body;
275037
+ return -1;
275038
+ }
275039
+ }
275040
+ function quoteSlot(quote) {
275041
+ return quote === 39 ? 0 : quote === 34 ? 1 : 2;
275042
+ }
275043
+ function continuesToken(text, i) {
275044
+ if (i === 0)
275045
+ return false;
275046
+ let at = i - 1;
275047
+ if ((text.charCodeAt(at) & 64512) === 56320 && at > 0)
275048
+ at--;
275049
+ const code = text.charCodeAt(at);
275050
+ return code === 36 || isNameCode(code, text, at);
274671
275051
  }
274672
275052
  function buildDerivationBaseMap(query) {
274673
- const basesOf = new Map;
274674
- const text = stripMalloyCommentsAndLiterals(query);
274675
- const ident = String.raw`(?:\x60([^\x60]+)\x60|([\p{L}\p{N}_]+))`;
274676
- const declRe = new RegExp(String.raw`(?:source|query)\s*:\s*${ident}(?:\s*\([^)]*\))?\s+is\s*\(?\s*${ident}`, "gu");
274677
- let match;
274678
- while ((match = declRe.exec(text)) !== null) {
274679
- const name = match[1] ?? match[2];
274680
- const base = match[3] ?? match[4];
274681
- const bases = basesOf.get(name) ?? new Set;
274682
- bases.add(base);
274683
- basesOf.set(name, bases);
275053
+ const out = new Map;
275054
+ readDefinitions(query, (_keyword, name, base) => addEdge(out, name, base));
275055
+ return out;
275056
+ }
275057
+ function readDefinitions(query, onItem) {
275058
+ const tokens = tokenize2(stripMalloyCommentsAndLiterals(query));
275059
+ let depth = 0;
275060
+ for (let i = 0;i < tokens.count; i++) {
275061
+ if (isPunct(tokens, i, "{"))
275062
+ depth++;
275063
+ else if (isPunct(tokens, i, "}"))
275064
+ depth--;
275065
+ if (depth !== 0 || !isStatementKeyword(tokens, i))
275066
+ continue;
275067
+ const keyword = isWord(tokens, i, "source") ? "source" : isWord(tokens, i, "query") ? "query" : undefined;
275068
+ if (!keyword)
275069
+ continue;
275070
+ readStatementItems(tokens, i + 2, false, (name, base) => onItem(keyword, name, base));
275071
+ }
275072
+ }
275073
+ function isNameCode(code, text, at) {
275074
+ if (code < 128) {
275075
+ return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 95;
275076
+ }
275077
+ return NAME_CHAR.test(String.fromCodePoint(text.codePointAt(at)));
275078
+ }
275079
+ function isSpaceCode(code, text, at) {
275080
+ if (code < 128)
275081
+ return code === 32 || code >= 9 && code <= 13;
275082
+ return SPACE_CHAR.test(String.fromCodePoint(text.codePointAt(at)));
275083
+ }
275084
+ function unitsAt(text, at) {
275085
+ return text.codePointAt(at) > 65535 ? 2 : 1;
275086
+ }
275087
+ function tokenize2(text) {
275088
+ const size = text.length + 1;
275089
+ const tokens = {
275090
+ source: text,
275091
+ count: 0,
275092
+ kind: new Uint8Array(size),
275093
+ start: new Int32Array(size),
275094
+ end: new Int32Array(size),
275095
+ partner: new Int32Array(size).fill(-1),
275096
+ decoded: new Map
275097
+ };
275098
+ const push = (kind, start, end) => {
275099
+ const index = tokens.count++;
275100
+ tokens.kind[index] = kind;
275101
+ tokens.start[index] = start;
275102
+ tokens.end[index] = end;
275103
+ return index;
275104
+ };
275105
+ const open2 = [];
275106
+ const scan = new StringScan(text);
275107
+ let i = 0;
275108
+ while (i < text.length) {
275109
+ const code = text.charCodeAt(i);
275110
+ if (isSpaceCode(code, text, i)) {
275111
+ i += unitsAt(text, i);
275112
+ continue;
275113
+ }
275114
+ const close = code === 96 ? scan.stringClose(i) : -1;
275115
+ if (close >= 0) {
275116
+ const index2 = push(QUOTED, i + 1, close);
275117
+ const inner = text.slice(i + 1, close);
275118
+ if (inner.includes("\\")) {
275119
+ tokens.decoded.set(index2, ParseUtil.parseString(inner));
275120
+ }
275121
+ i = close + 1;
275122
+ continue;
275123
+ }
275124
+ if (isNameCode(code, text, i)) {
275125
+ let j = i + unitsAt(text, i);
275126
+ while (j < text.length && isNameCode(text.charCodeAt(j), text, j)) {
275127
+ j += unitsAt(text, j);
275128
+ }
275129
+ push(NAME, i, j);
275130
+ i = j;
275131
+ continue;
275132
+ }
275133
+ const units = unitsAt(text, i);
275134
+ const index = push(PUNCT, i, i + units);
275135
+ if (code === 40 || code === 91 || code === 123) {
275136
+ open2.push(index);
275137
+ } else if (code === 41 || code === 93 || code === 125) {
275138
+ const at = open2.pop();
275139
+ if (at !== undefined) {
275140
+ tokens.partner[at] = index;
275141
+ tokens.partner[index] = at;
275142
+ }
275143
+ }
275144
+ i += units;
275145
+ }
275146
+ return tokens;
275147
+ }
275148
+ function textOf(tokens, i) {
275149
+ return tokens.decoded.get(i) ?? tokens.source.slice(tokens.start[i], tokens.end[i]);
275150
+ }
275151
+ function textIs(tokens, i, char) {
275152
+ return i >= 0 && i < tokens.count && tokens.end[i] - tokens.start[i] === 1 && tokens.source.charCodeAt(tokens.start[i]) === char.charCodeAt(0);
275153
+ }
275154
+ function isPunct(tokens, i, char) {
275155
+ return textIs(tokens, i, char) && tokens.kind[i] === PUNCT;
275156
+ }
275157
+ function isName(tokens, i) {
275158
+ return i >= 0 && i < tokens.count && tokens.kind[i] !== PUNCT;
275159
+ }
275160
+ function isWord(tokens, i, word) {
275161
+ if (i < 0 || i >= tokens.count || tokens.kind[i] !== NAME || tokens.end[i] - tokens.start[i] !== word.length) {
275162
+ return false;
275163
+ }
275164
+ const from = tokens.start[i];
275165
+ for (let k = 0;k < word.length; k++) {
275166
+ const code = tokens.source.charCodeAt(from + k);
275167
+ const lower = code >= 65 && code <= 90 ? code + 32 : code;
275168
+ if (lower !== word.charCodeAt(k))
275169
+ return false;
274684
275170
  }
274685
- return basesOf;
275171
+ return true;
275172
+ }
275173
+ function isStatementKeyword(tokens, i) {
275174
+ return i < tokens.count && tokens.kind[i] === NAME && textIs(tokens, i + 1, ":") && !textIs(tokens, i + 2, ":") && !textIs(tokens, i - 1, ":") && !textIs(tokens, i - 1, ".");
275175
+ }
275176
+ function readBase(tokens, i) {
275177
+ let j = i;
275178
+ while (textIs(tokens, j, "(") && tokens.kind[j] === PUNCT)
275179
+ j++;
275180
+ if (!isName(tokens, j) || isLiteralPrefix(tokens, j))
275181
+ return UNREADABLE_BASE;
275182
+ return textOf(tokens, j);
275183
+ }
275184
+ function isLiteralPrefix(tokens, i) {
275185
+ if (!(isWord(tokens, i, "f") || isWord(tokens, i, "s") || isWord(tokens, i, "r"))) {
275186
+ return false;
275187
+ }
275188
+ return ["'", '"', "`", "~"].some((quote) => isPunct(tokens, i + 1, quote));
275189
+ }
275190
+ function readStatementItems(tokens, start, shorthand, onItem) {
275191
+ let atItemStart = true;
275192
+ let i = start;
275193
+ while (i < tokens.count) {
275194
+ if (tokens.kind[i] === PUNCT) {
275195
+ if (textIs(tokens, i, ";"))
275196
+ return;
275197
+ if (tokens.partner[i] > i) {
275198
+ i = tokens.partner[i] + 1;
275199
+ atItemStart = false;
275200
+ continue;
275201
+ }
275202
+ if (textIs(tokens, i, ")") || textIs(tokens, i, "]") || textIs(tokens, i, "}")) {
275203
+ return;
275204
+ }
275205
+ atItemStart = textIs(tokens, i, ",");
275206
+ i++;
275207
+ continue;
275208
+ }
275209
+ if (isStatementKeyword(tokens, i))
275210
+ return;
275211
+ let k = i + 1;
275212
+ if (textIs(tokens, k, "(") && tokens.partner[k] > k) {
275213
+ k = tokens.partner[k] + 1;
275214
+ }
275215
+ if (isWord(tokens, k, "is")) {
275216
+ onItem(textOf(tokens, i), readBase(tokens, k + 1));
275217
+ i = k + 1;
275218
+ } else {
275219
+ if (shorthand && atItemStart) {
275220
+ const name = textOf(tokens, i);
275221
+ onItem(name, name);
275222
+ }
275223
+ i++;
275224
+ }
275225
+ atItemStart = false;
275226
+ }
275227
+ }
275228
+ function addEdge(map, name, base) {
275229
+ const bases = map.get(name) ?? new Set;
275230
+ bases.add(base);
275231
+ map.set(name, bases);
275232
+ }
275233
+ function buildJoinBaseMap(query) {
275234
+ const out = new Map;
275235
+ const tokens = tokenize2(stripMalloyCommentsAndLiterals(query));
275236
+ for (let i = 0;i < tokens.count; i++) {
275237
+ if (!isStatementKeyword(tokens, i))
275238
+ continue;
275239
+ if (!/^join_(?:one|many|cross)$/i.test(textOf(tokens, i)))
275240
+ continue;
275241
+ readStatementItems(tokens, i + 2, true, (alias, base) => addEdge(out, alias, base));
275242
+ }
275243
+ return out;
275244
+ }
275245
+ function buildIsEdgeMap(query) {
275246
+ const out = new Map;
275247
+ const tokens = tokenize2(stripMalloyCommentsAndLiterals(query));
275248
+ for (let k = 1;k < tokens.count; k++) {
275249
+ if (!isWord(tokens, k, "is"))
275250
+ continue;
275251
+ let n = k - 1;
275252
+ if (textIs(tokens, n, ")") && tokens.kind[n] === PUNCT && tokens.partner[n] >= 0) {
275253
+ n = tokens.partner[n] - 1;
275254
+ }
275255
+ if (!isName(tokens, n))
275256
+ continue;
275257
+ addEdge(out, textOf(tokens, n), readBase(tokens, k + 1));
275258
+ }
275259
+ return out;
275260
+ }
275261
+ function scanIdentifiers(text) {
275262
+ const tokens = tokenize2(text);
275263
+ const names = new Set;
275264
+ for (let i = 0;i < tokens.count; i++) {
275265
+ if (isName(tokens, i))
275266
+ names.add(textOf(tokens, i));
275267
+ }
275268
+ return names;
274686
275269
  }
275270
+ function collectIdentifierNames(text) {
275271
+ const names = scanIdentifiers(text);
275272
+ for (const name of scanIdentifiers(stripMalloyCommentsAndLiterals(text))) {
275273
+ names.add(name);
275274
+ }
275275
+ return names;
275276
+ }
275277
+ var UNREADABLE_BASE = "`", PUNCT = 0, NAME = 1, QUOTED = 2, NAME_CHAR, SPACE_CHAR;
275278
+ var init_query_text = __esm(() => {
275279
+ NAME_CHAR = /\p{Alphabetic}/u;
275280
+ SPACE_CHAR = /\s/u;
275281
+ });
275282
+
275283
+ // src/service/caller_joins.ts
275284
+ import { isSourceDef as isSourceDef6 } from "@malloydata/malloy";
275285
+ function isCallerAuthored(location, region, authorUrls) {
275286
+ const url2 = location?.url;
275287
+ if (url2 === undefined)
275288
+ return true;
275289
+ if (region.kind === "span" && url2 === region.url) {
275290
+ const line = location?.range?.start?.line;
275291
+ return !(typeof line === "number" && line < region.fromLine);
275292
+ }
275293
+ return !authorUrls.has(url2);
275294
+ }
275295
+ function isSourceJoin(field) {
275296
+ return !!field.join && isSourceDef6(field);
275297
+ }
275298
+ function aliasOf(field) {
275299
+ return field.as ?? field.name ?? "";
275300
+ }
275301
+ function contentsStruct(modelDef, name) {
275302
+ const entry = modelDef?.contents[name];
275303
+ return entry && isSourceDef6(entry) ? entry : undefined;
275304
+ }
275305
+ function runTargetStruct(prepared) {
275306
+ const ref = prepared._query?.structRef;
275307
+ if (typeof ref === "string")
275308
+ return contentsStruct(prepared._modelDef, ref);
275309
+ return ref && typeof ref === "object" ? ref : undefined;
275310
+ }
275311
+ function collectCallerJoins(prepared, region, authorUrls) {
275312
+ const query = prepared._query;
275313
+ if (!query)
275314
+ throw new CallerJoinWalkError("no compiled query to walk");
275315
+ const isCaller = (location) => isCallerAuthored(location, region, authorUrls);
275316
+ const modelDef = prepared._modelDef;
275317
+ const found = [];
275318
+ const record = (join6, path10, depth) => {
275319
+ if (depth >= CALLER_JOIN_MAX_DEPTH) {
275320
+ throw new CallerJoinWalkError("caller joins nest past the depth bound");
275321
+ }
275322
+ if (found.length >= CALLER_JOIN_MAX_COUNT) {
275323
+ throw new CallerJoinWalkError("too many caller joins");
275324
+ }
275325
+ found.push({ join: join6, alias: aliasOf(join6), path: path10 });
275326
+ walkStruct(join6, path10, depth + 1);
275327
+ };
275328
+ const walkFields = (fields, path10, key, depth, synthesized) => {
275329
+ for (const field of fields ?? []) {
275330
+ const fieldPath = [...path10, key, aliasOf(field)];
275331
+ if (isSourceJoin(field)) {
275332
+ if (field.location?.url === undefined) {
275333
+ if (synthesized)
275334
+ continue;
275335
+ throw new CallerJoinWalkError("a join carries no location");
275336
+ }
275337
+ if (isCaller(field.location))
275338
+ record(field, fieldPath, depth);
275339
+ } else if (field.type === "turtle" && isCaller(field.location)) {
275340
+ walkPipeline(field.pipeline, [...fieldPath, "pipeline"], depth);
275341
+ }
275342
+ }
275343
+ };
275344
+ const walkStruct = (struct, path10, depth) => {
275345
+ const composite = struct.type === "composite";
275346
+ walkFields(struct.fields, path10, "fields", depth, composite);
275347
+ if (composite) {
275348
+ for (const [i, member] of (struct.sources ?? []).entries()) {
275349
+ walkStruct(member, [...path10, "sources", i], depth);
275350
+ }
275351
+ }
275352
+ if (struct.type === "query_source") {
275353
+ walkQuery(struct.query, [...path10, "query"], depth);
275354
+ }
275355
+ };
275356
+ const walkQuery = (q, path10, depth) => {
275357
+ if (!q)
275358
+ return;
275359
+ const ref = q.structRef;
275360
+ const base = typeof ref === "string" ? contentsStruct(modelDef, ref) : ref;
275361
+ if (base && (typeof ref !== "string" || isCaller(base.location))) {
275362
+ walkStruct(base, [...path10, "structRef"], depth);
275363
+ }
275364
+ if (q.compositeResolvedSourceDef) {
275365
+ walkStruct(q.compositeResolvedSourceDef, [...path10, "resolved"], depth);
275366
+ }
275367
+ walkPipeline(q.pipeline, [...path10, "pipeline"], depth);
275368
+ };
275369
+ const walkPipeline = (pipeline, path10, depth) => {
275370
+ for (const [i, segment] of (pipeline ?? []).entries()) {
275371
+ walkFields(segment.extendSource, [...path10, i], "extendSource", depth, false);
275372
+ for (const field of segment.queryFields ?? []) {
275373
+ if (field.type === "turtle") {
275374
+ walkPipeline(field.pipeline, [...path10, i, "queryFields", aliasOf(field), "pipeline"], depth);
275375
+ }
275376
+ }
275377
+ }
275378
+ };
275379
+ const target = runTargetStruct(prepared);
275380
+ if (target)
275381
+ walkStruct(target, ["target"], 0);
275382
+ const resolved = query.compositeResolvedSourceDef;
275383
+ if (resolved) {
275384
+ assertResolvedJoinsAccountedFor(resolved, target, isCaller);
275385
+ walkStruct(resolved, ["resolved"], 0);
275386
+ }
275387
+ walkPipeline(query.pipeline, ["pipeline"], 0);
275388
+ assertEveryCallerJoinPlaced(prepared, found, isCaller);
275389
+ return found;
275390
+ }
275391
+ function assertResolvedJoinsAccountedFor(resolved, target, isCaller) {
275392
+ const declared = new Set((target?.fields ?? []).filter(isSourceJoin).map((f) => aliasOf(f)));
275393
+ for (const field of resolved.fields ?? []) {
275394
+ if (!isSourceJoin(field) || isCaller(field.location))
275395
+ continue;
275396
+ if (!declared.has(aliasOf(field))) {
275397
+ throw new CallerJoinWalkError("a resolved composite carries a join the declared source does not");
275398
+ }
275399
+ }
275400
+ }
275401
+ function assertEveryCallerJoinPlaced(prepared, found, isCaller) {
275402
+ const placed = new Set(found.map((f) => f.join));
275403
+ const seen = new Set;
275404
+ let budget = IR_SCAN_MAX_NODES;
275405
+ const stack = [prepared._query, runTargetStruct(prepared)];
275406
+ const modelDef = prepared._modelDef;
275407
+ while (stack.length > 0) {
275408
+ const value = stack.pop();
275409
+ if (!value || typeof value !== "object" || seen.has(value))
275410
+ continue;
275411
+ seen.add(value);
275412
+ if (--budget < 0) {
275413
+ throw new CallerJoinWalkError("compiled query too large to scan");
275414
+ }
275415
+ if (Array.isArray(value)) {
275416
+ for (const item of value)
275417
+ stack.push(item);
275418
+ continue;
275419
+ }
275420
+ const node = value;
275421
+ if (isSourceJoin(node)) {
275422
+ if (!isCaller(node.location))
275423
+ continue;
275424
+ if (!placed.has(node)) {
275425
+ throw new CallerJoinWalkError("a caller join sits where the walk does not look");
275426
+ }
275427
+ }
275428
+ for (const [key, child] of Object.entries(node)) {
275429
+ if (key === "location" || key === "annotations" || key === "outputStruct") {
275430
+ continue;
275431
+ }
275432
+ if (key === "fields" && node.type === "composite")
275433
+ continue;
275434
+ if (key === "structRef" && typeof child === "string") {
275435
+ const named = contentsStruct(modelDef, child);
275436
+ if (named && isCaller(named.location))
275437
+ stack.push(named);
275438
+ continue;
275439
+ }
275440
+ stack.push(child);
275441
+ }
275442
+ }
275443
+ }
275444
+ function derivationTerminals(start, basesOf, isModelSource, maxNames = DERIVATION_CHAIN_MAX_NAMES) {
275445
+ const seen = new Set;
275446
+ const terminals = [];
275447
+ const worklist = [start];
275448
+ for (let i = 0;i < worklist.length; i++) {
275449
+ const name = worklist[i];
275450
+ if (seen.has(name))
275451
+ continue;
275452
+ seen.add(name);
275453
+ if (seen.size > maxNames)
275454
+ return { proven: false, at: name };
275455
+ if (isModelSource(name)) {
275456
+ terminals.push(name);
275457
+ continue;
275458
+ }
275459
+ const bases = basesOf.get(name);
275460
+ if (!bases || bases.size === 0)
275461
+ return { proven: false, at: name };
275462
+ for (const base of bases)
275463
+ worklist.push(base);
275464
+ }
275465
+ return { proven: true, terminals };
275466
+ }
275467
+ function resolveCallerJoin(callerJoin, compiledModelDef, region, context) {
275468
+ const names = new Set;
275469
+ const boundaryNames = new Set;
275470
+ const callerSources = [];
275471
+ let composite = false;
275472
+ let curatedQuery;
275473
+ let unprovenAt;
275474
+ let boundaryUnprovenAt;
275475
+ const isCaller = (location) => isCallerAuthored(location, region, context.authorUrls);
275476
+ const text = regionMaps(region);
275477
+ const fail = (at) => {
275478
+ unprovenAt ??= at;
275479
+ return false;
275480
+ };
275481
+ const add = (name, forBoundary) => {
275482
+ names.add(name);
275483
+ if (forBoundary)
275484
+ boundaryNames.add(name);
275485
+ };
275486
+ const chainOf = (start) => derivationTerminals(start, text.derivations(), (name) => context.isModelSource(name));
275487
+ const byContentsKey = (key, forBoundary) => {
275488
+ const entry = contentsStruct(compiledModelDef, key);
275489
+ if (entry && isCaller(entry.location)) {
275490
+ const chain2 = chainOf(key);
275491
+ callerSources.push({ key, struct: entry, chain: chain2 });
275492
+ if (forBoundary) {
275493
+ if (chain2.proven) {
275494
+ for (const name2 of chain2.terminals)
275495
+ boundaryNames.add(name2);
275496
+ } else {
275497
+ boundaryUnprovenAt ??= chain2.at;
275498
+ }
275499
+ }
275500
+ return true;
275501
+ }
275502
+ const name = entry ? aliasOf(entry) : key;
275503
+ if (context.isModelSource(name)) {
275504
+ add(name, forBoundary);
275505
+ return true;
275506
+ }
275507
+ if (entry)
275508
+ return fail(name);
275509
+ const chain = chainOf(key);
275510
+ if (!chain.proven)
275511
+ return fail(chain.at);
275512
+ for (const terminal of chain.terminals)
275513
+ add(terminal, forBoundary);
275514
+ return true;
275515
+ };
275516
+ const isUnmodifiedModelQuery = (q) => {
275517
+ if (!q.name || !context.isModelQuery(q.name))
275518
+ return false;
275519
+ const declared = compiledModelDef.contents[q.name];
275520
+ return declared?.type === "query" && !isCaller(declared.location) && JSON.stringify(q.pipeline) === JSON.stringify(declared.pipeline);
275521
+ };
275522
+ const bySourceID = (sourceID, forBoundary) => {
275523
+ const keys = Object.entries(compiledModelDef.contents).filter(([, value]) => isSourceDef6(value) && value.sourceID === sourceID).map(([key]) => key);
275524
+ if (keys.length === 0)
275525
+ return fail(sourceID);
275526
+ return keys.every((key) => byContentsKey(key, forBoundary));
275527
+ };
275528
+ const resolveStruct = (struct, isJoinItself, forBoundary, depth) => {
275529
+ if (depth > RESOLVE_MAX_DEPTH)
275530
+ return fail(callerJoin.alias);
275531
+ let anchored = false;
275532
+ if (struct.sourceID) {
275533
+ if (!bySourceID(struct.sourceID, forBoundary))
275534
+ return false;
275535
+ anchored = true;
275536
+ }
275537
+ if (struct.type === "composite") {
275538
+ composite = true;
275539
+ const members = struct.sources;
275540
+ if (!Array.isArray(members) || members.length === 0) {
275541
+ return fail(callerJoin.alias);
275542
+ }
275543
+ const membersForBoundary = forBoundary && !struct.sourceID;
275544
+ for (const member of members) {
275545
+ if (!resolveStruct(member, false, membersForBoundary, depth + 1)) {
275546
+ return false;
275547
+ }
275548
+ }
275549
+ anchored = true;
275550
+ }
275551
+ if (struct.type === "query_source") {
275552
+ const q = struct.query;
275553
+ if (isJoinItself && q && isUnmodifiedModelQuery(q)) {
275554
+ curatedQuery = q.name;
275555
+ }
275556
+ const ref = q?.structRef;
275557
+ if (typeof ref === "string") {
275558
+ if (!byContentsKey(ref, forBoundary))
275559
+ return false;
275560
+ } else if (ref && typeof ref === "object") {
275561
+ if (!resolveStruct(ref, false, forBoundary, depth + 1))
275562
+ return false;
275563
+ } else {
275564
+ return fail(callerJoin.alias);
275565
+ }
275566
+ anchored = true;
275567
+ }
275568
+ if (anchored)
275569
+ return true;
275570
+ if (!isJoinItself)
275571
+ return fail(callerJoin.alias);
275572
+ const bases = new Set([
275573
+ ...text.joins().get(callerJoin.alias) ?? [],
275574
+ ...text.isEdges().get(callerJoin.alias) ?? []
275575
+ ]);
275576
+ if (bases.size === 0)
275577
+ return fail(callerJoin.alias);
275578
+ for (const base of bases) {
275579
+ if (!byContentsKey(base, forBoundary))
275580
+ return false;
275581
+ }
275582
+ return true;
275583
+ };
275584
+ if (!resolveStruct(callerJoin.join, true, true, 0)) {
275585
+ return { kind: "unproven", at: unprovenAt ?? callerJoin.alias };
275586
+ }
275587
+ return {
275588
+ kind: "resolved",
275589
+ names,
275590
+ boundaryNames,
275591
+ curatedQuery,
275592
+ composite,
275593
+ callerSources,
275594
+ boundaryUnprovenAt
275595
+ };
275596
+ }
275597
+ function regionMaps(region) {
275598
+ let maps = regionMapsCache.get(region);
275599
+ if (!maps) {
275600
+ const once = (build) => {
275601
+ let value;
275602
+ return () => value ??= build();
275603
+ };
275604
+ maps = {
275605
+ derivations: once(() => buildDerivationBaseMap(region.text)),
275606
+ joins: once(() => buildJoinBaseMap(region.text)),
275607
+ isEdges: once(() => buildIsEdgeMap(region.text))
275608
+ };
275609
+ regionMapsCache.set(region, maps);
275610
+ }
275611
+ return maps;
275612
+ }
275613
+ function locateCallerJoin(prepared, path10) {
275614
+ const query = prepared._query;
275615
+ const modelDef = prepared._modelDef;
275616
+ const resolve4 = (ref) => typeof ref === "string" ? contentsStruct(modelDef, ref) : ref;
275617
+ const byAlias = (list, alias) => {
275618
+ const matches = (list ?? []).filter((f) => aliasOf(f) === alias);
275619
+ return matches.length === 1 ? matches[0] : undefined;
275620
+ };
275621
+ let node;
275622
+ if (path10[0] === "target") {
275623
+ node = resolve4(query?.compositeResolvedSourceDef ?? query?.structRef);
275624
+ } else if (path10[0] === "resolved") {
275625
+ node = query?.compositeResolvedSourceDef;
275626
+ } else if (path10[0] === "pipeline") {
275627
+ node = query?.pipeline;
275628
+ } else {
275629
+ return;
275630
+ }
275631
+ let i = 1;
275632
+ while (i < path10.length && node !== undefined && node !== null) {
275633
+ const step = path10[i];
275634
+ if (typeof step === "number") {
275635
+ node = Array.isArray(node) ? node[step] : undefined;
275636
+ i += 1;
275637
+ continue;
275638
+ }
275639
+ const current = node;
275640
+ switch (step) {
275641
+ case "fields":
275642
+ case "extendSource":
275643
+ case "queryFields":
275644
+ node = byAlias(current[step], path10[i + 1]);
275645
+ i += 2;
275646
+ break;
275647
+ case "pipeline":
275648
+ case "query":
275649
+ case "sources":
275650
+ node = current[step];
275651
+ i += 1;
275652
+ break;
275653
+ case "structRef":
275654
+ node = resolve4(current.structRef);
275655
+ i += 1;
275656
+ break;
275657
+ case "resolved":
275658
+ node = current.compositeResolvedSourceDef;
275659
+ i += 1;
275660
+ break;
275661
+ default:
275662
+ return;
275663
+ }
275664
+ }
275665
+ const found = node;
275666
+ return found && typeof found === "object" && isSourceJoin(found) ? found : undefined;
275667
+ }
275668
+ var CallerJoinWalkError, CALLER_JOIN_MAX_DEPTH = 16, CALLER_JOIN_MAX_COUNT = 256, IR_SCAN_MAX_NODES = 200000, DERIVATION_CHAIN_MAX_NAMES = 64, RESOLVE_MAX_DEPTH = 8, regionMapsCache;
275669
+ var init_caller_joins = __esm(() => {
275670
+ init_query_text();
275671
+ CallerJoinWalkError = class CallerJoinWalkError extends Error {
275672
+ };
275673
+ regionMapsCache = new WeakMap;
275674
+ });
274687
275675
 
274688
275676
  // src/service/preaggregation_validation.ts
274689
275677
  import {
@@ -274917,10 +275905,430 @@ var init_preaggregation_validation = __esm(() => {
274917
275905
  init_preaggregation_classifier();
274918
275906
  });
274919
275907
 
274920
- // src/service/source_extraction.ts
275908
+ // src/service/filter_binding_guard.ts
274921
275909
  import {
274922
275910
  isJoined as isJoined4,
274923
- isSourceDef as isSourceDef6,
275911
+ isSourceDef as isSourceDef7
275912
+ } from "@malloydata/malloy";
275913
+ function nextDerivationLink(current, modelDef, seen) {
275914
+ const declared = resolveDeclaredSource(current, modelDef);
275915
+ if (declared.kind === "resolved" && !seen.has(declared.source)) {
275916
+ return declared.source;
275917
+ }
275918
+ if (declared.kind === "none") {
275919
+ const viaIdentity = findSourceByOwnAnnotationIdentity(current, modelDef, new Set(seen));
275920
+ if (viaIdentity && !seen.has(viaIdentity))
275921
+ return viaIdentity;
275922
+ const viaQuerySource = resolveQuerySourceBase(current, modelDef);
275923
+ if (viaQuerySource && !seen.has(viaQuerySource))
275924
+ return viaQuerySource;
275925
+ }
275926
+ return;
275927
+ }
275928
+ function activeName2(f) {
275929
+ return f.as ?? f.name;
275930
+ }
275931
+ function deepEqualIgnoring(a, b) {
275932
+ return JSON.stringify(strip(a)) === JSON.stringify(strip(b));
275933
+ }
275934
+ function strip(value) {
275935
+ if (Array.isArray(value))
275936
+ return value.map(strip);
275937
+ if (value && typeof value === "object") {
275938
+ const out = {};
275939
+ for (const [k, v] of Object.entries(value)) {
275940
+ if (IGNORED_KEYS.has(k))
275941
+ continue;
275942
+ out[k] = strip(v);
275943
+ }
275944
+ return out;
275945
+ }
275946
+ return value;
275947
+ }
275948
+ function fieldsIdentical(a, b) {
275949
+ if (!a || !b)
275950
+ return false;
275951
+ if (a.name !== b.name || a.type !== b.type)
275952
+ return false;
275953
+ if (!deepEqualIgnoring(a.e, b.e))
275954
+ return false;
275955
+ if (isJoined4(a) || isJoined4(b)) {
275956
+ if (!isJoined4(a) || !isJoined4(b))
275957
+ return false;
275958
+ if (a.join !== b.join)
275959
+ return false;
275960
+ if (!deepEqualIgnoring(a.onExpression, b.onExpression))
275961
+ return false;
275962
+ }
275963
+ return true;
275964
+ }
275965
+ function resolveFieldByPath(struct, path10) {
275966
+ let current = struct;
275967
+ let field;
275968
+ for (let i = 0;i < path10.length; i++) {
275969
+ if (!current)
275970
+ return;
275971
+ field = current.fields?.find((f) => activeName2(f) === path10[i]);
275972
+ if (!field)
275973
+ return;
275974
+ if (i < path10.length - 1) {
275975
+ if (!isJoined4(field))
275976
+ return;
275977
+ current = field;
275978
+ }
275979
+ }
275980
+ return field;
275981
+ }
275982
+ function fieldPathIdentical(declaring, executed, path10) {
275983
+ let dCur = declaring;
275984
+ let eCur = executed;
275985
+ for (let i = 0;i < path10.length; i++) {
275986
+ const name = path10[i];
275987
+ const dField = dCur?.fields?.find((f) => activeName2(f) === name);
275988
+ const eField = eCur?.fields?.find((f) => activeName2(f) === name);
275989
+ if (!fieldsIdentical(dField, eField))
275990
+ return false;
275991
+ if (i < path10.length - 1) {
275992
+ if (!dField || !isJoined4(dField) || !eField || !isJoined4(eField)) {
275993
+ return false;
275994
+ }
275995
+ dCur = dField;
275996
+ eCur = eField;
275997
+ }
275998
+ }
275999
+ return true;
276000
+ }
276001
+ function fieldUsageClosure(declaring, refSummary) {
276002
+ const seen = new Set;
276003
+ const paths = [];
276004
+ const queue = (refSummary?.fieldUsage ?? []).map((u) => [...u.path]);
276005
+ while (queue.length > 0) {
276006
+ if (paths.length >= MAX_CLOSURE_SIZE)
276007
+ return { paths, truncated: true };
276008
+ const path10 = queue.shift();
276009
+ if (!path10)
276010
+ break;
276011
+ const key = path10.join("\x00");
276012
+ if (seen.has(key))
276013
+ continue;
276014
+ seen.add(key);
276015
+ paths.push(path10);
276016
+ const field = resolveFieldByPath(declaring, path10);
276017
+ const nested = field?.refSummary;
276018
+ const prefix = path10.slice(0, -1);
276019
+ for (const u of nested?.fieldUsage ?? []) {
276020
+ queue.push([...prefix, ...u.path]);
276021
+ }
276022
+ }
276023
+ return { paths, truncated: false };
276024
+ }
276025
+ function assertFilterConditionBindsToDeclaringSource(declaringStruct, executedStruct, condition) {
276026
+ const { paths, truncated } = fieldUsageClosure(declaringStruct, condition.refSummary);
276027
+ if (truncated) {
276028
+ throw new Error("a row-security filter's field-usage closure exceeded the resolution bound");
276029
+ }
276030
+ for (const path10 of paths) {
276031
+ if (!fieldPathIdentical(declaringStruct, executedStruct, path10)) {
276032
+ throw new Error(`a row-security filter references \`${path10.join(".")}\`, which resolves to a different field on the executed source`);
276033
+ }
276034
+ }
276035
+ }
276036
+ function assertFilterDimensionBindsToDeclaringSource(declaringStruct, executedStruct, dimension) {
276037
+ const { paths, truncated } = fieldUsageClosure(declaringStruct, {
276038
+ fieldUsage: [{ path: [dimension] }]
276039
+ });
276040
+ if (truncated) {
276041
+ throw new Error("a #(filter) dimension's field-usage closure exceeded the resolution bound");
276042
+ }
276043
+ for (const path10 of paths) {
276044
+ if (!fieldPathIdentical(declaringStruct, executedStruct, path10)) {
276045
+ throw new Error(`a #(filter) dimension references \`${path10.join(".")}\`, which resolves to a different field on the executed source`);
276046
+ }
276047
+ }
276048
+ }
276049
+ function isEarlierPosition(a, b) {
276050
+ return a.line < b.line || a.line === b.line && a.character < b.character;
276051
+ }
276052
+ function allAnnotationNotes(annotations, depth = 0) {
276053
+ if (!annotations || depth > ANCESTOR_WALK_MAX_DEPTH)
276054
+ return [];
276055
+ return [
276056
+ ...annotations.blockNotes ?? [],
276057
+ ...annotations.notes ?? [],
276058
+ ...allAnnotationNotes(annotations.inherits, depth + 1)
276059
+ ];
276060
+ }
276061
+ function ownLevelNotes2(annotations) {
276062
+ return [...annotations?.blockNotes ?? [], ...annotations?.notes ?? []];
276063
+ }
276064
+ function findNoteOwner(notes, modelDef) {
276065
+ for (const note of notes) {
276066
+ if (!note.at)
276067
+ continue;
276068
+ let best;
276069
+ for (const value of Object.values(modelDef.contents)) {
276070
+ if (!isSourceDef7(value) || !value.location)
276071
+ continue;
276072
+ if (!ownLevelNotes2(value.annotations).includes(note))
276073
+ continue;
276074
+ if (value.location.url !== note.at.url)
276075
+ continue;
276076
+ if (!best?.location || isEarlierPosition(value.location.range.start, best.location.range.start)) {
276077
+ best = value;
276078
+ }
276079
+ }
276080
+ if (best)
276081
+ return best;
276082
+ }
276083
+ return;
276084
+ }
276085
+ function isGateNote(note) {
276086
+ try {
276087
+ return parseAuthorizeAnnotation(note.text) !== null;
276088
+ } catch {
276089
+ return false;
276090
+ }
276091
+ }
276092
+ function findAnnotationDeclaringSource(struct, modelDef, resolveSibling) {
276093
+ const notes = allAnnotationNotes(struct.annotations).filter(isGateNote);
276094
+ const viaNotes = findNoteOwner(notes, modelDef);
276095
+ if (viaNotes)
276096
+ return viaNotes;
276097
+ let current = struct;
276098
+ const seen = new Set([struct]);
276099
+ for (let depth = 0;depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
276100
+ const next = nextDerivationLink(current, modelDef, seen);
276101
+ if (!next)
276102
+ break;
276103
+ if (ownLevelNotes2(next.annotations).some(isGateNote))
276104
+ return next;
276105
+ seen.add(next);
276106
+ current = next;
276107
+ }
276108
+ for (const note of notes) {
276109
+ const sibling = findSiblingAnnotationDeclaringSource(note, resolveSibling);
276110
+ if (sibling)
276111
+ return sibling;
276112
+ }
276113
+ return findCompositeMemberDeclaringSource(struct, modelDef);
276114
+ }
276115
+ function findCompositeMemberDeclaringSource(struct, modelDef) {
276116
+ const member = resolveCompositeResolvedBase(struct);
276117
+ if (!member)
276118
+ return;
276119
+ if (ownLevelNotes2(member.annotations).some(isGateNote))
276120
+ return member;
276121
+ let current = member;
276122
+ const seen = new Set([member]);
276123
+ for (let depth = 0;depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
276124
+ const next = nextDerivationLink(current, modelDef, seen);
276125
+ if (!next)
276126
+ break;
276127
+ if (ownLevelNotes2(next.annotations).some(isGateNote))
276128
+ return next;
276129
+ seen.add(next);
276130
+ current = next;
276131
+ }
276132
+ return;
276133
+ }
276134
+ function findFilterAnnotationDeclaringSource(struct, modelDef, filter2, parseAnnotation2, resolveSibling) {
276135
+ const matches = (notes) => notes.filter((note) => {
276136
+ let parsed;
276137
+ try {
276138
+ parsed = parseAnnotation2(note.text);
276139
+ } catch {
276140
+ return false;
276141
+ }
276142
+ return !!parsed && parsed.dimension === filter2.dimension && parsed.type === filter2.type && parsed.name === filter2.name;
276143
+ });
276144
+ const matched = matches(allAnnotationNotes(struct.annotations));
276145
+ const viaNotes = findNoteOwner(matched, modelDef);
276146
+ if (viaNotes)
276147
+ return viaNotes;
276148
+ let current = struct;
276149
+ const seen = new Set([struct]);
276150
+ for (let depth = 0;depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
276151
+ const next = nextDerivationLink(current, modelDef, seen);
276152
+ if (!next)
276153
+ break;
276154
+ if (matches(ownLevelNotes2(next.annotations)).length > 0)
276155
+ return next;
276156
+ seen.add(next);
276157
+ current = next;
276158
+ }
276159
+ for (const note of matched) {
276160
+ const sibling = findSiblingAnnotationDeclaringSource(note, resolveSibling);
276161
+ if (sibling)
276162
+ return sibling;
276163
+ }
276164
+ return;
276165
+ }
276166
+ function assertGraftedGateBindsToDeclaringSource(entryPointStruct, executedStruct, condition, modelDef, resolveSibling) {
276167
+ if (!entryPointStruct) {
276168
+ throw new Error("a row-security gate's entry point could not be resolved");
276169
+ }
276170
+ const declaring = findAnnotationDeclaringSource(entryPointStruct, modelDef, resolveSibling);
276171
+ if (!declaring) {
276172
+ throw new Error("a row-security gate's declaring source could not be resolved");
276173
+ }
276174
+ assertFilterConditionBindsToDeclaringSource(declaring, executedStruct, condition);
276175
+ }
276176
+ function rangesEqual(a, b) {
276177
+ return a.start.line === b.start.line && a.start.character === b.start.character && a.end.line === b.end.line && a.end.character === b.end.character;
276178
+ }
276179
+ function locationContains(outer, inner) {
276180
+ if (outer.url !== inner.url)
276181
+ return false;
276182
+ if (isEarlierPosition(inner.range.start, outer.range.start))
276183
+ return false;
276184
+ if (isEarlierPosition(outer.range.end, inner.range.end))
276185
+ return false;
276186
+ return true;
276187
+ }
276188
+ function spanSize(range) {
276189
+ return (range.end.line - range.start.line) * 1e5 + (range.end.character - range.start.character);
276190
+ }
276191
+ function findConditionOriginByLocation(modelDef, at) {
276192
+ let best;
276193
+ let bestSpan = Infinity;
276194
+ for (const value of Object.values(modelDef.contents)) {
276195
+ if (!isSourceDef7(value) || !value.location)
276196
+ continue;
276197
+ if (!locationContains(value.location, at)) {
276198
+ continue;
276199
+ }
276200
+ const span = spanSize(value.location.range);
276201
+ if (span < bestSpan) {
276202
+ best = value;
276203
+ bestSpan = span;
276204
+ }
276205
+ }
276206
+ return best;
276207
+ }
276208
+ function findSiblingDeclaringSource(at, resolveSibling) {
276209
+ if (!at || !resolveSibling)
276210
+ return;
276211
+ const siblingModelDef = resolveSibling(at.url);
276212
+ if (!siblingModelDef)
276213
+ return;
276214
+ return findConditionOriginByLocation(siblingModelDef, at);
276215
+ }
276216
+ function findSiblingAnnotationDeclaringSource(note, resolveSibling) {
276217
+ if (!note?.at || !resolveSibling)
276218
+ return;
276219
+ const siblingModelDef = resolveSibling(note.at.url);
276220
+ if (!siblingModelDef)
276221
+ return;
276222
+ for (const value of Object.values(siblingModelDef.contents)) {
276223
+ if (!isSourceDef7(value))
276224
+ continue;
276225
+ for (const candidate of ownLevelNotes2(value.annotations)) {
276226
+ if (candidate.at && candidate.at.url === note.at.url && rangesEqual(candidate.at.range, note.at.range)) {
276227
+ return value;
276228
+ }
276229
+ }
276230
+ }
276231
+ return;
276232
+ }
276233
+ function firstFieldUsageLocation(condition) {
276234
+ const refSummary = condition.refSummary;
276235
+ const first2 = refSummary?.fieldUsage?.[0];
276236
+ return first2?.at;
276237
+ }
276238
+ function findFilterOrigin(struct, condition, modelDef, resolveSibling) {
276239
+ let origin2 = struct;
276240
+ let current = struct;
276241
+ const seen = new Set([struct]);
276242
+ for (let depth = 0;depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
276243
+ let next = modelDef ? nextDerivationLink(current, modelDef, seen) : undefined;
276244
+ if (!next && modelDef) {
276245
+ const at = firstFieldUsageLocation(condition);
276246
+ if (at) {
276247
+ const candidate = findConditionOriginByLocation(modelDef, at);
276248
+ if (candidate && !seen.has(candidate))
276249
+ next = candidate;
276250
+ }
276251
+ }
276252
+ if (!next || !next.filterList?.includes(condition))
276253
+ break;
276254
+ origin2 = next;
276255
+ seen.add(next);
276256
+ current = next;
276257
+ }
276258
+ if (origin2 === struct) {
276259
+ const sibling = findSiblingDeclaringSource(firstFieldUsageLocation(condition), resolveSibling);
276260
+ if (sibling && siblingCandidateOwnsCondition(sibling, condition)) {
276261
+ return sibling;
276262
+ }
276263
+ }
276264
+ return origin2;
276265
+ }
276266
+ function siblingCandidateOwnsCondition(candidate, condition) {
276267
+ const at = firstFieldUsageLocation(condition);
276268
+ if (!at)
276269
+ return false;
276270
+ return !!candidate.filterList?.some((c) => {
276271
+ if (c.code !== condition.code)
276272
+ return false;
276273
+ const candidateAt = firstFieldUsageLocation(c);
276274
+ return !!candidateAt && candidateAt.url === at.url && rangesEqual(candidateAt.range, at.range);
276275
+ });
276276
+ }
276277
+ function conditionReadsNoField(condition) {
276278
+ const refSummary = condition.refSummary;
276279
+ return !refSummary?.fieldUsage || refSummary.fieldUsage.length === 0;
276280
+ }
276281
+ function isOwnFreshFilter(struct, condition, freshness) {
276282
+ const at = firstFieldUsageLocation(condition);
276283
+ if (!at)
276284
+ return false;
276285
+ const structLocation = struct.location;
276286
+ if (structLocation && locationContains(structLocation, at))
276287
+ return true;
276288
+ if (freshness.queryLocation && locationContains(freshness.queryLocation, at)) {
276289
+ return true;
276290
+ }
276291
+ return !!freshness.compiledUrl && at.url === freshness.compiledUrl;
276292
+ }
276293
+ function assertInheritedSourceFiltersBind(struct, modelDef, freshness = {}, resolveSibling = undefined, depth = 0, visited = new Set, alreadyProven = new Set) {
276294
+ if (visited.has(struct))
276295
+ return;
276296
+ if (depth > MAX_JOIN_RECURSION_DEPTH) {
276297
+ throw new Error("row-security filter recursion exceeded the join-depth resolution bound");
276298
+ }
276299
+ visited.add(struct);
276300
+ for (const condition of struct.filterList ?? []) {
276301
+ if (alreadyProven.has(condition))
276302
+ continue;
276303
+ if (conditionReadsNoField(condition))
276304
+ continue;
276305
+ const origin2 = findFilterOrigin(struct, condition, modelDef, resolveSibling);
276306
+ if (origin2 !== struct) {
276307
+ assertFilterConditionBindsToDeclaringSource(origin2, struct, condition);
276308
+ continue;
276309
+ }
276310
+ if (isOwnFreshFilter(struct, condition, freshness))
276311
+ continue;
276312
+ throw new Error("a row-security filter's declaring source could not be resolved");
276313
+ }
276314
+ for (const field of struct.fields ?? []) {
276315
+ if (isJoined4(field) && isSourceDef7(field)) {
276316
+ assertInheritedSourceFiltersBind(field, modelDef, freshness, resolveSibling, depth + 1, visited, alreadyProven);
276317
+ }
276318
+ }
276319
+ }
276320
+ var MAX_CLOSURE_SIZE = 256, MAX_JOIN_RECURSION_DEPTH = 16, IGNORED_KEYS;
276321
+ var init_filter_binding_guard = __esm(() => {
276322
+ init_gate_registry_walk();
276323
+ init_gate_classification();
276324
+ init_authorize();
276325
+ IGNORED_KEYS = new Set(["location", "annotations", "accessModifier"]);
276326
+ });
276327
+
276328
+ // src/service/source_extraction.ts
276329
+ import {
276330
+ isJoined as isJoined5,
276331
+ isSourceDef as isSourceDef8,
274924
276332
  modelDefToModelInfo
274925
276333
  } from "@malloydata/malloy";
274926
276334
  function joinFieldNamesUnresolvableDeclaration(field, modelDef) {
@@ -274932,12 +276340,12 @@ function joinFieldNamesUnresolvableDeclaration(field, modelDef) {
274932
276340
  if (!entry)
274933
276341
  continue;
274934
276342
  const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
274935
- if (declared && isSourceDef6(declared))
276343
+ if (declared && isSourceDef8(declared))
274936
276344
  return false;
274937
276345
  }
274938
276346
  return true;
274939
276347
  }
274940
- function isEarlierPosition(a, b) {
276348
+ function isEarlierPosition2(a, b) {
274941
276349
  return a.line < b.line || a.line === b.line && a.character < b.character;
274942
276350
  }
274943
276351
  function considerNoteOwner(declaredBy, note, candidate) {
@@ -274945,12 +276353,12 @@ function considerNoteOwner(declaredBy, note, candidate) {
274945
276353
  if (!location || location.url !== note.at.url)
274946
276354
  return;
274947
276355
  const currentLocation = declaredBy.get(note)?.location;
274948
- if (!currentLocation || isEarlierPosition(location.range.start, currentLocation.range.start)) {
276356
+ if (!currentLocation || isEarlierPosition2(location.range.start, currentLocation.range.start)) {
274949
276357
  declaredBy.set(note, candidate);
274950
276358
  }
274951
276359
  }
274952
276360
  function collectSourceInfos(modelDef) {
274953
- const names = Object.keys(modelDef.contents).filter((name) => isSourceDef6(modelDef.contents[name]));
276361
+ const names = Object.keys(modelDef.contents).filter((name) => isSourceDef8(modelDef.contents[name]));
274954
276362
  const exports = [...new Set([...modelDef.exports, ...names])];
274955
276363
  return modelDefToModelInfo({ ...modelDef, exports }).entries.filter((entry) => entry.kind === "source");
274956
276364
  }
@@ -274963,7 +276371,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
274963
276371
  const nearMissAuthorize = [];
274964
276372
  const sweptStructs = [];
274965
276373
  for (const obj of Object.values(modelDef.contents)) {
274966
- if (!isSourceDef6(obj))
276374
+ if (!isSourceDef8(obj))
274967
276375
  continue;
274968
276376
  const struct = obj;
274969
276377
  sweptStructs.push(obj);
@@ -274982,7 +276390,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
274982
276390
  const entry = value.entry;
274983
276391
  if (entry.type === "source_registry_reference")
274984
276392
  continue;
274985
- if (!isSourceDef6(entry))
276393
+ if (!isSourceDef8(entry))
274986
276394
  continue;
274987
276395
  sweptStructs.push(entry);
274988
276396
  for (const note of ownLevelNotes(entry.annotations)) {
@@ -275015,7 +276423,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
275015
276423
  });
275016
276424
  }
275017
276425
  }
275018
- const sources = Object.values(modelDef.contents).filter((obj) => isSourceDef6(obj)).map((sourceObj) => {
276426
+ const sources = Object.values(modelDef.contents).filter((obj) => isSourceDef8(obj)).map((sourceObj) => {
275019
276427
  const struct = sourceObj;
275020
276428
  const sourceName = struct.as || struct.name;
275021
276429
  const annotations = annotationTexts(struct.annotations);
@@ -275090,7 +276498,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
275090
276498
  continue;
275091
276499
  }
275092
276500
  const fieldName2 = field.as || field.name;
275093
- if (isJoined4(field) && isSourceDef6(field)) {
276501
+ if (isJoined5(field) && isSourceDef8(field)) {
275094
276502
  const joinedStruct = field;
275095
276503
  if (joinFieldNamesUnresolvableDeclaration(joinedStruct, modelDef)) {
275096
276504
  continue;
@@ -275259,7 +276667,7 @@ import {
275259
276667
  API,
275260
276668
  FixedConnectionMap,
275261
276669
  InMemoryURLReader as InMemoryURLReader2,
275262
- isSourceDef as isSourceDef7,
276670
+ isSourceDef as isSourceDef9,
275263
276671
  MalloyConfig as MalloyConfig2,
275264
276672
  MalloyError as MalloyError4,
275265
276673
  Annotations as Annotations6,
@@ -275275,18 +276683,18 @@ import { readFileSync as readFileSync2 } from "fs";
275275
276683
  import { createRequire as createRequire2 } from "module";
275276
276684
  import * as path10 from "path";
275277
276685
  import { fileURLToPath as fileURLToPath6 } from "url";
275278
- function denyUnlessAdmitted(resolution, givens, label) {
276686
+ function denyUnlessAdmitted(resolution, givens, label, site = "entry_point") {
275279
276687
  if (resolution.shape === "lock") {
275280
276688
  const outcome = decideLock(resolution.condition.e, (id) => resolution.givenNamesById.get(id), givens);
275281
276689
  if (outcome === "admit") {
275282
- recordLockDecision("admitted");
276690
+ recordLockDecision("admitted", site);
275283
276691
  return;
275284
276692
  }
275285
- recordLockDecision(outcome === "unresolvable" ? "denied_unresolvable" : "denied_by_lock");
276693
+ recordLockDecision(outcome === "unresolvable" ? "denied_unresolvable" : "denied_by_lock", site);
275286
276694
  throw new AccessDeniedError(`Access denied for source "${label}".`);
275287
276695
  }
275288
- recordLockDecision("denied_unresolvable");
275289
- recordRowLevelGateDecision("denied_by_gate");
276696
+ recordLockDecision("denied_unresolvable", site);
276697
+ recordRowLevelGateDecision("denied_by_gate", site);
275290
276698
  if (resolution.cause)
275291
276699
  recordRowLevelGateRejected(resolution.cause);
275292
276700
  throw new AccessDeniedError(`Access denied for source "${label}".`);
@@ -275359,7 +276767,41 @@ function hydrateMarkdownOnlyCells(notebookCells) {
275359
276767
  return { type: "code", text: sc.text };
275360
276768
  });
275361
276769
  }
275362
- var MALLOY_VERSION, REQUEST_CHAIN_MAX_NAMES = 64, NO_PREAGGREGATE_VIOLATIONS, Model;
276770
+ function scrubHiddenIds(def, published) {
276771
+ const hidden = (id) => typeof id === "string" && !published.has(id.split("@")[0]);
276772
+ const walk2 = (value) => {
276773
+ if (Array.isArray(value))
276774
+ return value.map(walk2);
276775
+ if (!value || typeof value !== "object")
276776
+ return value;
276777
+ const record = value;
276778
+ if (typeof record.join === "string" && (hidden(record.sourceID) || hidden(record.referenceID))) {
276779
+ return joinOutline(record);
276780
+ }
276781
+ const out = {};
276782
+ for (const [key, inner] of Object.entries(record)) {
276783
+ if (SOURCE_IDENTITY_KEYS.has(key) && hidden(inner))
276784
+ continue;
276785
+ out[key] = walk2(inner);
276786
+ }
276787
+ return out;
276788
+ };
276789
+ return walk2(def);
276790
+ }
276791
+ function joinOutline(field) {
276792
+ const name = field.as ?? field.name;
276793
+ if (typeof field.join !== "string") {
276794
+ return { type: field.type, name };
276795
+ }
276796
+ const fields = Array.isArray(field.fields) ? field.fields : [];
276797
+ return {
276798
+ type: field.type,
276799
+ join: field.join,
276800
+ name,
276801
+ fields: fields.map((inner) => inner && typeof inner === "object" ? joinOutline(inner) : inner)
276802
+ };
276803
+ }
276804
+ var MALLOY_VERSION, REQUEST_CHAIN_MAX_NAMES = 64, NO_PREAGGREGATE_VIOLATIONS, Model, SOURCE_IDENTITY_KEYS;
275363
276805
  var init_model = __esm(() => {
275364
276806
  init_telemetry();
275365
276807
  init_materialization_metrics();
@@ -275387,9 +276829,12 @@ var init_model = __esm(() => {
275387
276829
  init_motly();
275388
276830
  init_model_limits();
275389
276831
  init_json_utils();
276832
+ init_query_text();
276833
+ init_caller_joins();
275390
276834
  init_query_metadata();
275391
276835
  init_preaggregation_validation();
275392
276836
  init_gate_registry_walk();
276837
+ init_filter_binding_guard();
275393
276838
  init_gate_classification();
275394
276839
  init_source_extraction();
275395
276840
  init_authorize_metrics();
@@ -275423,7 +276868,9 @@ var init_model = __esm(() => {
275423
276868
  preaggregateViolationsMemo;
275424
276869
  authorizeReferencedGivenNames = new Set;
275425
276870
  discoveryCurationEnabled = false;
276871
+ siblingModelDefResolver;
275426
276872
  queryBoundary = { mode: "all", exploresDeclared: false, isQueryEntryPoint: true };
276873
+ fileDerivationBases;
275427
276874
  freshnessResolver;
275428
276875
  preaggregateEntityIdResolver;
275429
276876
  entryPointGatesBySource = new Map;
@@ -275431,7 +276878,7 @@ var init_model = __esm(() => {
275431
276878
  gateClassificationDepsCache;
275432
276879
  graftedMaterializerCache = new Map;
275433
276880
  static GRAFTED_MATERIALIZER_CACHE_MAX = 32;
275434
- rowLevelFilteredRunnables = new WeakSet;
276881
+ rowLevelFilteredRunnables = new WeakMap;
275435
276882
  meter = publisherMeter();
275436
276883
  queryExecutionHistogram = this.meter.createHistogram("malloy_model_query_duration", {
275437
276884
  description: "How long it takes to execute a Malloy model query",
@@ -275580,7 +277027,7 @@ var init_model = __esm(() => {
275580
277027
  if (!modelDef)
275581
277028
  return byName;
275582
277029
  for (const entry of Object.values(modelDef.contents)) {
275583
- if (!isSourceDef7(entry))
277030
+ if (!isSourceDef9(entry))
275584
277031
  continue;
275585
277032
  const name = entry.as ?? entry.name;
275586
277033
  byName.set(name, this.collectEntryPointGates(entry, modelDef, new Set, true));
@@ -275619,14 +277066,14 @@ var init_model = __esm(() => {
275619
277066
  reachableStructsForNoteSweep(modelDef) {
275620
277067
  const structs = [];
275621
277068
  for (const obj of Object.values(modelDef.contents)) {
275622
- if (isSourceDef7(obj))
277069
+ if (isSourceDef9(obj))
275623
277070
  structs.push(obj);
275624
277071
  }
275625
277072
  for (const value of Object.values(modelDef.sourceRegistry ?? {})) {
275626
277073
  const entry = value.entry;
275627
277074
  if (entry.type === "source_registry_reference")
275628
277075
  continue;
275629
- if (isSourceDef7(entry))
277076
+ if (isSourceDef9(entry))
275630
277077
  structs.push(entry);
275631
277078
  }
275632
277079
  structs.push(...derivedStructsReachable(structs, modelDef));
@@ -275660,7 +277107,11 @@ var init_model = __esm(() => {
275660
277107
  async assertAuthorizedForAllSources(runnable, givens, bypassAuthorize = false, options) {
275661
277108
  await this.authorizeAndBindRunnable(runnable, givens, {
275662
277109
  bypassAuthorize,
275663
- checkOnly: options?.checkOnly
277110
+ checkOnly: options?.checkOnly,
277111
+ callerJoins: options?.callerRegion ? {
277112
+ runnable,
277113
+ region: options.callerRegion
277114
+ } : undefined
275664
277115
  });
275665
277116
  }
275666
277117
  async collectAuthorizeEntryPointGates(runnable, givens, graftScope, skipOwnSourceGate = false) {
@@ -275716,7 +277167,7 @@ var init_model = __esm(() => {
275716
277167
  }
275717
277168
  return rowLevel;
275718
277169
  }
275719
- async queryEntryPointHasRowLevelGate(runnable) {
277170
+ async queryEntryPointHasRowLevelGate(runnable, callerRegion) {
275720
277171
  try {
275721
277172
  const { struct, modelDef, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(runnable);
275722
277173
  if (!modelDef)
@@ -275726,7 +277177,15 @@ var init_model = __esm(() => {
275726
277177
  if (compositeResolvedSourceDef) {
275727
277178
  gates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true, undefined, struct ? ownLevelNotes(struct.annotations) : []));
275728
277179
  }
275729
- return gates.length > 0;
277180
+ if (gates.length > 0)
277181
+ return true;
277182
+ if (!callerRegion)
277183
+ return false;
277184
+ const callerJoins = await this.resolvedCallerJoins(runnable, callerRegion);
277185
+ if (!callerJoins)
277186
+ return true;
277187
+ const gated = (name) => (this.entryPointGatesBySource.get(name)?.length ?? 0) > 0;
277188
+ return callerJoins.some(({ resolution, compiledModelDef }) => resolution.kind === "unproven" || Array.from(resolution.names).some(gated) || resolution.callerSources.some((source) => !source.chain.proven || source.chain.terminals.some(gated) || this.collectEntryPointGates(source.struct, compiledModelDef, new Set, true).length > 0));
275730
277189
  } catch {
275731
277190
  return true;
275732
277191
  }
@@ -275739,7 +277198,7 @@ var init_model = __esm(() => {
275739
277198
  return;
275740
277199
  if (this.entryPointGatesBySource.has(entryPoint))
275741
277200
  return;
275742
- const proof = this.requestChainProvesUngated(entryPoint, buildDerivationBaseMap(stripMalloyCommentsAndLiterals(query)));
277201
+ const proof = this.requestChainProvesUngated(entryPoint, buildDerivationBaseMap(query));
275743
277202
  if (proof.proven) {
275744
277203
  for (const base of proof.gatedBases) {
275745
277204
  for (const entry of this.entryPointGatesBySource.get(base) ?? []) {
@@ -275757,7 +277216,7 @@ var init_model = __esm(() => {
275757
277216
  async assertLocksOnRequestDeclaredBases(entryPoint, query, givens, bypassAuthorize) {
275758
277217
  if (!this.declaresAnyGate())
275759
277218
  return;
275760
- const basesOf = buildDerivationBaseMap(stripMalloyCommentsAndLiterals(query));
277219
+ const basesOf = buildDerivationBaseMap(query);
275761
277220
  const seen = new Set;
275762
277221
  const worklist = entryPoint ? [entryPoint] : [...basesOf.keys()];
275763
277222
  for (let i = 0;i < worklist.length; i++) {
@@ -275765,8 +277224,6 @@ var init_model = __esm(() => {
275765
277224
  if (seen.has(name))
275766
277225
  continue;
275767
277226
  seen.add(name);
275768
- if (seen.size > REQUEST_CHAIN_MAX_NAMES)
275769
- return;
275770
277227
  if (this.entryPointGatesBySource.has(name)) {
275771
277228
  try {
275772
277229
  await this.assertAuthorized(name, givens, bypassAuthorize);
@@ -275793,65 +277250,297 @@ var init_model = __esm(() => {
275793
277250
  return this.declaresAnyGateMemo;
275794
277251
  }
275795
277252
  requestChainProvesUngated(entryPoint, basesOf) {
275796
- const seen = new Set;
275797
- const gatedBases = [];
275798
- const worklist = [entryPoint];
275799
- for (let i = 0;i < worklist.length; i++) {
275800
- const name = worklist[i];
275801
- if (seen.has(name))
275802
- continue;
275803
- seen.add(name);
275804
- if (seen.size > REQUEST_CHAIN_MAX_NAMES) {
275805
- return { proven: false, reason: "chain_not_established", at: name };
277253
+ const chain = derivationTerminals(entryPoint, basesOf, (name) => this.entryPointGatesBySource.has(name), REQUEST_CHAIN_MAX_NAMES);
277254
+ if (!chain.proven) {
277255
+ return {
277256
+ proven: false,
277257
+ reason: "chain_not_established",
277258
+ at: chain.at
277259
+ };
277260
+ }
277261
+ return {
277262
+ proven: true,
277263
+ gatedBases: chain.terminals.filter((name) => (this.entryPointGatesBySource.get(name)?.length ?? 0) > 0)
277264
+ };
277265
+ }
277266
+ resolvedCallerJoins(runnable, region) {
277267
+ const cached2 = this.callerJoinResolutions.get(runnable);
277268
+ if (cached2 && cached2.region === region)
277269
+ return cached2.result;
277270
+ const result = this.computeCallerJoins(runnable, region);
277271
+ this.callerJoinResolutions.set(runnable, { region, result });
277272
+ return result;
277273
+ }
277274
+ callerJoinResolutions = new WeakMap;
277275
+ async computeCallerJoins(runnable, region) {
277276
+ let prepared;
277277
+ try {
277278
+ prepared = await runnable.getPreparedQuery();
277279
+ } catch {
277280
+ return;
277281
+ }
277282
+ const compiledModelDef = prepared._modelDef;
277283
+ if (!compiledModelDef) {
277284
+ throw new CallerJoinWalkError("the compiled query carries no model");
277285
+ }
277286
+ const authorUrls = this.authorUrls();
277287
+ const context = {
277288
+ isModelSource: (name) => this.entryPointGatesBySource.has(name),
277289
+ isModelQuery: (name) => this.modelDef?.contents[name]?.type === "query",
277290
+ authorUrls
277291
+ };
277292
+ return collectCallerJoins(prepared, region, authorUrls).map((callerJoin) => ({
277293
+ callerJoin,
277294
+ compiledModelDef,
277295
+ resolution: resolveCallerJoin(callerJoin, compiledModelDef, region, context)
277296
+ }));
277297
+ }
277298
+ authorUrlsMemo;
277299
+ authorUrls() {
277300
+ if (this.authorUrlsMemo)
277301
+ return this.authorUrlsMemo;
277302
+ const urls = new Set;
277303
+ const modelDef = this.modelDef;
277304
+ if (modelDef) {
277305
+ urls.add(modelDef.modelID);
277306
+ for (const url2 of Object.keys(modelDef.modelAnnotations ?? {})) {
277307
+ urls.add(url2);
277308
+ }
277309
+ for (const imported of modelDef.imports ?? []) {
277310
+ urls.add(imported.importURL);
275806
277311
  }
275807
- const modelGates = this.entryPointGatesBySource.get(name);
275808
- if (modelGates !== undefined) {
275809
- if (modelGates.length > 0)
275810
- gatedBases.push(name);
277312
+ const walk2 = (tree) => {
277313
+ for (const [url2, deps] of Object.entries(tree ?? {})) {
277314
+ urls.add(url2);
277315
+ walk2(deps);
277316
+ }
277317
+ };
277318
+ walk2(modelDef.dependencies);
277319
+ for (const entry of Object.values(modelDef.contents)) {
277320
+ const url2 = entry?.location?.url;
277321
+ if (url2)
277322
+ urls.add(url2);
277323
+ }
277324
+ }
277325
+ this.authorUrlsMemo = urls;
277326
+ return urls;
277327
+ }
277328
+ async decideCallerSource(source, compiledModelDef, alias, givens, graftScope) {
277329
+ const gates = this.collectEntryPointGates(source.struct, compiledModelDef, new Set, true);
277330
+ if (gates.length > 0) {
277331
+ return Promise.all(gates.map((entry) => this.resolveGateShape(entry, compiledModelDef, graftScope)));
277332
+ }
277333
+ if (!source.chain.proven) {
277334
+ this.denyCallerJoin(alias, "chain_not_established", source.chain.at);
277335
+ }
277336
+ for (const base of source.chain.terminals) {
277337
+ for (const entry of this.entryPointGatesBySource.get(base) ?? []) {
277338
+ const shape = this.modelDef ? await this.resolveGateShape(entry, this.modelDef, graftScope) : { shape: "rejected", cause: undefined };
277339
+ if (shape.shape === "row_level") {
277340
+ this.denyCallerJoin(alias, "row_filter_not_graftable", base);
277341
+ }
277342
+ denyUnlessAdmitted(shape, givens, alias, "caller_join");
277343
+ }
277344
+ }
277345
+ return [];
277346
+ }
277347
+ async probeCallerJoinGates(runnable, region, givens, graftScope) {
277348
+ if (!this.declaresAnyGate())
277349
+ return [];
277350
+ let resolved;
277351
+ try {
277352
+ resolved = await this.resolvedCallerJoins(runnable, region);
277353
+ } catch (err) {
277354
+ this.denyCallerJoin(await this.resolveAuthorizeSourceFromRunnable(runnable) ?? "query", "walk_incomplete", err instanceof Error ? err.message : String(err));
277355
+ }
277356
+ if (!resolved)
277357
+ return [];
277358
+ const grafts = [];
277359
+ for (const { callerJoin, compiledModelDef, resolution } of resolved) {
277360
+ const alias = callerJoin.alias;
277361
+ if (resolution.kind === "unproven") {
277362
+ this.denyCallerJoin(alias, "chain_not_established", resolution.at);
277363
+ }
277364
+ const shapes = [];
277365
+ for (const name of resolution.names) {
277366
+ for (const entry of this.entryPointGatesBySource.get(name) ?? []) {
277367
+ shapes.push({
277368
+ at: name,
277369
+ shape: this.modelDef ? await this.resolveGateShape(entry, this.modelDef, graftScope) : { shape: "rejected", cause: undefined }
277370
+ });
277371
+ }
277372
+ }
277373
+ for (const source of resolution.callerSources) {
277374
+ for (const shape of await this.decideCallerSource(source, compiledModelDef, alias, givens, graftScope)) {
277375
+ shapes.push({ at: source.key, shape });
277376
+ }
277377
+ }
277378
+ for (const { at, shape } of shapes) {
277379
+ if (shape.shape === "row_level") {
277380
+ if (resolution.composite) {
277381
+ this.denyCallerJoin(alias, "row_filter_not_graftable", at);
277382
+ }
277383
+ grafts.push({
277384
+ label: alias,
277385
+ graftTarget: shape.graftTarget,
277386
+ filterText: shape.filterText,
277387
+ condition: shape.condition,
277388
+ givenNames: shape.givenNames,
277389
+ callerJoinPath: callerJoin.path
277390
+ });
277391
+ continue;
277392
+ }
277393
+ denyUnlessAdmitted(shape, givens, alias, "caller_join");
277394
+ }
277395
+ }
277396
+ return grafts;
277397
+ }
277398
+ denyCallerJoin(alias, reason, at) {
277399
+ recordRowLevelGateDecision("denied_by_gate", "caller_join");
277400
+ logger.debug("Caller join is not provably allowed; denying", {
277401
+ modelPath: this.modelPath,
277402
+ alias,
277403
+ reason,
277404
+ at
277405
+ });
277406
+ throw new AccessDeniedError(`Access denied for source "${alias}".`);
277407
+ }
277408
+ async assertCallerJoinsQueryable(runnable, region) {
277409
+ const { mode, exploresDeclared } = this.queryBoundary;
277410
+ if (mode === "all" || !exploresDeclared)
277411
+ return;
277412
+ const refusal = () => this.notQueryable("Query target is not queryable.", true, "source");
277413
+ let resolved;
277414
+ try {
277415
+ resolved = await this.resolvedCallerJoins(runnable, region);
277416
+ } catch {
277417
+ throw refusal();
277418
+ }
277419
+ if (!resolved)
277420
+ throw refusal();
277421
+ for (const { resolution } of resolved) {
277422
+ if (resolution.kind === "unproven")
277423
+ throw refusal();
277424
+ if (resolution.boundaryUnprovenAt !== undefined)
277425
+ throw refusal();
277426
+ if (resolution.curatedQuery && this.isCuratedQuery(resolution.curatedQuery)) {
275811
277427
  continue;
275812
277428
  }
275813
- const bases = basesOf.get(name);
275814
- if (!bases || bases.size === 0) {
275815
- return { proven: false, reason: "chain_not_established", at: name };
277429
+ for (const name of resolution.boundaryNames) {
277430
+ if (!this.isCuratedSource(name))
277431
+ throw refusal();
277432
+ }
277433
+ }
277434
+ }
277435
+ async assertCallerJoinBasesEarly(query, givens, phase, decided = new Set) {
277436
+ const joins = buildJoinBaseMap(query);
277437
+ if (joins.size === 0)
277438
+ return;
277439
+ if (phase === "boundary") {
277440
+ const { mode, exploresDeclared } = this.queryBoundary;
277441
+ if (mode === "all" || !exploresDeclared)
277442
+ return;
277443
+ } else if (!this.declaresAnyGate() || !this.modelDef) {
277444
+ return;
277445
+ }
277446
+ const derivations = buildDerivationBaseMap(query);
277447
+ for (const [alias, bases] of joins) {
277448
+ const seen = new Set;
277449
+ const worklist = [...bases];
277450
+ for (let i = 0;i < worklist.length; i++) {
277451
+ const name = worklist[i];
277452
+ if (seen.has(name))
277453
+ continue;
277454
+ seen.add(name);
277455
+ if (seen.size > REQUEST_CHAIN_MAX_NAMES)
277456
+ break;
277457
+ if (!this.entryPointGatesBySource.has(name)) {
277458
+ for (const base of derivations.get(name) ?? []) {
277459
+ worklist.push(base);
277460
+ }
277461
+ const querySource = this.queries?.find((q) => q.name === name)?.sourceName;
277462
+ if (querySource && (phase !== "boundary" || !this.isCuratedQuery(name))) {
277463
+ worklist.push(querySource);
277464
+ }
277465
+ continue;
277466
+ }
277467
+ if (phase === "boundary") {
277468
+ if (!this.isCuratedSource(name)) {
277469
+ throw this.notQueryable("Query target is not queryable.", true, "source");
277470
+ }
277471
+ continue;
277472
+ }
277473
+ if (decided.has(name))
277474
+ continue;
277475
+ try {
277476
+ for (const entry of this.entryPointGatesBySource.get(name) ?? []) {
277477
+ const resolution = await this.resolveGateShape(entry, this.modelDef, this.defaultGraftScope());
277478
+ if (resolution.shape === "row_level")
277479
+ continue;
277480
+ denyUnlessAdmitted(resolution, givens, alias, "caller_join");
277481
+ }
277482
+ } catch (error) {
277483
+ if (error instanceof AccessDeniedError) {
277484
+ this.assertQueryBoundaryEarly(name, undefined, undefined);
277485
+ }
277486
+ throw error;
277487
+ }
277488
+ decided.add(name);
275816
277489
  }
275817
- for (const base of bases)
275818
- worklist.push(base);
275819
277490
  }
275820
- return { proven: true, gatedBases };
275821
277491
  }
275822
277492
  async authorizeAndBindRunnable(runnable, givens, options) {
275823
277493
  if (options?.bypassAuthorize) {
275824
277494
  this.noteAuthorizeBypass("runnable", await this.resolveAuthorizeSourceFromRunnable(runnable));
277495
+ await this.assertNoMisboundInheritedFilters(runnable);
275825
277496
  return runnable;
275826
277497
  }
275827
277498
  const graftScope = options?.graftScope ?? this.defaultGraftScope();
275828
- const rowLevel = await this.probeEntryPointGates(runnable, givens, graftScope, options?.skipOwnSourceGate ?? false);
275829
- if (rowLevel.length === 0) {
277499
+ const entryPointRowLevel = await this.probeEntryPointGates(runnable, givens, graftScope, options?.skipOwnSourceGate ?? false);
277500
+ if (entryPointRowLevel.length === 0) {
275830
277501
  if (options?.callerQueryText && !options?.skipOwnSourceGate) {
275831
277502
  await this.assertRequestDeclaredEntryPointIsNotLaundered(runnable, options.callerQueryText, givens, graftScope);
275832
277503
  }
277504
+ }
277505
+ const callerJoinRowLevel = options?.callerJoins ? await this.probeCallerJoinGates(options.callerJoins.runnable, options.callerJoins.region, givens, graftScope) : [];
277506
+ if (callerJoinRowLevel.length > 0 && options?.callerJoins?.runnable !== runnable) {
277507
+ this.denyCallerJoin(callerJoinRowLevel[0].label, "routed_past_caller_join_gate", callerJoinRowLevel[0].graftTarget);
277508
+ }
277509
+ const rowLevel = [...entryPointRowLevel, ...callerJoinRowLevel];
277510
+ if (rowLevel.length === 0) {
277511
+ await this.assertNoMisboundInheritedFilters(runnable);
275833
277512
  return runnable;
275834
277513
  }
277514
+ const siteOf = (graft) => graft.callerJoinPath ? "caller_join" : "entry_point";
275835
277515
  if (!options?.recompile) {
275836
277516
  const decidable = (g) => g.givenNames.length === 0 || g.givenNames.every((name) => Object.hasOwn(givens, name));
275837
277517
  if (!options?.checkOnly || !rowLevel.every(decidable)) {
275838
- recordRowLevelGateDecision("denied_by_gate");
277518
+ recordRowLevelGateDecision("denied_by_gate", siteOf(rowLevel[0]));
275839
277519
  throw new AccessDeniedError(`Access denied for source "${rowLevel[0].label}".`);
275840
277520
  }
275841
277521
  return runnable;
275842
277522
  }
275843
277523
  try {
275844
- const graftedMaterializer = this.getOrBuildGraftedMaterializer(rowLevel, graftScope);
275845
- const recompiled = options.recompile(graftedMaterializer, rowLevel);
277524
+ const grafts = Array.from(new Map(rowLevel.map((g) => [
277525
+ `${g.graftTarget}\x00${g.filterText}`,
277526
+ g
277527
+ ])).values());
277528
+ const graftedMaterializer = this.getOrBuildGraftedMaterializer(grafts, graftScope);
277529
+ const recompiled = options.recompile(graftedMaterializer, grafts);
275846
277530
  if (options.proveGraft) {
277531
+ if (callerJoinRowLevel.length > 0) {
277532
+ throw new Error("a caller-join graft has no prover here");
277533
+ }
275847
277534
  await options.proveGraft(recompiled);
275848
277535
  } else {
275849
277536
  await this.assertGateLanded(recompiled, rowLevel);
275850
277537
  }
275851
- this.rowLevelFilteredRunnables.add(recompiled);
277538
+ await this.assertGraftedGatesBind(recompiled, rowLevel, graftScope);
277539
+ await this.assertNoMisboundInheritedFilters(recompiled, new Set(rowLevel.map((r) => r.condition)), siteOf(rowLevel[0]));
277540
+ this.rowLevelFilteredRunnables.set(recompiled, rowLevel);
275852
277541
  return recompiled;
275853
277542
  } catch (err) {
275854
- recordRowLevelGateDecision("denied_by_gate");
277543
+ recordRowLevelGateDecision("denied_by_gate", siteOf(rowLevel[0]));
275855
277544
  logger.debug("Row-level authorize attach failed; denying", {
275856
277545
  modelPath: this.modelPath,
275857
277546
  error: err instanceof Error ? err.message : String(err)
@@ -275867,20 +277556,26 @@ var init_model = __esm(() => {
275867
277556
  return {
275868
277557
  struct: undefined,
275869
277558
  modelDef: undefined,
275870
- compositeResolvedSourceDef: undefined
277559
+ compositeResolvedSourceDef: undefined,
277560
+ compiledUrl: undefined,
277561
+ queryLocation: undefined
275871
277562
  };
275872
277563
  const structRef = prepared._query?.structRef;
275873
277564
  const struct = typeof structRef === "string" ? modelDef.contents[structRef] : structRef;
275874
277565
  return {
275875
277566
  struct: struct && typeof struct === "object" ? struct : undefined,
275876
277567
  modelDef,
275877
- compositeResolvedSourceDef: prepared._query?.compositeResolvedSourceDef
277568
+ compositeResolvedSourceDef: prepared._query?.compositeResolvedSourceDef,
277569
+ compiledUrl: typeof prepared._query?.location?.url === "string" && prepared._query.location.url.startsWith("internal://") ? prepared._query.location.url : undefined,
277570
+ queryLocation: prepared._query?.location
275878
277571
  };
275879
277572
  } catch {
275880
277573
  return {
275881
277574
  struct: undefined,
275882
277575
  modelDef: undefined,
275883
- compositeResolvedSourceDef: undefined
277576
+ compositeResolvedSourceDef: undefined,
277577
+ compiledUrl: undefined,
277578
+ queryLocation: undefined
275884
277579
  };
275885
277580
  }
275886
277581
  }
@@ -275928,7 +277623,7 @@ var init_model = __esm(() => {
275928
277623
  const copy = structuredClone(modelDef);
275929
277624
  for (const { graftTarget, condition } of grafts) {
275930
277625
  const target = copy.contents[graftTarget];
275931
- if (!target || !isSourceDef7(target)) {
277626
+ if (!target || !isSourceDef9(target)) {
275932
277627
  throw new Error(`graft target "${graftTarget}" is not a source in this model`);
275933
277628
  }
275934
277629
  target.filterList = [...target.filterList ?? [], condition];
@@ -275957,14 +277652,64 @@ var init_model = __esm(() => {
275957
277652
  struct.filterList = [...struct.filterList ?? [], condition];
275958
277653
  }
275959
277654
  }
277655
+ async assertGraftedGatesBind(recompiled, rowLevel, graftScope) {
277656
+ let executedStruct;
277657
+ let preparedForCallerJoins;
277658
+ for (const { graftTarget, condition, callerJoinPath } of rowLevel) {
277659
+ const entryPointStruct = graftScope.modelDef.contents[graftTarget];
277660
+ let site;
277661
+ if (callerJoinPath) {
277662
+ preparedForCallerJoins ??= await recompiled.getPreparedQuery();
277663
+ const located = locateCallerJoin(preparedForCallerJoins, callerJoinPath);
277664
+ site = located && condition.code ? this.resolveFilterListLandingStruct(located, graftScope.modelDef, condition.code, 0) : undefined;
277665
+ if (!site) {
277666
+ throw new Error("a caller join's row-security gate could not be re-located on the recompiled query");
277667
+ }
277668
+ } else {
277669
+ if (executedStruct === undefined) {
277670
+ const { struct, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(recompiled);
277671
+ executedStruct = compositeResolvedSourceDef ?? struct;
277672
+ if (!executedStruct) {
277673
+ throw new Error("a row-security gate's executed source could not be resolved");
277674
+ }
277675
+ }
277676
+ site = executedStruct;
277677
+ }
277678
+ assertGraftedGateBindsToDeclaringSource(isSourceDef9(entryPointStruct) ? entryPointStruct : undefined, site, condition, graftScope.modelDef, this.siblingModelDefResolver);
277679
+ }
277680
+ }
277681
+ async assertNoMisboundInheritedFilters(runnable, alreadyProven = new Set, site = "entry_point") {
277682
+ const {
277683
+ struct,
277684
+ modelDef,
277685
+ compositeResolvedSourceDef,
277686
+ compiledUrl,
277687
+ queryLocation
277688
+ } = await this.resolveRunTargetStruct(runnable);
277689
+ const target = compositeResolvedSourceDef ?? struct;
277690
+ if (!target)
277691
+ return;
277692
+ try {
277693
+ assertInheritedSourceFiltersBind(target, modelDef, { compiledUrl, queryLocation }, this.siblingModelDefResolver, undefined, undefined, alreadyProven);
277694
+ } catch (err) {
277695
+ recordRowLevelGateDecision("denied_by_gate", site);
277696
+ logger.debug("Inherited source filter binding check failed; denying", {
277697
+ modelPath: this.modelPath,
277698
+ error: err instanceof Error ? err.message : String(err)
277699
+ });
277700
+ const label = target.as || target.name;
277701
+ throw new AccessDeniedError(`Access denied for source "${label}".`);
277702
+ }
277703
+ }
275960
277704
  async assertGateLanded(recompiled, grafts) {
275961
277705
  const prepared = await recompiled.getPreparedQuery();
275962
277706
  const modelDef = prepared._modelDef ?? this.modelDef;
275963
277707
  const structRef = prepared._query?.compositeResolvedSourceDef ?? prepared._query?.structRef;
275964
277708
  const resolvedRef = typeof structRef === "string" ? modelDef?.contents[structRef] : structRef;
275965
277709
  const struct = resolvedRef && typeof resolvedRef === "object" ? resolvedRef : undefined;
275966
- for (const { condition } of grafts) {
275967
- if (!condition.code || !this.filterListContainsCode(struct, modelDef, condition.code, 0)) {
277710
+ for (const { condition, callerJoinPath } of grafts) {
277711
+ const site = callerJoinPath ? locateCallerJoin(prepared, callerJoinPath) : struct;
277712
+ if (!condition.code || !this.filterListContainsCode(site, modelDef, condition.code, 0)) {
275968
277713
  throw new Error("a row-level gate condition did not land on the recompiled query");
275969
277714
  }
275970
277715
  }
@@ -275989,7 +277734,7 @@ var init_model = __esm(() => {
275989
277734
  const struct = snapshot;
275990
277735
  for (const { graftTarget, condition } of grafts) {
275991
277736
  const target = scope.modelDef.contents[graftTarget];
275992
- if (!target || !isSourceDef7(target) || !target.sourceID || struct.sourceID !== target.sourceID) {
277737
+ if (!target || !isSourceDef9(target) || !target.sourceID || struct.sourceID !== target.sourceID) {
275993
277738
  throw new Error(`own-scope run target does not match graft target "${graftTarget}"`);
275994
277739
  }
275995
277740
  if (!struct.filterList?.some((f) => f.code === condition.code)) {
@@ -276019,30 +277764,59 @@ var init_model = __esm(() => {
276019
277764
  }
276020
277765
  static MAX_GATE_PROOF_DEPTH = 8;
276021
277766
  filterListContainsCode(struct, modelDef, code, depth) {
277767
+ return this.resolveFilterListLandingStruct(struct, modelDef, code, depth) !== undefined;
277768
+ }
277769
+ resolveFilterListLandingStruct(struct, modelDef, code, depth) {
276022
277770
  if (!struct || depth > Model.MAX_GATE_PROOF_DEPTH)
276023
- return false;
277771
+ return;
276024
277772
  if (struct.filterList?.some((f) => f.code === code))
276025
- return true;
277773
+ return struct;
276026
277774
  const duck = struct;
276027
277775
  if (duck.type === "query_source" && modelDef) {
276028
277776
  const ref = duck.query?.compositeResolvedSourceDef ?? duck.query?.structRef;
276029
277777
  const base = typeof ref === "string" ? modelDef.contents[ref] : ref;
276030
- if (base && isSourceDef7(base)) {
276031
- return this.filterListContainsCode(base, modelDef, code, depth + 1);
277778
+ if (base && isSourceDef9(base)) {
277779
+ return this.resolveFilterListLandingStruct(base, modelDef, code, depth + 1);
276032
277780
  }
276033
277781
  }
276034
- return false;
277782
+ return;
276035
277783
  }
276036
- async assertAuthorizedForText(text, givens) {
277784
+ async assertAuthorizedForText(text, givens, options) {
276037
277785
  const target = extractRunTargetSourceName(text);
276038
277786
  await this.assertAuthorized(target, givens);
276039
- if (!hasCallerAuthorizeAnnotation(text)) {
277787
+ if (hasCallerAuthorizeAnnotation(text))
277788
+ return;
277789
+ if (options?.wholeFile) {
276040
277790
  await this.assertLocksOnRequestDeclaredBases(target ?? "", text, givens);
277791
+ return;
276041
277792
  }
277793
+ const decided = new Set(target ? [target] : []);
277794
+ await this.assertCallerJoinBasesEarly(text, givens, "locks", decided);
277795
+ await this.assertLocksOnAppearingNames(text, givens, false, decided);
276042
277796
  }
276043
- async assertAuthorizedForRunnable(runnable, givens) {
277797
+ async assertLocksOnAppearingNames(text, givens, bypassAuthorize, decided) {
277798
+ if (!this.declaresAnyGate())
277799
+ return;
277800
+ for (const name of collectIdentifierNames(text)) {
277801
+ const asQuery = this.queries?.find((q) => q.name === name)?.sourceName;
277802
+ const source = (this.entryPointGatesBySource.get(name)?.length ?? 0) > 0 ? name : asQuery && (this.entryPointGatesBySource.get(asQuery)?.length ?? 0) > 0 ? asQuery : undefined;
277803
+ if (!source || decided.has(source))
277804
+ continue;
277805
+ decided.add(source);
277806
+ try {
277807
+ await this.assertAuthorized(source, givens, bypassAuthorize);
277808
+ } catch (error) {
277809
+ if (error instanceof AccessDeniedError) {
277810
+ this.assertQueryBoundaryEarly(source, undefined, undefined);
277811
+ }
277812
+ throw error;
277813
+ }
277814
+ }
277815
+ }
277816
+ async assertAuthorizedForRunnable(runnable, givens, callerRegion) {
276044
277817
  await this.assertAuthorizedForAllSources(runnable, givens, false, {
276045
- checkOnly: true
277818
+ checkOnly: true,
277819
+ callerRegion
276046
277820
  });
276047
277821
  }
276048
277822
  async resolveAuthorizeSourceFromRunnable(runnable) {
@@ -276065,17 +277839,54 @@ var init_model = __esm(() => {
276065
277839
  const target = extractRunTargetSourceName(scanned);
276066
277840
  if (!target)
276067
277841
  return;
276068
- const aliasOf = buildSourceAliasMap(scanned);
277842
+ const aliasOf2 = buildSourceAliasMap(scanned);
276069
277843
  let current = target;
276070
277844
  const seen = new Set;
276071
277845
  while (current && !seen.has(current)) {
276072
277846
  if (this.filterMap.has(current))
276073
277847
  return current;
276074
277848
  seen.add(current);
276075
- current = aliasOf.get(current);
277849
+ current = aliasOf2.get(current);
276076
277850
  }
276077
277851
  return;
276078
277852
  }
277853
+ sameDeclarationLocation(a, b) {
277854
+ const la = a.location;
277855
+ const lb = b.location;
277856
+ if (!la || !lb)
277857
+ return false;
277858
+ return la.url === lb.url && JSON.stringify(la.range.start) === JSON.stringify(lb.range.start) && JSON.stringify(la.range.end) === JSON.stringify(lb.range.end);
277859
+ }
277860
+ fieldIsUnaliased(struct, name) {
277861
+ const field = struct.fields?.find((f) => (f.as ?? f.name) === name);
277862
+ return !!field && field.name === name && !field.e;
277863
+ }
277864
+ async assertFilterAnnotationsBindToDeclaringSource(runnable, declaringSourceName, filters) {
277865
+ try {
277866
+ const { struct, modelDef, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(runnable);
277867
+ const executedStruct = compositeResolvedSourceDef ?? struct;
277868
+ if (!executedStruct || !modelDef) {
277869
+ throw new Error("a #(filter) run target could not be resolved");
277870
+ }
277871
+ const candidateStruct = modelDef.contents[declaringSourceName];
277872
+ if (!candidateStruct || !isSourceDef9(candidateStruct)) {
277873
+ throw new Error(`a #(filter) declaring source "${declaringSourceName}" could not be resolved`);
277874
+ }
277875
+ for (const filter2 of filters) {
277876
+ const declaringStruct = findFilterAnnotationDeclaringSource(candidateStruct, modelDef, filter2, parseFilterAnnotation, this.siblingModelDefResolver) ?? (this.sameDeclarationLocation(candidateStruct, executedStruct) && this.fieldIsUnaliased(candidateStruct, filter2.dimension) ? candidateStruct : undefined);
277877
+ if (!declaringStruct) {
277878
+ throw new Error("a #(filter) dimension's declaring source could not be resolved");
277879
+ }
277880
+ assertFilterDimensionBindsToDeclaringSource(declaringStruct, executedStruct, filter2.dimension);
277881
+ }
277882
+ } catch (err) {
277883
+ logger.debug("#(filter) annotation binding check failed; denying", {
277884
+ modelPath: this.modelPath,
277885
+ error: err instanceof Error ? err.message : String(err)
277886
+ });
277887
+ throw new AccessDeniedError(`Access denied for source "${declaringSourceName}".`);
277888
+ }
277889
+ }
276079
277890
  static async create(packageName, packagePath, modelPath, malloyConfig, options) {
276080
277891
  const { runtime, modelURL, importBaseURL, dataStyles, modelType } = await Model.getModelRuntime(packagePath, modelPath, malloyConfig, options);
276081
277892
  try {
@@ -276113,7 +277924,7 @@ var init_model = __esm(() => {
276113
277924
  onRowLevelGateUnexpressible: (sourceName, detail) => logger.warn("Row-level #(access_filter) gate not expressible at this entry point; every query against it will be denied", { packageName, modelPath, sourceName, detail }),
276114
277925
  onOwnRowLevelConditionCompiled: (sourceName, condition, route) => {
276115
277926
  const struct = compiledModelDef.contents[sourceName];
276116
- if (!struct || !isSourceDef7(struct))
277927
+ if (!struct || !isSourceDef9(struct))
276117
277928
  return;
276118
277929
  validateSourceLineGateGivenUsage(sourceName, route, struct, condition.refSummary, condition.e, compiledModelDef, (cause, detail) => {
276119
277930
  recordRowLevelGateRejected(cause);
@@ -276269,6 +278080,42 @@ var init_model = __esm(() => {
276269
278080
  }
276270
278081
  setQueryBoundary(policy) {
276271
278082
  this.queryBoundary = policy;
278083
+ this.fileDerivationBases = policy.derivationText ? buildDerivationBaseMap(stripMalloyCommentsAndLiterals(policy.derivationText)) : undefined;
278084
+ }
278085
+ assertFileOnSurface() {
278086
+ const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
278087
+ if (mode === "all" || !exploresDeclared)
278088
+ return;
278089
+ if (!isQueryEntryPoint && !this.isNotebook()) {
278090
+ throw this.notQueryable(`No queryable model "${this.modelPath}".`, true, "model");
278091
+ }
278092
+ }
278093
+ setSiblingModelDefResolver(resolver) {
278094
+ this.siblingModelDefResolver = resolver;
278095
+ }
278096
+ notQueryable(refusal, explainable, refused) {
278097
+ const surface = this.queryBoundary.offSurface;
278098
+ if (explainable && surface && !this.hasAnyAuthorizeNote()) {
278099
+ return new OffSurfaceError(`${refusal} ${this.offSurfaceReason(surface, refused)}`);
278100
+ }
278101
+ return new NotQueryableError(refusal);
278102
+ }
278103
+ offSurfaceReason(surface, refused) {
278104
+ const where = surface.indexModel ? `It is not on this package's published surface, "${INDEX_MODEL_NAME}": ` + `only what that file exports is queryable, and only through it.` : `It is not on this package's published surface, the models ` + `publisher.json "explores" lists (${JSON.stringify(surface.files)}): ` + `only what those files export is queryable, and only through them.`;
278105
+ let fix;
278106
+ if (refused === "model") {
278107
+ fix = surface.indexModel ? `import "${this.modelPath}" in "${INDEX_MODEL_NAME}", name the ` + `sources you want in that file's export { ... }, and address ` + `the query to "${INDEX_MODEL_NAME}".` : `import "${this.modelPath}" in a listed model, name the sources ` + `you want in that model's export { ... }, and address the ` + `query to that model.`;
278108
+ } else if (!this.isNotebook() && !this.isDashboard()) {
278109
+ fix = `name the ${refused} in the export { ... } of "${this.modelPath}".`;
278110
+ } else {
278111
+ const exporter = surface.indexModel ? `"${INDEX_MODEL_NAME}"` : `a listed model`;
278112
+ const readdress = this.isDashboard() ? "" : surface.indexModel ? `, and address the query to "${INDEX_MODEL_NAME}"` : `, and address the query to that model`;
278113
+ fix = `name the ${refused} in the export { ... } of ${exporter}, ` + `importing the file that declares it if needed${readdress}.`;
278114
+ }
278115
+ return `${where} Fix: ${fix}`;
278116
+ }
278117
+ declaresSource(name) {
278118
+ return this.sources?.some((s) => s.name === name) ?? false;
276272
278119
  }
276273
278120
  definitionIdentity(name) {
276274
278121
  if (!name)
@@ -276293,51 +278140,52 @@ var init_model = __esm(() => {
276293
278140
  return this.admittedByPackage(name, this.queryBoundary.packageCuratedSources);
276294
278141
  }
276295
278142
  isCuratedQuery(name) {
276296
- if (!this.isNotebook() && (this.getQueries() ?? []).some((q) => q.name === name))
278143
+ if (!this.isNotebook() && !this.isDashboard() && (this.getQueries() ?? []).some((q) => q.name === name))
276297
278144
  return true;
276298
278145
  return this.admittedByPackage(name, this.queryBoundary.packageCuratedQueries);
276299
278146
  }
276300
- assertQueryBoundaryEarly(sourceName, queryName, query) {
276301
- const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
278147
+ assertQueryBoundaryEarly(sourceName, queryName, query, derivationContext) {
278148
+ const { mode, exploresDeclared } = this.queryBoundary;
276302
278149
  if (mode === "all" || !exploresDeclared)
276303
278150
  return "cleared";
276304
- if (!isQueryEntryPoint && !this.isNotebook()) {
276305
- throw new NotQueryableError(`No queryable model "${this.modelPath}".`);
276306
- }
278151
+ this.assertFileOnSurface();
276307
278152
  if (queryName && !query) {
276308
278153
  if (!sourceName && this.isCuratedQuery(queryName))
276309
278154
  return "cleared";
276310
278155
  if (sourceName && this.isCuratedSource(sourceName))
276311
278156
  return "cleared";
276312
- throw new NotQueryableError(`No queryable query "${queryName}".`);
278157
+ if (!sourceName && this.isDashboard() && (this.queries ?? []).some((q) => q.name === queryName)) {
278158
+ return "deferred";
278159
+ }
278160
+ if (sourceName) {
278161
+ throw this.notQueryable(`No queryable source "${sourceName}".`, this.declaresSource(sourceName), "source");
278162
+ }
278163
+ throw this.notQueryable(`No queryable query "${queryName}".`, (this.queries ?? []).some((q) => q.name === queryName), "query");
276313
278164
  }
276314
278165
  if (sourceName) {
276315
278166
  if (this.isCuratedSource(sourceName))
276316
278167
  return "cleared";
276317
- throw new NotQueryableError(`No queryable source "${sourceName}".`);
278168
+ throw this.notQueryable(`No queryable source "${sourceName}".`, this.declaresSource(sourceName), "source");
276318
278169
  }
276319
- if (query) {
276320
- const target = extractRunTargetSourceName(query);
276321
- if (target && !this.isCuratedSource(target) && !this.derivesFromCurated(target, query) && this.sources?.some((s) => s.name === target)) {
276322
- throw new NotQueryableError("Query target is not queryable.");
278170
+ for (const target of query ? extractRunTargetSourceNames(query) : []) {
278171
+ if (!this.isCuratedSource(target) && !this.derivesFromCurated(target, query, derivationContext) && this.declaresSource(target)) {
278172
+ throw this.notQueryable("Query target is not queryable.", true, "source");
276323
278173
  }
276324
278174
  }
276325
278175
  return "deferred";
276326
278176
  }
276327
- assertQueryBoundaryCompiled(compiledSource, query) {
276328
- const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
278177
+ assertQueryBoundaryCompiled(compiledSource, query, derivationContext) {
278178
+ const { mode, exploresDeclared } = this.queryBoundary;
276329
278179
  if (mode === "all" || !exploresDeclared)
276330
278180
  return;
276331
- if (!isQueryEntryPoint && !this.isNotebook()) {
276332
- throw new NotQueryableError(`No queryable model "${this.modelPath}".`);
276333
- }
278181
+ this.assertFileOnSurface();
276334
278182
  if (compiledSource) {
276335
278183
  if (this.isCuratedSource(compiledSource))
276336
278184
  return;
276337
- if (query && this.derivesFromCurated(compiledSource, query))
278185
+ if ((query || derivationContext || this.fileDerivationBases) && this.derivesFromCurated(compiledSource, query, derivationContext))
276338
278186
  return;
276339
278187
  }
276340
- throw new NotQueryableError("Query target is not queryable.");
278188
+ throw this.notQueryable("Query target is not queryable.", compiledSource !== undefined, "source");
276341
278189
  }
276342
278190
  async assertCompiledTargetQueryable(runnable, query) {
276343
278191
  const { mode, exploresDeclared } = this.queryBoundary;
@@ -276345,18 +278193,95 @@ var init_model = __esm(() => {
276345
278193
  return;
276346
278194
  this.assertQueryBoundaryCompiled(await this.resolveAuthorizeSourceFromRunnable(runnable), query);
276347
278195
  }
278196
+ async assertNotebookCellOnSurface(cellIndex, runnable) {
278197
+ const { mode, exploresDeclared } = this.queryBoundary;
278198
+ if (mode === "all" || !exploresDeclared)
278199
+ return;
278200
+ const cells = this.runnableNotebookCells ?? [];
278201
+ const cellText = cells[cellIndex]?.text ?? "";
278202
+ const context = cells.slice(0, cellIndex + 1).filter((c) => c.type === "code").map((c) => c.text).join(`
278203
+ `);
278204
+ const early = this.assertQueryBoundaryEarly(undefined, undefined, cellText, context);
278205
+ if (early === "deferred") {
278206
+ this.assertQueryBoundaryCompiled(await this.resolveAuthorizeSourceFromRunnable(runnable), cellText, context);
278207
+ }
278208
+ }
278209
+ async surfaceRefusal(request) {
278210
+ const { mode, exploresDeclared } = this.queryBoundary;
278211
+ if (mode === "all" || !exploresDeclared)
278212
+ return;
278213
+ const text = request.query ?? (request.queryName ? `run: ${quoteMalloyIdentifier2(request.queryName)}` : undefined);
278214
+ if (!text || !this.modelMaterializer)
278215
+ return;
278216
+ let compiledSource;
278217
+ try {
278218
+ const runnable = this.modelMaterializer.loadRestrictedQuery(`
278219
+ ` + text);
278220
+ await runnable.getPreparedQuery();
278221
+ compiledSource = await this.resolveAuthorizeSourceFromRunnable(runnable);
278222
+ } catch (error) {
278223
+ if (error instanceof MalloyError4 || error instanceof ModelCompilationError) {
278224
+ return;
278225
+ }
278226
+ throw error;
278227
+ }
278228
+ try {
278229
+ const early = this.assertQueryBoundaryEarly(undefined, request.query ? undefined : request.queryName, request.query);
278230
+ if (early === "deferred") {
278231
+ this.assertQueryBoundaryCompiled(compiledSource, request.query);
278232
+ }
278233
+ return;
278234
+ } catch (error) {
278235
+ if (error instanceof OffSurfaceError) {
278236
+ return {
278237
+ source: compiledSource && this.offSurfaceBase(compiledSource)
278238
+ };
278239
+ }
278240
+ if (error instanceof NotQueryableError)
278241
+ return {};
278242
+ throw error;
278243
+ }
278244
+ }
278245
+ offSurfaceBase(name) {
278246
+ const seen = new Set;
278247
+ let current = name;
278248
+ while (!seen.has(current)) {
278249
+ seen.add(current);
278250
+ const bases = this.fileDerivationBases?.get(current);
278251
+ const next = bases ? Array.from(bases).find((base) => !this.isCuratedSource(base)) : undefined;
278252
+ if (!next)
278253
+ return current;
278254
+ current = next;
278255
+ }
278256
+ return current;
278257
+ }
276348
278258
  isNotebook() {
276349
278259
  return this.modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
276350
278260
  }
278261
+ isDashboard() {
278262
+ return this.queryBoundary.dashboard === true;
278263
+ }
276351
278264
  ownCuratedSourceNames() {
276352
- if (this.isNotebook())
278265
+ if (this.isNotebook() || this.isDashboard())
276353
278266
  return new Set;
276354
278267
  return new Set((this.getSources() ?? []).map((s) => s.name).filter((n) => n !== undefined));
276355
278268
  }
276356
- derivesFromCurated(name, query) {
278269
+ derivesFromCurated(name, query, derivationContext) {
276357
278270
  const own = this.ownCuratedSourceNames();
276358
278271
  const packageCurated = this.queryBoundary.packageCuratedSources;
276359
- const basesOf = buildDerivationBaseMap(stripMalloyCommentsAndLiterals(query));
278272
+ const basesOf = query ? buildDerivationBaseMap(stripMalloyCommentsAndLiterals(query)) : new Map;
278273
+ const contextBases = derivationContext ? buildDerivationBaseMap(stripMalloyCommentsAndLiterals(derivationContext)) : undefined;
278274
+ for (const [derived, bases] of [
278275
+ ...this.fileDerivationBases ?? [],
278276
+ ...contextBases ?? []
278277
+ ]) {
278278
+ const into = basesOf.get(derived);
278279
+ if (into)
278280
+ for (const base of bases)
278281
+ into.add(base);
278282
+ else
278283
+ basesOf.set(derived, new Set(bases));
278284
+ }
276360
278285
  const proven = new Set;
276361
278286
  const inProgress = new Set;
276362
278287
  const proves = (current) => {
@@ -276780,13 +278705,22 @@ var init_model = __esm(() => {
276780
278705
  const liveBuildManifest = this.withoutPreaggregateEntries(buildManifest);
276781
278706
  const querySurfaceGivens = this.filterGivensToModelSurface(givens);
276782
278707
  const boundary = this.assertQueryBoundaryEarly(sourceName, queryName, query);
278708
+ const callerRegion = !sourceName && !queryName && query ? { kind: "query", text: query } : undefined;
278709
+ const readCallerJoinText = !!callerRegion && !hasCallerAuthorizeAnnotation(callerRegion.text);
278710
+ if (readCallerJoinText) {
278711
+ await this.assertCallerJoinBasesEarly(callerRegion.text, givens ?? {}, "boundary");
278712
+ }
276783
278713
  const surfaceName = extractRunTargetSourceName(query);
276784
278714
  const earlySource = sourceName || (queryName ? this.queries?.find((q) => q.name === queryName)?.sourceName : undefined) || (surfaceName && !this.sources?.some((s) => s.name === surfaceName) ? this.queries?.find((q) => q.name === surfaceName)?.sourceName : undefined) || surfaceName;
276785
278715
  if (earlySource) {
276786
278716
  await this.assertAuthorized(earlySource, givens ?? {}, bypassAuthorize);
276787
- if (query && !hasCallerAuthorizeAnnotation(query) && !this.entryPointGatesBySource.has(earlySource)) {
276788
- await this.assertLocksOnRequestDeclaredBases(earlySource, query, givens ?? {}, bypassAuthorize);
276789
- }
278717
+ }
278718
+ const decided = new Set(earlySource ? [earlySource] : []);
278719
+ if (readCallerJoinText && !bypassAuthorize) {
278720
+ await this.assertCallerJoinBasesEarly(callerRegion.text, givens ?? {}, "locks", decided);
278721
+ }
278722
+ if (query && !hasCallerAuthorizeAnnotation(query)) {
278723
+ await this.assertLocksOnAppearingNames(query, givens ?? {}, bypassAuthorize, decided);
276790
278724
  }
276791
278725
  try {
276792
278726
  for (const [field, callerText] of [
@@ -276821,6 +278755,8 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276821
278755
  throw new BadRequestError("Invalid query request. (Query AND !sourceName) OR (queryName AND sourceName) must be defined.");
276822
278756
  }
276823
278757
  const isAdHocQuery = !sourceName && !queryName && !!query;
278758
+ let injectedFilters;
278759
+ let injectedFilterSourceName;
276824
278760
  if (!bypassFilters) {
276825
278761
  const effectiveSource = isAdHocQuery ? this.resolveFilterSource(query) : sourceName;
276826
278762
  if (effectiveSource) {
@@ -276828,14 +278764,20 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276828
278764
  if (filters.length > 0) {
276829
278765
  const filterClause = buildFilterClause(filters, filterParams ?? {});
276830
278766
  queryString = injectFilterRefinement(queryString, filterClause);
278767
+ injectedFilters = filters.filter((f) => filterHasValue(f, filterParams ?? {}));
278768
+ injectedFilterSourceName = effectiveSource;
276831
278769
  }
276832
278770
  }
276833
278771
  }
276834
278772
  runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
276835
278773
  liveRunnable = runnable;
278774
+ if (injectedFilters && injectedFilters.length > 0 && injectedFilterSourceName) {
278775
+ await runnable.getPreparedQuery();
278776
+ await this.assertFilterAnnotationsBindToDeclaringSource(runnable, injectedFilterSourceName, injectedFilters);
278777
+ }
276836
278778
  const storageRoutingPossible = getPersistStorageMode() === "on" && this.serveBindings.length > 0 && !!this.serveDestinationConfig;
276837
278779
  const preaggServeMaterializer = this.preaggregateServeMaterializer;
276838
- const routingBlockedByRowLevelGate = (storageRoutingPossible || !!preaggServeMaterializer) && !bypassAuthorize && this.hasAnyAuthorizeNote() && await this.queryEntryPointHasRowLevelGate(runnable);
278780
+ const routingBlockedByRowLevelGate = (storageRoutingPossible || !!preaggServeMaterializer) && !bypassAuthorize && this.hasAnyAuthorizeNote() && await this.queryEntryPointHasRowLevelGate(runnable, callerRegion);
276839
278781
  if (routingBlockedByRowLevelGate) {
276840
278782
  recordStorageServeRouting("blocked_by_row_level_gate");
276841
278783
  }
@@ -276884,6 +278826,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276884
278826
  if (error instanceof BadRequestError) {
276885
278827
  throw error;
276886
278828
  }
278829
+ if (error instanceof AccessDeniedError) {
278830
+ throw error;
278831
+ }
276887
278832
  if (error instanceof FilterValidationError) {
276888
278833
  throw new BadRequestError(error.message);
276889
278834
  }
@@ -276905,11 +278850,15 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276905
278850
  const compiledSource = await this.resolveAuthorizeSourceFromRunnable(runnable);
276906
278851
  if (boundary === "deferred") {
276907
278852
  this.assertQueryBoundaryCompiled(compiledSource, query);
278853
+ if (callerRegion && liveRunnable) {
278854
+ await this.assertCallerJoinsQueryable(liveRunnable, callerRegion);
278855
+ }
276908
278856
  }
276909
278857
  runnable = await this.authorizeAndBindRunnable(runnable, givens ?? {}, {
276910
278858
  recompile: (mm) => mm.loadRestrictedQuery(queryString),
276911
278859
  bypassAuthorize,
276912
- callerQueryText: query
278860
+ callerQueryText: query,
278861
+ callerJoins: callerRegion && liveRunnable ? { runnable: liveRunnable, region: callerRegion } : undefined
276913
278862
  });
276914
278863
  const maxRows = getMaxQueryRows();
276915
278864
  const maxBytes = getMaxResponseBytes();
@@ -276960,8 +278909,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276960
278909
  modelPath: this.modelPath,
276961
278910
  error: err instanceof Error ? err.message : String(err)
276962
278911
  });
276963
- recordRowLevelGateDecision("denied_by_gate");
276964
- throw new AccessDeniedError(`Access denied for source "${compiledSource ?? sourceName ?? "unknown"}".`);
278912
+ const unbound = this.rowLevelFilteredRunnables.get(runnable)?.find((graft) => graft.callerJoinPath && graft.givenNames.some((name) => !(name in (givens ?? {}))));
278913
+ recordRowLevelGateDecision("denied_by_gate", unbound ? "caller_join" : "entry_point");
278914
+ throw new AccessDeniedError(`Access denied for source "${unbound?.label ?? compiledSource ?? sourceName ?? "unknown"}".`);
276965
278915
  }
276966
278916
  const givenCode = err?.code;
276967
278917
  if (typeof givenCode === "string" && givenCode.startsWith("runtime-given-")) {
@@ -277020,7 +278970,8 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
277020
278970
  executionTime = performance.now() - startTime;
277021
278971
  }
277022
278972
  if (this.queryHadRowLevelFilterAttached(runnable) && queryResults.totalRows === 0) {
277023
- recordRowLevelGateDecision("empty_after_filter");
278973
+ const first2 = this.rowLevelFilteredRunnables.get(runnable)?.[0];
278974
+ recordRowLevelGateDecision("empty_after_filter", first2?.callerJoinPath ? "caller_join" : "entry_point");
277024
278975
  }
277025
278976
  assertWithinModelRowLimit(queryResults.totalRows, maxRows, "model_query");
277026
278977
  const wrappedResult = API.util.wrapResult(queryResults);
@@ -277118,7 +279069,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
277118
279069
  return;
277119
279070
  try {
277120
279071
  const entry = this.modelDef.contents?.[sourceName];
277121
- if (!entry || !isSourceDef7(entry))
279072
+ if (!entry || !isSourceDef9(entry))
277122
279073
  return;
277123
279074
  const def = entry;
277124
279075
  if (!def.annotations)
@@ -277129,42 +279080,121 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
277129
279080
  }
277130
279081
  }
277131
279082
  getStandardModel() {
279083
+ const published = this.publishedNames();
279084
+ const keep = (items) => published && items ? items.filter((item) => item.name && published.has(item.name)) : items;
277132
279085
  return {
277133
279086
  type: "source",
277134
279087
  packageName: this.packageName,
277135
279088
  modelPath: this.modelPath,
277136
279089
  malloyVersion: MALLOY_VERSION,
277137
279090
  dataStyles: JSON.stringify(this.dataStyles),
277138
- modelDef: JSON.stringify(this.modelDef),
277139
- modelInfo: JSON.stringify(this.modelInfo ?? {}),
277140
- sourceInfos: this.getSourceInfos()?.map((sourceInfo) => JSON.stringify(sourceInfo)),
277141
- sources: this.getSources(),
277142
- queries: this.getQueries(),
279091
+ modelDef: JSON.stringify(this.publishedModelDef(published)),
279092
+ modelInfo: JSON.stringify(this.modelInfo ? { ...this.modelInfo, entries: keep(this.modelInfo.entries) } : {}),
279093
+ sourceInfos: keep(this.getSourceInfos())?.map((sourceInfo) => JSON.stringify(sourceInfo)),
279094
+ sources: keep(this.getSources()),
279095
+ queries: keep(this.getQueries()),
277143
279096
  givens: this.givens
277144
279097
  };
277145
279098
  }
277146
- serializeNewSources(newSources) {
277147
- return newSources?.map((source) => JSON.stringify(this.givens && this.givens.length > 0 ? { ...source, givens: this.givens } : source));
279099
+ publishedNames() {
279100
+ if (!this.discoveryCurationEnabled)
279101
+ return;
279102
+ const exports = this.modelDef?.exports;
279103
+ if (!Array.isArray(exports))
279104
+ return;
279105
+ const names = new Set(exports);
279106
+ const { mode, exploresDeclared } = this.queryBoundary;
279107
+ if (!this.isDashboard() || mode === "all" || !exploresDeclared) {
279108
+ return names;
279109
+ }
279110
+ const readable = (source) => this.isCuratedSource(source) || this.derivesFromCurated(source);
279111
+ const admitted = new Set;
279112
+ for (const name of names) {
279113
+ const query = this.queries?.find((q) => q.name === name);
279114
+ const source = query ? query.sourceName : name;
279115
+ if (source && readable(source))
279116
+ admitted.add(name);
279117
+ }
279118
+ return admitted;
279119
+ }
279120
+ publishedModelDef(published) {
279121
+ if (!published || !this.modelDef)
279122
+ return this.modelDef;
279123
+ const runsPublished = (query) => {
279124
+ const ref = query.structRef;
279125
+ const name = typeof ref === "string" ? ref : ref?.as ?? ref?.name;
279126
+ return name !== undefined && published.has(name);
279127
+ };
279128
+ return {
279129
+ ...this.modelDef,
279130
+ contents: Object.fromEntries(Object.entries(this.modelDef.contents ?? {}).filter(([name]) => published.has(name)).map(([name, def]) => [name, scrubHiddenIds(def, published)])),
279131
+ exports: (this.modelDef.exports ?? []).filter((name) => published.has(name)),
279132
+ sourceRegistry: Object.fromEntries(Object.entries(this.modelDef.sourceRegistry ?? {}).filter(([key]) => published.has(key.split("@")[0]))),
279133
+ queryList: (this.modelDef.queryList ?? []).filter(runsPublished),
279134
+ references: []
279135
+ };
279136
+ }
279137
+ showsFileText(text) {
279138
+ if (this.isDashboard())
279139
+ return true;
279140
+ const published = this.publishedNames();
279141
+ if (!published)
279142
+ return true;
279143
+ const unpublished = new Set(Object.keys(this.modelDef?.contents ?? {}).filter((name) => !published.has(name)));
279144
+ if (unpublished.size === 0)
279145
+ return true;
279146
+ return !malloyIdentifiers(text).some((name) => unpublished.has(name));
279147
+ }
279148
+ serializeNewSources(newSources, cellIndex) {
279149
+ const readable = this.notebookReadable(cellIndex);
279150
+ return newSources?.filter((source) => !readable || readable(source.name)).map((source) => JSON.stringify(this.givens && this.givens.length > 0 ? { ...source, givens: this.givens } : source));
279151
+ }
279152
+ notebookReadable(cellIndex) {
279153
+ const { mode, exploresDeclared } = this.queryBoundary;
279154
+ if (mode === "all" || !exploresDeclared)
279155
+ return;
279156
+ const context = (this.runnableNotebookCells ?? []).slice(0, cellIndex + 1).filter((c) => c.type === "code").map((c) => c.text).join(`
279157
+ `);
279158
+ return (name) => this.isCuratedSource(name) || this.derivesFromCurated(name, undefined, context);
279159
+ }
279160
+ async showsCellQueryInfo(cellIndex, cell) {
279161
+ if (!cell.runnable)
279162
+ return true;
279163
+ try {
279164
+ await this.assertNotebookCellOnSurface(cellIndex, cell.runnable);
279165
+ return true;
279166
+ } catch (error) {
279167
+ if (error instanceof NotQueryableError)
279168
+ return false;
279169
+ throw error;
279170
+ }
277148
279171
  }
277149
279172
  async getNotebookModel() {
277150
- const notebookCells = this.runnableNotebookCells.map((cell) => {
277151
- return {
279173
+ const cells = this.runnableNotebookCells;
279174
+ const notebookCells = [];
279175
+ for (const [index, cell] of cells.entries()) {
279176
+ notebookCells.push({
277152
279177
  type: cell.type,
277153
279178
  text: cell.text,
277154
- newSources: this.serializeNewSources(cell.newSources),
277155
- queryInfo: cell.queryInfo ? JSON.stringify(cell.queryInfo) : undefined
277156
- };
277157
- });
279179
+ newSources: this.serializeNewSources(cell.newSources, index),
279180
+ queryInfo: cell.queryInfo && await this.showsCellQueryInfo(index, cell) ? JSON.stringify(cell.queryInfo) : undefined
279181
+ });
279182
+ }
277158
279183
  const allAnnotations = this.modelDef ? ownModelNotes(this.modelDef) : [];
277159
279184
  const notebookTag = motlyTag(allAnnotations);
279185
+ const readable = this.notebookReadable(notebookCells.length - 1);
279186
+ const shownSource = (name) => !readable || name !== undefined && readable(name);
277160
279187
  const notebook = {
277161
279188
  type: "notebook",
277162
279189
  packageName: this.packageName,
277163
279190
  modelPath: this.modelPath,
277164
279191
  malloyVersion: MALLOY_VERSION,
277165
- modelInfo: JSON.stringify(this.modelInfo ?? {}),
277166
- sources: this.modelDef && this.sources,
277167
- queries: this.modelDef && this.queries,
279192
+ modelInfo: JSON.stringify(this.modelInfo ? {
279193
+ ...this.modelInfo,
279194
+ entries: this.modelInfo.entries?.filter((entry) => entry.kind === "source" ? shownSource(entry.name) : true)
279195
+ } : {}),
279196
+ sources: this.modelDef && this.sources?.filter((source) => shownSource(source.name)),
279197
+ queries: this.modelDef && this.queries?.filter((query) => !readable || shownSource(query.sourceName)),
277168
279198
  annotations: allAnnotations,
277169
279199
  autorun: readAutorun(notebookTag),
277170
279200
  startingGivens: readStartingGivens(notebookTag, (name) => (this.givens ?? []).find((g) => g.name === name)?.type),
@@ -277192,6 +279222,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
277192
279222
  let queryName = undefined;
277193
279223
  let queryResult = undefined;
277194
279224
  if (cell.runnable) {
279225
+ await this.assertNotebookCellOnSurface(cellIndex, cell.runnable);
277195
279226
  try {
277196
279227
  let runnableToExecute = cell.runnable;
277197
279228
  let textToExecute = cell.text;
@@ -277208,6 +279239,11 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
277208
279239
  if (filterClause && cell.modelMaterializer) {
277209
279240
  textToExecute = injectFilterRefinement(cell.text, filterClause);
277210
279241
  runnableToExecute = cell.modelMaterializer.loadQuery(textToExecute);
279242
+ const injectedCellFilters = cellFilters.filter((f) => filterHasValue(f, filterParams ?? {}));
279243
+ if (injectedCellFilters.length > 0 && effectiveSource) {
279244
+ await runnableToExecute.getPreparedQuery();
279245
+ await this.assertFilterAnnotationsBindToDeclaringSource(runnableToExecute, effectiveSource, injectedCellFilters);
279246
+ }
277211
279247
  }
277212
279248
  if (cell.modelMaterializer) {
277213
279249
  const textForRecompile = textToExecute;
@@ -277291,7 +279327,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
277291
279327
  text: cell.text,
277292
279328
  queryName,
277293
279329
  result: queryResult,
277294
- newSources: this.serializeNewSources(cell.newSources)
279330
+ newSources: this.serializeNewSources(cell.newSources, cellIndex)
277295
279331
  };
277296
279332
  }
277297
279333
  static async getModelRuntime(packagePath, modelPath, malloyConfig, options) {
@@ -277489,6 +279525,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
277489
279525
  }
277490
279526
  }
277491
279527
  };
279528
+ SOURCE_IDENTITY_KEYS = new Set(["extends", "sourceID", "referenceID"]);
277492
279529
  });
277493
279530
 
277494
279531
  // ../../node_modules/concat-map/index.js
@@ -299818,9 +301855,10 @@ class ModelController {
299818
301855
  if (model.getType() === "notebook") {
299819
301856
  throw new ModelNotFoundError(`${modelPath} is a notebook`);
299820
301857
  }
301858
+ model.assertFileOnSurface();
299821
301859
  const [compiled, sourceText] = await Promise.all([
299822
301860
  model.getModel(),
299823
- p.getModelFileText(modelPath).catch((error) => {
301861
+ p.getModelFileText(modelPath).then((text) => model.showsFileText(text) ? text : undefined).catch((error) => {
299824
301862
  logger.warn("getModel: model source text unavailable", {
299825
301863
  environmentName,
299826
301864
  packageName,
@@ -299832,7 +301870,7 @@ class ModelController {
299832
301870
  ]);
299833
301871
  return sourceText === undefined ? compiled : { ...compiled, sourceText };
299834
301872
  } catch (error) {
299835
- if (error instanceof ModelNotFoundError) {
301873
+ if (error instanceof ModelNotFoundError || error instanceof NotQueryableError) {
299836
301874
  throw error;
299837
301875
  }
299838
301876
  throw new Error(`Failed to get model ${modelPath} from package ${packageName} in environment ${environmentName}: ${error}`);
@@ -300143,6 +302181,24 @@ var BACK_PRESSURE_SUGGESTIONS = [
300143
302181
  "If this persists, raise the limit or scale up the pod."
300144
302182
  ];
300145
302183
  function classifyToolError(operation, identifier, error) {
302184
+ if (error instanceof OffSurfaceError) {
302185
+ return {
302186
+ message: error.message,
302187
+ suggestions: [
302188
+ "This is curation, not a typo: the name is real and the package does not publish it. Retrying with a different spelling will not help.",
302189
+ "To query what IS published, call get_context for this package and use the model_path it returns, verbatim."
302190
+ ]
302191
+ };
302192
+ }
302193
+ if (error instanceof PackageManifestError) {
302194
+ return {
302195
+ message: error.message,
302196
+ suggestions: [
302197
+ "This is not transient. The package's publisher.json is invalid, so retrying fails the same way until the file is fixed.",
302198
+ "Fix the field the message names in publisher.json, then call reload_package."
302199
+ ]
302200
+ };
302201
+ }
300146
302202
  if (error instanceof EnvironmentNotFoundError || error instanceof PackageNotFoundError || error instanceof ModelNotFoundError || error instanceof NotQueryableError || error instanceof ConnectionNotFoundError) {
300147
302203
  return getNotFoundError(identifier);
300148
302204
  }
@@ -302003,7 +304059,7 @@ async function runContextQuery(request, environmentStore, extraWarnings = []) {
302003
304059
  return jsonResource(uri, {
302004
304060
  sources: sources2,
302005
304061
  ...listingEnvelope,
302006
- ...warningsFor("This package loaded but exposes no sources. That is a curation gap, not an empty database: check the package's explores list and export {} statements, and call get_status for load errors and stale packages.")
304062
+ ...warningsFor("This package loaded but exposes no sources. That is a curation gap, not an empty database: check what the package's index.malloy exports (its export { ... }), and call get_status for load errors and stale packages.")
302007
304063
  });
302008
304064
  }
302009
304065
  return jsonResource(uri, {
@@ -309483,7 +311539,7 @@ init_authorize();
309483
311539
  import crypto4 from "crypto";
309484
311540
  import * as fs8 from "fs";
309485
311541
  import * as path12 from "path";
309486
- import { fileURLToPath as fileURLToPath7, pathToFileURL as pathToFileURL2 } from "url";
311542
+ import { fileURLToPath as fileURLToPath7, pathToFileURL as pathToFileURL3 } from "url";
309487
311543
 
309488
311544
  // src/service/compile_restriction.ts
309489
311545
  init_errors();
@@ -309638,6 +311694,7 @@ init_materialization_metrics();
309638
311694
  var import_recursive_readdir = __toESM(require_recursive_readdir(), 1);
309639
311695
  import * as fs7 from "fs/promises";
309640
311696
  import * as path11 from "path";
311697
+ import { pathToFileURL as pathToFileURL2 } from "url";
309641
311698
  import"@malloydata/db-duckdb/native";
309642
311699
  import { DuckDBConnection as DuckDBConnection3 } from "@malloydata/db-duckdb";
309643
311700
  import {
@@ -310538,7 +312595,7 @@ function packageLoadFailureStatus(error) {
310538
312595
  if (error instanceof ModelCompilationError || error instanceof MalloyError5) {
310539
312596
  return "compilation_error";
310540
312597
  }
310541
- if (error instanceof BadRequestError) {
312598
+ if (error instanceof BadRequestError || error instanceof PackageManifestError) {
310542
312599
  return "policy_rejected";
310543
312600
  }
310544
312601
  if (error instanceof ServiceUnavailableError) {
@@ -310589,6 +312646,7 @@ class Package {
310589
312646
  this.malloyConfig = malloyConfig;
310590
312647
  this.applyDiscoveryPolicyToModels();
310591
312648
  this.applyQueryBoundaryToModels();
312649
+ this.applySiblingModelResolverToModels();
310592
312650
  }
310593
312651
  exploresDeclared() {
310594
312652
  const explores = this.packageMetadata.explores;
@@ -310600,7 +312658,7 @@ class Package {
310600
312658
  if (!previousSurface || previousSurface.length === 0 || this.packageMetadata.explores !== undefined) {
310601
312659
  return;
310602
312660
  }
310603
- const message = `This package published "${previousSurface.join('", "')}" before the last ` + `reload and publishes no surface now, so every model in it is listed ` + `and queryable by name again, including the sources that surface was ` + `withholding. A surface file that was deleted or renamed is the usual ` + `cause. If that was intended, nothing to do. If not, restore the file ` + `(under its original name -- the name IS the surface), or declare an ` + `"explores" in publisher.json naming what this package should ` + `publish. To keep the package open deliberately, write ` + `"explores": [], which says so and stops this notice.`;
312661
+ const message = `This package published "${previousSurface.join('", "')}" before the ` + `last reload and publishes no surface now, so every model in it is ` + `listed and queryable by name again. Fix: if that was not intended, ` + `restore ${INDEX_MODEL_NAME} under that exact name.`;
310604
312662
  this.surfaceWidenedWarning = message;
310605
312663
  logger.warn(`Package ${this.packageName} no longer publishes a surface`, {
310606
312664
  packageName: this.packageName,
@@ -310644,6 +312702,8 @@ class Package {
310644
312702
  continue;
310645
312703
  if (!exploreSet.has(modelPath))
310646
312704
  continue;
312705
+ if (this.isServedDashboard(modelPath))
312706
+ continue;
310647
312707
  for (const source of model.getSources() ?? []) {
310648
312708
  add(sources, source.name, model.definitionIdentity(source.name));
310649
312709
  }
@@ -310654,16 +312714,39 @@ class Package {
310654
312714
  packageCuratedSources = sources;
310655
312715
  packageCuratedQueries = queries;
310656
312716
  }
312717
+ const offSurface = mode === "declared" && exploresDeclared && exploreSet ? {
312718
+ indexModel: this.surfaceIsIndexModel(),
312719
+ files: [...exploreSet].sort()
312720
+ } : undefined;
310657
312721
  for (const [modelPath, model] of this.models) {
312722
+ const dashboardText = this.dashboardFileText.get(modelPath);
312723
+ const dashboard = dashboardText !== undefined;
310658
312724
  model.setQueryBoundary({
310659
312725
  mode,
310660
312726
  exploresDeclared,
310661
- isQueryEntryPoint: exploreSet ? exploreSet.has(modelPath) : true,
312727
+ isQueryEntryPoint: exploreSet ? exploreSet.has(modelPath) || dashboard : true,
310662
312728
  packageCuratedSources,
310663
- packageCuratedQueries
312729
+ packageCuratedQueries,
312730
+ offSurface,
312731
+ dashboard,
312732
+ derivationText: dashboardText
310664
312733
  });
310665
312734
  }
310666
312735
  }
312736
+ dashboardFileText = new Map;
312737
+ isServedDashboard(modelPath) {
312738
+ return this.dashboardFileText.has(modelPath);
312739
+ }
312740
+ applySiblingModelResolverToModels() {
312741
+ const byUrl = new Map;
312742
+ for (const [modelPath, model] of this.models) {
312743
+ byUrl.set(pathToFileURL2(path11.join(this.packagePath, modelPath)).toString(), model);
312744
+ }
312745
+ const resolver = (url2) => byUrl.get(url2)?.getModelDef();
312746
+ for (const model of this.models.values()) {
312747
+ model.setSiblingModelDefResolver(resolver);
312748
+ }
312749
+ }
310667
312750
  static async create(environmentName, packageName, packagePath, environmentMalloyConfig, cleanupDirectoryOnFailure = false) {
310668
312751
  assertSafeEnvironmentPath(packagePath);
310669
312752
  const startTime = performance.now();
@@ -310715,7 +312798,7 @@ class Package {
310715
312798
  defaultConnectionName: "duckdb"
310716
312799
  }).catch((err) => {
310717
312800
  const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
310718
- if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError) {
312801
+ if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError || realError instanceof PackageManifestError) {
310719
312802
  throw realError;
310720
312803
  }
310721
312804
  throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`);
@@ -311280,10 +313363,12 @@ class Package {
311280
313363
  continue;
311281
313364
  if (exploreSet && !exploreSet.has(modelPath))
311282
313365
  continue;
313366
+ if (exploreSet && this.isServedDashboard(modelPath))
313367
+ continue;
311283
313368
  if (model.hasEmptyDiscoverySurface()) {
311284
313369
  warnings.push({
311285
313370
  model: modelPath,
311286
- message: `Model "${modelPath}" is on this package's discovery ` + `surface but exposes nothing: its export closure surfaces ` + `no sources or named queries (typically an import-only ` + `file, or an export {} that filters everything out). Add ` + `e.g. 'export { source_name }' to surface sources on this ` + `model, or ` + (this.surfaceIsIndexModel() ? `delete the file AND any "explores" entry naming it. ` + `Deleting the file alone returns the package to ` + `listing every model only when no "explores" was ` + `written; where one was, it would be left naming a ` + `model that no longer exists and the package would ` + `list nothing.` : `remove it from explores.`)
313371
+ message: this.surfaceIsIndexModel() ? `${INDEX_MODEL_NAME} exports nothing, so nothing in this ` + `package can be queried. Fix: add an export { ... } ` + `naming the sources to publish.` : `${modelPath} exports nothing, so nothing can be queried ` + `through it. Fix: add an export { ... } naming the ` + `sources to publish, or remove it from "explores".`
311287
313372
  });
311288
313373
  }
311289
313374
  }
@@ -311293,7 +313378,7 @@ class Package {
311293
313378
  const exploreSet = this.exploreSet();
311294
313379
  if (!exploreSet || exploreSet.size === 0)
311295
313380
  return [];
311296
- const surface = Array.from(this.models.entries()).filter(([modelPath]) => modelPath.endsWith(MODEL_FILE_SUFFIX) && exploreSet.has(modelPath));
313381
+ const surface = Array.from(this.models.entries()).filter(([modelPath]) => modelPath.endsWith(MODEL_FILE_SUFFIX) && exploreSet.has(modelPath) && !this.isServedDashboard(modelPath));
311297
313382
  if (surface.length === 0)
311298
313383
  return [];
311299
313384
  if (!surface.every(([, model]) => !!model.getCompilationError())) {
@@ -311388,7 +313473,7 @@ class Package {
311388
313473
  });
311389
313474
  } catch (err) {
311390
313475
  const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
311391
- if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError) {
313476
+ if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError || realError instanceof PackageManifestError) {
311392
313477
  throw realError;
311393
313478
  }
311394
313479
  throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`);
@@ -311440,6 +313525,7 @@ class Package {
311440
313525
  this.packageMetadata.manifestLocation = outcome.packageMetadata.manifestLocation ?? null;
311441
313526
  this.applyDiscoveryPolicyToModels();
311442
313527
  this.applyQueryBoundaryToModels();
313528
+ this.applySiblingModelResolverToModels();
311443
313529
  await this.discoverDashboards();
311444
313530
  this.recordManifestBinding(entries);
311445
313531
  this.wireFreshnessResolvers();
@@ -311464,7 +313550,9 @@ class Package {
311464
313550
  const values = await Promise.all(Array.from(this.models.keys()).filter((modelPath) => {
311465
313551
  if (!modelPath.endsWith(MODEL_FILE_SUFFIX))
311466
313552
  return false;
311467
- return exploreSet ? exploreSet.has(modelPath) : true;
313553
+ if (!exploreSet)
313554
+ return true;
313555
+ return exploreSet.has(modelPath) && !this.isServedDashboard(modelPath);
311468
313556
  }).map(async (modelPath) => {
311469
313557
  let error;
311470
313558
  try {
@@ -311481,18 +313569,6 @@ class Package {
311481
313569
  }));
311482
313570
  return values;
311483
313571
  }
311484
- isQueryableEntryPoint(modelPath) {
311485
- if (!this.queryBoundaryActive())
311486
- return true;
311487
- const exploreSet = this.exploreSet();
311488
- return exploreSet ? exploreSet.has(modelPath) : true;
311489
- }
311490
- servableEntryPoints() {
311491
- if (!this.queryBoundaryActive())
311492
- return () => true;
311493
- const exploreSet = this.exploreSet();
311494
- return (modelPath) => exploreSet ? exploreSet.has(modelPath) : true;
311495
- }
311496
313572
  queryBoundaryActive() {
311497
313573
  if (this.packageMetadata.queryableSources === "all")
311498
313574
  return false;
@@ -311510,7 +313586,6 @@ class Package {
311510
313586
  const discovered = new Map;
311511
313587
  const factsByPath = new Map;
311512
313588
  const allFacts = new Map;
311513
- const heldBack = [];
311514
313589
  const unconventionalSlugs = [];
311515
313590
  const droppedByError = [];
311516
313591
  const dashboardSlugs = new Set;
@@ -311576,10 +313651,6 @@ class Package {
311576
313651
  };
311577
313652
  }
311578
313653
  dashboardSlugs.add(name);
311579
- if (!this.isQueryableEntryPoint(modelPath)) {
311580
- heldBack.push({ modelPath, name });
311581
- continue;
311582
- }
311583
313654
  if (!matchesDocumentedDashboardName(name)) {
311584
313655
  unconventionalSlugs.push({ modelPath, name });
311585
313656
  }
@@ -311588,7 +313659,34 @@ class Package {
311588
313659
  factsByPath.set(modelPath, facts);
311589
313660
  }
311590
313661
  this.dashboards = discovered;
311591
- this.dashboardWarnings = await this.lintDashboards(factsByPath, allFacts, heldBack, unconventionalSlugs, dashboardSlugs, droppedByError);
313662
+ const dashboardFileText = new Map;
313663
+ for (const manifest of discovered.values()) {
313664
+ const model = this.models.get(manifest.entryFile);
313665
+ if (!model)
313666
+ continue;
313667
+ try {
313668
+ dashboardFileText.set(manifest.entryFile, await model.getFileText(this.packagePath));
313669
+ } catch {
313670
+ dashboardFileText.set(manifest.entryFile, "");
313671
+ }
313672
+ }
313673
+ this.dashboardFileText = dashboardFileText;
313674
+ this.applyQueryBoundaryToModels();
313675
+ this.lintInputs = {
313676
+ factsByPath,
313677
+ allFacts,
313678
+ unconventionalSlugs,
313679
+ dashboardSlugs,
313680
+ droppedByError
313681
+ };
313682
+ await this.relintDashboards();
313683
+ }
313684
+ lintInputs;
313685
+ async relintDashboards() {
313686
+ const inputs = this.lintInputs;
313687
+ if (!inputs)
313688
+ return;
313689
+ this.dashboardWarnings = await this.lintDashboards(inputs.factsByPath, inputs.allFacts, inputs.unconventionalSlugs, inputs.dashboardSlugs, inputs.droppedByError);
311592
313690
  for (const warning of this.dashboardWarnings) {
311593
313691
  logger.warn("Dashboard lint", {
311594
313692
  packageName: this.packageName,
@@ -311597,32 +313695,60 @@ class Package {
311597
313695
  });
311598
313696
  }
311599
313697
  }
311600
- lintTilesAgainstSurface(modelPath, manifest) {
313698
+ async lintTilesAgainstSurface(modelPath, manifest) {
311601
313699
  const model = this.models.get(modelPath);
311602
- if (!model || !manifest.tiles)
313700
+ if (!model || !this.queryBoundaryActive())
311603
313701
  return [];
311604
- const listedModel = (this.packageMetadata.explores ?? []).find((entry) => entry.endsWith(MODEL_FILE_SUFFIX) && !isDashboardModelPath(entry));
311605
- const importInto = listedModel ? `a listed file such as "${listedModel}"` : `a file you list there`;
311606
- const findings = [];
311607
- for (const tile of manifest.tiles) {
311608
- try {
311609
- model.assertQueryBoundaryEarly(undefined, undefined, `run: ${tile.query}`);
311610
- } catch (error) {
311611
- if (!(error instanceof NotQueryableError))
311612
- throw error;
311613
- findings.push({
311614
- model: modelPath,
311615
- subject: manifest.name,
311616
- message: `tile "${tile.query}" reads a source this package's surface ` + `does not publish, so the tile answers 404 once served, ` + `although the file compiles. Listing a file publishes what ` + `it declares, not what it imports. Fix: add the file that ` + `declares the source to 'explores', keeping the entries ` + `already there, or import it into ${importInto} and add ` + `it to that file's export { … }.`,
311617
- severity: "error"
311618
- });
311619
- }
313702
+ const surfaceIsIndex = this.surfaceIsIndexModel();
313703
+ const listedModel = (this.packageMetadata.explores ?? []).find((entry) => entry.endsWith(MODEL_FILE_SUFFIX) && !this.isServedDashboard(entry));
313704
+ const unexported = surfaceIsIndex ? `which ${INDEX_MODEL_NAME} doesn't export` : `which no file "explores" lists exports`;
313705
+ const fixFor = (source) => {
313706
+ const name = source ?? "the source it reads";
313707
+ return surfaceIsIndex ? `Fix: add ${name} to the export { ... } in ${INDEX_MODEL_NAME}.` : listedModel ? `Fix: add ${name} to the export { ... } in ${listedModel}.` : `Fix: delete "explores" from publisher.json and add ${name} ` + `to the export { ... } in ${INDEX_MODEL_NAME}.`;
313708
+ };
313709
+ const checks = [];
313710
+ for (const tile of manifest.tiles ?? []) {
313711
+ checks.push({
313712
+ request: { query: `run: ${tile.query}` },
313713
+ describe: (source) => `Tile ${tile.query} on dashboard ${manifest.name} reads ` + `${source}, ${unexported}, so it won't load.`
313714
+ });
313715
+ }
313716
+ if (manifest.query) {
313717
+ checks.push({
313718
+ request: { queryName: manifest.query },
313719
+ describe: (source) => `Dashboard ${manifest.name} reads ${source}, ` + `${unexported}, so it won't load.`
313720
+ });
313721
+ }
313722
+ for (const given of manifest.givens) {
313723
+ const suggest = given.suggest;
313724
+ if (!suggest)
313725
+ continue;
313726
+ const request = suggest.query ? { queryName: suggest.query } : suggest.source && suggest.dimension ? {
313727
+ query: `run: ${suggest.source} -> { group_by: ${suggest.dimension} }`
313728
+ } : undefined;
313729
+ if (!request)
313730
+ continue;
313731
+ checks.push({
313732
+ request,
313733
+ describe: (source) => `Filter ${given.name} on dashboard ${manifest.name} suggests ` + `from ${source}, ${unexported}, so its list will be ` + `empty.`
313734
+ });
311620
313735
  }
313736
+ const refusals = await Promise.all(checks.map(({ request }) => model.surfaceRefusal(request)));
313737
+ const findings = [];
313738
+ refusals.forEach((refusal, i) => {
313739
+ if (!refusal)
313740
+ return;
313741
+ findings.push({
313742
+ model: modelPath,
313743
+ subject: manifest.name,
313744
+ message: `${checks[i].describe(refusal.source ?? "a source")} ${fixFor(refusal.source)}`,
313745
+ severity: "error"
313746
+ });
313747
+ });
311621
313748
  return findings;
311622
313749
  }
311623
- async lintDashboards(factsByPath, allFacts, heldBack, unconventionalSlugs, knownSlugs, droppedByError) {
313750
+ async lintDashboards(factsByPath, allFacts, unconventionalSlugs, knownSlugs, droppedByError) {
311624
313751
  const warnings = [];
311625
- const withheldDrills = new Map;
311626
313752
  try {
311627
313753
  for (const { modelPath, name } of droppedByError) {
311628
313754
  warnings.push({
@@ -311640,27 +313766,6 @@ class Package {
311640
313766
  severity: "warn"
311641
313767
  });
311642
313768
  }
311643
- for (const { modelPath, name } of heldBack) {
311644
- for (const file of allFacts.values()) {
311645
- for (const drill of file.drills) {
311646
- if (!drill.to.includes(name))
311647
- continue;
311648
- const where = `${drill.source}.${drill.dimension}`;
311649
- withheldDrills.set(`${where}|${name}`, {
311650
- subject: where,
311651
- message: `# drill on ${where} targets "${name}", which IS a ` + `dashboard in this package but is not served (see the ` + `finding on "${modelPath}"), so the click has nowhere ` + `to land.`,
311652
- severity: "error"
311653
- });
311654
- }
311655
- }
311656
- const remedy = this.surfaceIsIndexModel() ? `This package's surface is "${INDEX_MODEL_NAME}", and a ` + `dashboard file cannot be exported from it -- dashboards are ` + `files, not sources. To serve this one, declare (or extend) ` + `an 'explores' in publisher.json listing both ` + `"${INDEX_MODEL_NAME}" and "${modelPath}"; an explicit key ` + `overrides the convention. Or set ` + `queryableSources: "all" to keep the curated surface for ` + `discovery only.` : `Add it to 'explores', or set queryableSources: "all" to ` + `keep the curated surface for discovery only.`;
311657
- warnings.push({
311658
- model: modelPath,
311659
- subject: name,
311660
- message: `is a dashboard, but "${modelPath}" is not part of this ` + `package's discovery surface and this package sets ` + `queryableSources: "declared", so its query would be ` + `refused. It is not served. ${remedy} Listing it is not ` + `always sufficient on its own: the queryable sources are ` + `the union of every listed file's export closure, so a tile ` + `reading a source that only an UNLISTED file exports is ` + `still refused. List that file too, or re-export the source ` + `from one already listed.`,
311661
- severity: "warn"
311662
- });
311663
- }
311664
313769
  for (const [modelPath, facts] of factsByPath) {
311665
313770
  const manifest = this.dashboards.get(dashboardSlug(modelPath));
311666
313771
  const findings = manifest ? lintDashboard(facts, manifest) : lintUndiscoveredDashboard(facts);
@@ -311668,15 +313773,12 @@ class Package {
311668
313773
  warnings.push({ model: modelPath, ...finding });
311669
313774
  }
311670
313775
  if (manifest) {
311671
- for (const finding of this.lintTilesAgainstSurface(modelPath, manifest)) {
313776
+ for (const finding of await this.lintTilesAgainstSurface(modelPath, manifest)) {
311672
313777
  warnings.push(finding);
311673
313778
  }
311674
313779
  }
311675
313780
  }
311676
313781
  const drillFacts = Array.from(allFacts.values());
311677
- for (const finding of withheldDrills.values()) {
311678
- warnings.push(finding);
311679
- }
311680
313782
  for (const finding of lintDrillTargets(drillFacts, knownSlugs)) {
311681
313783
  warnings.push(finding);
311682
313784
  }
@@ -311695,7 +313797,7 @@ class Package {
311695
313797
  error: errMessage(err)
311696
313798
  });
311697
313799
  warnings.push({
311698
- message: `Dashboard lint stopped early, so this list is incomplete: an ` + `unknown subset of the curation, dashboard, drill, given and ` + `unsupported-component checks did not run. Treat a missing ` + `finding of any of those kinds as unknown rather than clean, ` + `including the absence of a "held back from the listing" ` + `finding. Reload the package to run the lint again. The ` + `dashboards themselves are unaffected, because they are ` + `discovered before the lint runs. An operator can find the ` + `cause in the server log under "Dashboard lint failed".`,
313800
+ message: `Dashboard lint stopped early, so this list is incomplete: an ` + `unknown subset of the tile, dashboard, drill, given and ` + `unsupported-component checks did not run. Treat a missing ` + `finding of any of those kinds as unknown rather than clean. ` + `Reload the package to run the lint again. The ` + `dashboards themselves are unaffected, because they are ` + `discovered before the lint runs. An operator can find the ` + `cause in the server log under "Dashboard lint failed".`,
311699
313801
  severity: "warn"
311700
313802
  });
311701
313803
  }
@@ -311725,8 +313827,7 @@ class Package {
311725
313827
  return warnings;
311726
313828
  }
311727
313829
  listDashboards() {
311728
- const servable = this.servableEntryPoints();
311729
- return Array.from(this.dashboards.values()).filter((manifest) => servable(manifest.entryFile)).map((manifest) => ({
313830
+ return Array.from(this.dashboards.values()).map((manifest) => ({
311730
313831
  resource: this.dashboardResource(manifest.name),
311731
313832
  packageName: this.packageName,
311732
313833
  name: manifest.name,
@@ -311738,9 +313839,6 @@ class Package {
311738
313839
  }
311739
313840
  getDashboard(name) {
311740
313841
  const manifest = this.dashboards.get(name);
311741
- if (manifest && !this.isQueryableEntryPoint(manifest.entryFile)) {
311742
- return;
311743
- }
311744
313842
  if (!manifest)
311745
313843
  return;
311746
313844
  return {
@@ -311888,6 +313986,7 @@ class Package {
311888
313986
  this.packageMetadata = packageMetadata;
311889
313987
  this.applyDiscoveryPolicyToModels();
311890
313988
  this.applyQueryBoundaryToModels();
313989
+ this.applySiblingModelResolverToModels();
311891
313990
  }
311892
313991
  }
311893
313992
 
@@ -312079,9 +314178,10 @@ class Environment {
312079
314178
  const modelPath = safeJoinUnderRoot(this.environmentPath, packageName, modelName);
312080
314179
  const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
312081
314180
  const modelDir = path12.dirname(modelPath);
312082
- const virtualUrl = scope === "append" ? pathToFileURL2(path12.join(modelDir, "__compile_check.malloy")) : pathToFileURL2(modelPath);
314181
+ const virtualUrl = scope === "append" ? pathToFileURL3(path12.join(modelDir, "__compile_check.malloy")) : pathToFileURL3(modelPath);
312083
314182
  const virtualUri = virtualUrl.toString();
312084
314183
  let fullSource = source ?? "";
314184
+ let callerRegion;
312085
314185
  if (scope === "append") {
312086
314186
  let modelContent = "";
312087
314187
  try {
@@ -312089,6 +314189,13 @@ class Environment {
312089
314189
  } catch {}
312090
314190
  fullSource = modelContent ? `${modelContent}
312091
314191
  ${source}` : source ?? "";
314192
+ callerRegion = {
314193
+ kind: "span",
314194
+ url: virtualUri,
314195
+ fromLine: modelContent ? modelContent.split(`
314196
+ `).length : 0,
314197
+ text: source ?? ""
314198
+ };
312092
314199
  }
312093
314200
  const substitute = scope !== "package" || source !== undefined;
312094
314201
  const interceptingReader = {
@@ -312115,7 +314222,9 @@ ${source}` : source ?? "";
312115
314222
  if (gateModel && hasExactGateModel && source !== undefined) {
312116
314223
  await denyHiddenAsNotQueryable(() => {
312117
314224
  gateModel.assertQueryBoundaryEarly(undefined, undefined, source);
312118
- }, () => gateModel.assertAuthorizedForText(source, givens ?? {}));
314225
+ }, () => gateModel.assertAuthorizedForText(source, givens ?? {}, {
314226
+ wholeFile: scope !== "append"
314227
+ }));
312119
314228
  }
312120
314229
  const boundManifestEntries = pkg.getBuildManifestEntries();
312121
314230
  const runtime = new Runtime3({
@@ -312173,6 +314282,9 @@ ${source}` : source ?? "";
312173
314282
  replacement: source === undefined ? undefined : { modelPath: modelName, source }
312174
314283
  });
312175
314284
  } catch (error) {
314285
+ if (error instanceof MalloyError6 || error instanceof ModelCompilationError || error instanceof PackageManifestError) {
314286
+ throw error;
314287
+ }
312176
314288
  throw new ServiceUnavailableError(`Package compile worker unavailable: ${error instanceof Error ? error.message : String(error)}`);
312177
314289
  }
312178
314290
  const seen = new Set;
@@ -312220,7 +314332,7 @@ ${source}` : source ?? "";
312220
314332
  if (scope === "append") {
312221
314333
  let baseModel;
312222
314334
  try {
312223
- baseModel = await runtime.loadModel(pathToFileURL2(modelPath)).getModel();
314335
+ baseModel = await runtime.loadModel(pathToFileURL3(modelPath)).getModel();
312224
314336
  } catch (error) {
312225
314337
  logger.warn("Compile gate could not load the base model", {
312226
314338
  modelPath,
@@ -312261,7 +314373,7 @@ ${source}` : source ?? "";
312261
314373
  throw new NotQueryableError("Query target is not queryable.");
312262
314374
  }
312263
314375
  return gateModel.assertCompiledTargetQueryable(materializer, source);
312264
- }, () => hasExactGateModel ? gateModel.assertAuthorizedForRunnable(materializer, givens ?? {}) : gateModel.assertAuthorizedFromCompiledRunnable(materializer, givens ?? {}));
314376
+ }, () => hasExactGateModel ? gateModel.assertAuthorizedForRunnable(materializer, givens ?? {}, callerRegion) : gateModel.assertAuthorizedFromCompiledRunnable(materializer, givens ?? {}));
312265
314377
  }
312266
314378
  let sql;
312267
314379
  if (includeSql && queryMaterializer) {
@@ -312971,6 +315083,8 @@ ${source}` : source ?? "";
312971
315083
  await _package.reloadAllModels({});
312972
315084
  _package.bindStorageServeBindings({});
312973
315085
  }
315086
+ } else {
315087
+ await _package.relintDashboards();
312974
315088
  }
312975
315089
  return _package.getPackageMetadata();
312976
315090
  });
@@ -318692,12 +320806,17 @@ recall is scoring search against an expectation that was never satisfiable.
318692
320806
  Because it runs from the model text alone it is cheap enough to point at every
318693
320807
  published version and read as a trend, which is what it is for. Its verdicts are
318694
320808
  \`eval-diagnose\`'s codes verbatim, validated against that table at startup, so
318695
- \`COVERAGE\`, \`AMBIGUOUS\`, \`NO-DISAMBIG\` and \`CONVENTION\` mean there exactly what
318696
- they mean here. It writes nothing: not a golden, not the case-level \`coverage\`
320809
+ \`MISSING\`, \`AMBIGUOUS\`, \`RULE_UNWRITTEN\` and \`UNDERSPECIFIED\` mean there exactly
320810
+ what they mean here. A pass is \`MODELLED\`. It writes nothing: not a golden, not the case-level \`coverage\`
318697
320811
  field, not a run directory. Where its verdict and that field disagree is where a
318698
320812
  version regressed, and the field is the standing judgement about the question
318699
320813
  while this is a measurement against one build.
318700
320814
 
320815
+ Its \`--out\` report records \`compiledSurface\`: \`read\`, or why the compiled field
320816
+ list was not read. Without that list the judge calls a column a source exposes
320817
+ implicitly absent, so check the field before comparing two runs. A \`--model\`
320818
+ run never has it.
320819
+
318701
320820
  How to invoke it. Either \`--model <file-or-dir>\` for a local package or
318702
320821
  \`--publisher <url> --package <pkg>\` for a served one, plus \`--set <dir>\`, and
318703
320822
  \`--version <label>\` to stamp the report so a trend has an x-axis:
@@ -318745,7 +320864,7 @@ purpose. Separately, the whole model goes into one prompt per case, so past a
318745
320864
  few thousand lines it stops running on Linux and above about 100 KB the verdict
318746
320865
  stops being stable. Do not take the over-size message's advice to narrow
318747
320866
  \`--model\` to one file, which drops every imported source and manufactures
318748
- \`COVERAGE\` verdicts.
320867
+ \`MISSING\` verdicts.
318749
320868
 
318750
320869
  ## Validate the definitions, not every answer
318751
320870
 
@@ -318859,6 +320978,10 @@ was. Separately, the whole model goes into one prompt per case, which caps the
318859
320978
  package size it can run on at all and destabilises the verdict well before that
318860
320979
  cap.
318861
320980
 
320981
+ The measurements below predate the renamed verdicts, and their tables keep the
320982
+ names they were taken under: \`ok\` is now \`MODELLED\`, \`COVERAGE\` is \`MISSING\`,
320983
+ \`NO-DISAMBIG\` is merged into \`AMBIGUOUS\`, and \`CONVENTION\` is \`RULE_UNWRITTEN\`.
320984
+
318862
320985
  ## Measured against a labelled set: the label drifts too
318863
320986
 
318864
320987
  The \`evals/ecommerce\` set in \`credibledata/malloy-samples\` labels every case
@@ -319002,7 +321125,7 @@ concatenates every \`*.malloy\` under the tree and does **not** resolve \`import
319002
321125
  real packages split across files exactly that way (in
319003
321126
  \`credibledata/malloy-samples\`, \`ecommerce/brand_synergy.malloy\` opens with
319004
321127
  \`import "ecommerce.malloy"\`). Narrowing to one file drops every imported source,
319005
- so the judge correctly answers \`COVERAGE\`, no entity represents the concept
321128
+ so the judge correctly answers \`MISSING\`, no entity represents the concept
319006
321129
  anywhere, for cases the model answers perfectly well. That is a false gap
319007
321130
  indistinguishable from a real one, and it lands in the numerator of a published
319008
321131
  trend.
@@ -319050,11 +321173,10 @@ format address the cost, the ceiling and \`json_objects()\` together. Stdlib
319050
321173
  \`urllib\` keeps the stdlib-only promise; the real cost is auth, since \`claude -p\`
319051
321174
  rides the CLI's credentials.
319052
321175
 
319053
- **And for a large package, stop sending the whole model.** Rule out \`COVERAGE\`
321176
+ **And for a large package, stop sending the whole model.** Rule out \`MISSING\`
319054
321177
  against a complete inventory of names and one-line docs, a small fraction of the
319055
321178
  bytes, which keeps "no candidate anywhere" decidable; then send full text for
319056
- the candidate sources only, to settle \`AMBIGUOUS\`, \`NO-DISAMBIG\` and
319057
- \`CONVENTION\`. Deliberately NOT retrieval ranking: folding retrieval into this
321179
+ the candidate sources only, to settle \`AMBIGUOUS\` and \`RULE_UNWRITTEN\`. Deliberately NOT retrieval ranking: folding retrieval into this
319058
321180
  number would collapse the distinction from recall that the metric exists to
319059
321181
  draw.`
319060
321182
  },
@@ -319199,7 +321321,7 @@ record, and \`verify_definitions.py\` re-runs and compares it on every rebuild.
319199
321321
  "query": "run: order_items -> { aggregate: control is sale_price.sum() { where: status != 'Cancelled' } - inventory_items.cost.sum() { where: status != 'Cancelled' } }",
319200
321322
  "note": "excludes Cancelled, per status's own doc and units_sold"
319201
321323
  },
319202
- "cause": "CONVENTION"
321324
+ "cause": "RULE_UNWRITTEN"
319203
321325
  }
319204
321326
  \`\`\`
319205
321327
 
@@ -319223,7 +321345,7 @@ Authoring a control is judgement, and the same rules as authoring a golden apply
319223
321345
  where two populations are both defensible from the docs, do not pick one --
319224
321346
  record the ambiguity in \`note\` and leave the record \`unchecked\`. \`cause\` is the
319225
321347
  \`eval-diagnose\` code the author expects a disagreement to mean (the cogs case is
319226
- \`CONVENTION\`); it is carried onto the finding, not inferred.
321348
+ \`RULE_UNWRITTEN\`); it is carried onto the finding, not inferred.
319227
321349
 
319228
321350
  This is **validate what someone asserted**, not **find business-wrong definitions
319229
321351
  unaided**. It turns "I believe \`total_gross_margin\` should exclude cancelled
@@ -319370,7 +321492,7 @@ a permanent parallel mechanism.`
319370
321492
  {
319371
321493
  name: "eval-answer/ledger-schema",
319372
321494
  description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
319373
- body: "# The eval ledger: files and events\n\nThe ledger is plain files in the model package's git repository. There is no\neval API and no eval database. The conductor (`skill:eval-loop`) reads and\nwrites these files directly; the stages share them as their contract.\n`eval-answer` writes `attempt`, `tool_call`, and `score`. An engine-side\n`eval-retrieval` skill, which does not ship here, writes `retrieval_score` and\n`probe`; both stay in this contract so one validator covers every run\ndirectory.\n`eval-diagnose` writes `issue` and `issue_status`. `eval-improve` writes\n`candidate`. `eval-loop` writes `acceptance_check`, further `issue_status`, and\n`checkpoint`.\n\n**The contract is code: `eval-answer/scripts/ledger.py`.** Every script that\nwrites `run.json` or `events.jsonl` builds its lines through that module, so a\nfield rename that reaches only one writer fails at write time. This document\nis the human-readable rendering; where the two disagree, the module is right\nand this file is the bug. Check any run directory with:\n\n```\npython3 skills/eval-answer/scripts/ledger.py validate <runDir>\n```\n\nErrors are broken identity (missing `run.json`, a missing required field, two\nscores for one attempt); warnings are missing comparison pins (a run without\n`skillsVersion` still measures, but cannot anchor an A/B on that pin) and\ngrandfathered unknown fields on old runs.\n\n## Layout\n\n```\nevals/<set>/\n set.json # set metadata (below)\n cases.jsonl # one case per line\n judge-regressions.jsonl # judge verdicts a human overruled\n runs/<runId>/\n run.json # run config, the attribution pins\n events.jsonl # append-only event lines\n artifacts/ # prediction CSVs, judge outputs, transcripts\n```\n\nThe set directory lives in the SAME git repository as the model it evaluates,\nso a checkpoint (a git commit) pins the model and the ledger together. Never\nplace `evals/` inside the directory tree the answerer's package serves: gold\nin the served tree is a contamination path.\n\nRules that make the ledger trustworthy:\n\n- `events.jsonl` is append-only. Never edit a line. To void one, append a new\n event whose payload marks the old one `voided`. One sanctioned exception: a\n re-runnable stage (diagnose, improve) replaces ITS OWN prior events, keyed by\n issue id, through `ledger.replace_events()` -- so a crash-and-rerun does not\n duplicate issues. A stage never touches another stage's lines.\n- A stage never rewrites another stage's fields.\n- One artifact directory per attempt (`qid` plus `sample`). Never overwrite a\n previous attempt's prediction CSV.\n- End-of-run numbers come from counting event lines (`jq` over\n `events.jsonl`), never from arithmetic recalled in prose.\n\n## `set.json`\n\n| Field | Notes |\n|---|---|\n| `name` | Set name; also the directory name. |\n| `description` | |\n| `datasetVersion` | Integer. Bump on any golden repair or case change. Runs record the version they scored against. |\n| `targetModelPath` | Model path within the package. |\n| `truthPackage` | Name of the package holding the semantics-free sources every golden is re-derived from -- a package NAME, not an object. Without it `verify_goldens.py` re-derives nothing: it reports \"nothing to re-derive against\" and **exits 3**, which a caller must read as unverified rather than as a pass. The checks that need no truth server -- rubric figures, verification axis, stale rubric claims, set names -- still run and still report. `init_truth_package.py` scaffolds the package. |\n| `truthModel` | Model file inside that package. Default `truth.malloy`. |\n| `targetPackage` | Name of the package UNDER TEST. `verify_goldens.py` refuses a \"truth\" server that also serves it, because an answerer on that server can retrieve the raw truth sources beside the model and quietly change what is under test. `run_baseline.py` and `improve.py` pass the package they were given, so this field is the fallback for a direct call; without either the guard cannot fire. |\n| `truthTableRewrite` | Boolean, default false. Rewrites `duckdb.table('data/x.parquet')` refs to bare `x` in canonical queries, for a truth package whose tables are registered rather than read from files. |\n\n## `cases.jsonl`\n\nOne JSON object per line:\n\n| Field | Notes |\n|---|---|\n| `qid` | Stable case id. |\n| `question` | Exact text the answerer will see. |\n| `split` | `dev` or `holdout`. Frozen at import. Diagnose and improve read `dev` only; the acceptance check runs both. |\n| `tags` | list |\n| `state` | `candidate` / `selected` / `excluded`. |\n| `source` | Where the case came from. |\n| `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (artifact under the set directory), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |\n| `golden.kind` | What the key IS. A value (`scalar`, rows), `unanswerable`, or `criteria`: prose with no value, where the author's criteria are the whole key. `criteria` is the one kind that is legitimately `verified` on arrival, because there is no derivation to perform and nothing that can be numerically wrong; an imported key holding a VALUE is `provisional` until re-derived (`skill:eval-import`). **`verify_goldens.py --promote` is the only thing that writes `golden.status`**: it marks a `provisional` golden `verified` when its value re-derived cleanly from the truth package AND a second derivation exists. `--refresh` rewrites a drifted VALUE and never changes a status; `invalid` and `ambiguous` are settled by a person through the golden side door. |\n| `golden.rubric` | **On the golden, not on the case.** The prose the judge is shown as `CASE RUBRIC`, naming what counts as correct and what does not. `run_baseline.py` reads `golden.rubric`; a rubric written at the case's top level is silently not passed, the judge is told `CASE RUBRIC: none`, and it then scores from the golden value and the answer alone -- which reads as a judge that ignores its instructions. |\n| `golden.mustState` | **On the golden, not on the case.** What the answer has to say out loud. Read from `golden.mustState` by the run-package builder. |\n| `goldenRevision` | Integer. Bump on every golden change; `score` events stamp the revision they compared against. |\n| `expectedEntities` | `required`: entity ids (`kind:source:name`) the answer cannot be produced without. `requiredAnyOf`: a list of **groups**, each a list of ids of which any one suffices -- for a case the model can answer through more than one route. Naming only one route scores the other as a retrieval miss and steers the fix to the docs for an entity whose docs were never the problem. `acceptable`: ids that are not noise if returned. An entity counts as delivered when returned under its exact id, under the same type and name on a sibling source, or by name in a returned source's documentation; `score_retrieval.py` records the route per entity as `delivery`. |\n\n## Term files (`eval-retrieval` only)\n\nA customer set has no term file. The engine-side `eval-retrieval` skill keeps\n`intents.jsonl` fixtures -- search term, entity type, a description of what the\nsearcher meant, a validity flag -- beside its own scripts, for evaluating the\nengine against fixed inputs. Their shape and the `retrieval_score` events they\nproduce are documented there and validated here.\n\n## `run.json`\n\nThe attribution pins. Two runs are comparable only when these match where it\nmatters. Two runs with different `target` values are comparable on answer\nverdicts only if the same model version reached both, which is rarely worth\nassuming:\n\nRequired fields are the run's **identity** -- every run ever written carries\nthem. The rest are optional in the schema, but the **comparison pins** are\nwarned about when absent, because a run missing one cannot take part in an A/B\non that axis.\n\n| Field | Notes |\n|---|---|\n| `runId` *(required)* | Directory name. |\n| `target` *(required)* | Which server answered, and how it reached the data: `local`, `local-proxied`, or `platform`. Decides what an improve step is even allowed to do (see `skill:eval-loop`). |\n| `answererModel` *(required)* | |\n| `phase` / `started` *(required)* | |\n| `judgeModel` / `judgeVersion` / `rubricSha` *(pins)* | The judge's model, and the version + content sha of `skill:eval-judge`. |\n| `judgePromptSha` *(pin)* | Content sha of the prompt the judge is SHOWN, which is the harness's own template and not the skill file `rubricSha` covers. The template carries the golden rendering, the evidence blocks and the tie-breaking rules, so two runs whose prompts differed compared as the same judge -- the blind spot that let a golden-rendering defect run through three arms with every test passing, because the tests covered the code paths and not the text the model sees. Absent on runs written before it existed. |\n| `datasetVersion` *(pin)* | From `set.json` at run time. |\n| `modelSha` *(pin)* | Content sha of the `model.malloy` snapshot in the run directory -- the bytes the answerer actually queried. A git sha is not enough: a snapshot host serves a copy, often of a dirty tree no commit names. |\n| `skillsVersion` *(pin)* | HEAD of the checkout the agents' skills were loaded from, dirty-marked (`ledger.skills_git_sha(root)`). The skills are the doctrine the agents load; a run that cannot name their version cannot anchor a skills A/B. |\n| `skillsRoot` / `harnessVersion` | Which checkout supplied the doctrine (`--skills-root`, e.g. a Publisher checkout for the open-source skills; default this one), and this checkout's own HEAD. The eval-* skills always come from the harness checkout, whatever `skillsRoot` says. Two runs whose `skillsRoot` differ are a skills A/B only if their manifests name the same skills. |\n| `diagnoserModel` / `improverModel` | Written by `diagnose.py` / `improve.py` when those stages run, so the run names every LLM that touched it. Absent on a run that was only answered and judged. |\n| `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when that path had uncommitted changes -- fine for a band measurement, not for an A/B pin. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\n| `modelDir` | The package directory `--model-dir` named, when it was: the path the `-dirty` marker on `modelGitSha` was decided over. Absent means the whole repo, where any unrelated untracked file marks the model dirty. |\n| `environment` / `package` / `modelPath` | What was served, and from where. On a platform target `environment` is the organization and `package` is the workspace the MCP URL is scoped to. |\n| `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |\n| `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |\n| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |\n| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |\n| `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |\n| `retrievalCalls` | `{semantic, lexical, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |\n| `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart are lexical by design and an arm started immediately measures two retrievers. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |\n| `coverageReport` | The `check_coverage.py --out` report this run read, when `--coverage` named one: its path, the model version it was stamped with, its `agentModel`, and `decided` of `cases`. Coverage is a read of the MODEL, so it is measured separately and costs a judge call per case; the run does not run it, it consumes it. Its per-case verdict then beats the authored `coverage` label in retrieval attribution, and each `score_retrieval` row records `coverage_source` (`measured`, `authored`, `none`) so nobody has to guess which one charged the failure. Absent when no report was given, which is a different fact from a report that found everything covered. |\n| `label` | `<set>-<phase>-<nn>`, assigned by `run_baseline.py` from the set name, the run's phase and the next free number beside it (`ecommerce-baseline-01`, `ecommerce-baseline-02`, `ecommerce-blind_gate-01`). The A/A pair is two runs of the same phase; the post-edit arms are two runs of `blind_gate`. Hand-typed names do not survive one afternoon of runs -- `base`, `rejudged2`, `r3`, `post1` sort wrongly, group not at all, and cannot be matched to an arm. `--label` overrides for a run that genuinely needs a human name. |\n| `effort` | |\n| `maxTurns` / `answererTimeout` *(pins)* | The answerer's turn cap and per-attempt timeout, as the arm ran them. `skill:eval-loop` step 6 lists the call budget among the pins to freeze for a whole run, and nothing wrote one, so no two runs could be compared on the setting that decides whether a case got to finish at all. Distinct from `callBudget`, which no harness has ever written. Size the cap from a pilot rather than taking the default: on a 29-case arm at the default 30, completed attempts had a median of 17 turns and a 90th percentile of 26, and the four cases that ran past it were recorded as wrong answers. |\n| `truncated` / `contaminated` | The qids excluded for a HARNESS-owned reason: the cap cut the answer off, or the attempt breached isolation. Both leave the denominator, both are named here rather than only in the console, and either one makes `status` `incomplete`. |\n| `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every \"cost per arm\" quoted before then was the answerer alone. Per-case judge cost is on each `score` event. |\n| `answererCostCopiedFrom` | The run whose attempts `answererCostUsd` was COPIED from, when this run spawned no answerer -- a `--rejudge`, a `--rebuild`, a `--from`. Those copy the source's attempt events verbatim, `cost_usd` included, so the figure is right about the attempts and wrong as this run's spend: one re-judge reported $17.56 of answering that never happened. Null when this run paid, so a total over several run files can skip the copies. |\n| `goldenCheck` | What `verify_goldens.py` said before the run started: `N ok, M drifted, K other finding(s)`, or why it did not run (no truth server on a platform target; `--skip-golden-check`; rebuild). A run that started on a drifted set says so here rather than pretending its verdicts mean something. `run_baseline` calls `verify()` without a model, so the set-name audit (check 5) does not run in-arm; the trailing `N stale entity name(s)` is the in-arm lint's own count, and `entity-name lint not run` says there was no model text to lint against. Without it the field read `0 other finding(s)` for a check that never happened. |\n| `staleEntityNames` | The `expectedEntities` names that appear nowhere in the served model, from the in-arm lint. Each one depresses the run's own recall on every case listing it, so a stale set reads as a retrieval failure until someone checks by hand. `null` when there was no model text to lint against (a platform target), which is a different fact from `[]`. |\n| `status` | `complete`; `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust; or `incomplete` when the arm ran to the end and still cannot report a rate, because `truncated` or `contaminated` is non-empty. `incomplete` is a third state, not a soft `complete`: the arm did everything asked of it and the number is still unearned. It does NOT stop the run being compared: `flip_table.py` scores the cases both arms did, and names the ones this arm left unscored -- an excluded case carries no verdict, so it can never be counted as a flip. |\n| `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package, so an edit to an imported file moves it where a sha of the one `--model-path` does not; `servedRevision` is minted per load, so it identifies a load rather than content and is a poor pin alone. Measured: `publisher.json`'s `version` moves neither, and nothing in Publisher reads it -- it is not in the Package API schema and never returned, so it pins nothing. |\n| `datasetSha` *(pin)* | Content hash of `set.json` + `cases.jsonl`. Deliberately SEPARATE from the model's sha: a golden repair is not a model change, and one pin covering both would make every answer-key fix read as an edit to the model, which is the distinction an A/B rests on. Automatic, so nobody has to remember it; `datasetVersion` stays beside it as the human-readable sequence. **Local targets only.** A hosted target that publishes IMMUTABLE versions needs none of this: the set rides inside the version, and immutability -- not hashing -- is what makes a pin trustworthy. There, `targetVersion` alone identifies model and set together. |\n| `doubtedGoldens` | The cases whose golden is not believed: `qid`, `gold_status` (`suspect` or `verified_wrong`), `gold_note`, and `declaredBy` (`judge` or `set`). A status the SET declared is a key somebody already settled, not an opinion the judge formed this run, and attributing both to the judge claimed a judgement that never happened. **Read this before diagnose.** These are dataset issues, not model failures, and they go through the golden side door in `skill:eval-loop`. Empty list when no key is in doubt from either source. Written from the same scan that prints the end-of-run warning, because a warning that lives only in console text is one scrollback away from sending a modelling agent at a model that is already right. |\n| `mode` / `setName` / `serverVersion` / `traceMode` / `callBudget` | Defined and accepted, **not yet written by any harness** -- kept in the schema for the platform target and the conductor, which need them. (`targetVersion` and `status` were in this list and are both written now.) |\n\n## Events\n\nEvery line in `events.jsonl` is **flat**: `{ \"kind\": ..., <fields> }`, one\nJSON object per line, with an optional `at` ISO timestamp. Case-scoped kinds\n(`attempt`, `tool_call`, `score`) carry `qid`, `sample`, `phase` on the line;\nrun-level kinds do not. (An earlier draft of this document nested fields under\na `payload` with a `caseId`; no writer ever did that, and 23 run directories\nexist in the flat shape, so the flat shape is the contract.) `kind` is one of:\n`attempt`, `tool_call`, `score`, `retrieval_score`, `issue`, `issue_status`,\n`candidate`, `acceptance_check`, `checkpoint`.\n\n### `attempt`\n\n| Field | Type | Notes |\n|---|---|---|\n| `qid` | string | |\n| `sample` | int or null | Which repeat. Required even when null. |\n| `phase` | string | `baseline` / `loop` / `blind_gate` / `canary` / `final`. `phase` lives here, on the attempt, not in run config. |\n| `question_sha` | string | Hash of the exact text the answerer saw, so two runs can be compared on whether they asked the same thing. A case may carry `questionSha`, the seal stamped at conversion, and that wins when present; the ecommerce set writes a 16-char prefix of it, and a set without one left this null. Neither form proves the question was not edited, since both follow the case as it now reads: that comparison is check 6 of `verify_goldens.py`. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |\n| `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |\n| `servedRevision` | string or null | From the package actually queried. |\n| `n_get_context` / `n_execute` / `n_execute_errors` | int | |\n| `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |\n| `mcp_tool_uses` | int or null | The MCP subset: `get_context` plus `execute_query`. Null on a run written before the split. |\n| `reported_calls` | int | MCP calls the answerer claimed. `reported_calls > host_tool_uses` is the under-report floor from `skill:eval-answer`: the answerer cannot have made more calls than the host logged. `validate_run` warns on it, and only for runs that carry `mcp_tool_uses`, because the field meant something narrower before. |\n| `contaminated` | bool or `\"unknown\"` | `\"unknown\"` when no host log exists. Read it with `ledger.is_contaminated`, never for truthiness: runs written before 2026-09-03 carry the strings `\"true\"`/`\"false\"`, and `bool(\"false\")` is True. `ledger.event` now rejects those strings on write; `validate_run` grandfathers them on read as a warning. |\n| `contamination_reasons` | list | Empty when clean. |\n| `input_tokens` / `output_tokens` / `cache_read_tokens` / `cache_write_tokens` | int or null | Answerer token usage, the four counts that reprice a run from its own ledger. Null when the host does not report it. `cost_usd` beside them comes from the CLI's `total_cost_usd`, which prices cache reads and writes at their rates, so it was always cache-inclusive; what was missing was the breakdown that reproduces it. `cache_write_tokens` (`cache_creation_input_tokens` upstream) was never captured before 2026-09-15, and on one analysed run cache writes were 44% of the agent's cost, the single largest line. **Read `input_tokens` with care:** it counts only the tokens after the last cache breakpoint. One run recorded 224 for 24 questions beside 2.4M cache reads; the 224 is not the context volume, and a token total that omits the cache columns is not a size. |\n| `cost_usd` | float or null | Answerer cost for this attempt. |\n| `num_turns` / `wall_seconds` | int, float or null | |\n| `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |\n| `skills_invoked` | list of string | The skills the answerer invoked through the `Skill` tool, deduplicated and sorted, counted from its own transcript. Always a list on a run that recorded it, so an EMPTY list is the finding: the arm granted a manifest and the agent reached for none of it. Absent on runs written before 2026-09-15, which is why every reader takes `or []`. Only explicit `Skill` calls are counted, and only skills the arm granted -- one outside `--answerer-skills` is a contamination breach and lands in `contamination_reasons` instead. What it is for: a manifest names what an arm GRANTED, and comparing two arms' doctrine is meaningless if the doctrine never loaded. |\n| `transcriptPath` | string | The answerer's transcript under `artifacts/`. |\n\nToken counts sit here rather than being derived later because the claim a\nsemantic model makes is about cost as well as correctness -- that a documented\nmodel reaches a good answer in fewer turns and tokens than working from raw\nschema. A ledger that counts calls but not tokens can state half of that.\n\n### `tool_call`\n\nOne event per MCP `get_context` or `execute_query` the attempt made.\n\n| Field | Type | Notes |\n|---|---|---|\n| `tool` | string | `get_context` or `execute_query`. |\n| `traceId` | string or null | `get_context` only; look up in your host's trace store. |\n| `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |\n| `rankedSummary` | object | Copied at capture so evidence survives trace eviction: `entityIds` (identity, in response order), `hits` (one row per entity: `entity_id`, `source`, `relevance`, `matched_targets`, `position`, `position_in_source`), `resultCount`, `docTokens`. **There is no rank.** This field previously documented `ranks` and \"per-target `targets` with within-target ranks\"; no writer ever wrote either, and what `ranks` did hold was `range(1, n+1)` over a list already flattened source-card by source-card -- a position, not a rank, and not even a relevance order, since the response sorts globally and then buckets into cards. Ask relevance questions of `hits[].relevance`. |\n| `rankedSummary.hits[].matched_targets` | list or null | Which of the caller's search targets matched this entity, each with the server's own relevance. **`null` means no attribution was published**, which is not the same as `[]`: the server attaches it to entities only (a source card's attribution is discarded before an entity object exists) and only on the semantic path, because a lexical score is relative to its own query and comparing two of them means nothing. A lexical run therefore cannot say which target found what, and a reader must report that as unavailable rather than as \"never asked\". |\n| `retrieval_mode` | string or null | `get_context` only: the `retrieval` field of the response that answered, `semantic` or `lexical`. Null when the server named none, which means no embedding provider. Recorded per call because the semantic path can fall over partway through a run. |\n| `query` | string or null | `execute_query` only: the Malloy the call sent, so the final query can be chosen by which call the server actually answered. |\n| `modelPath` | string or null | `execute_query` only: the model file the call named. A source does not resolve outside the file that declares it, so re-execution needs this. |\n| `error` | string or null | |\n\nNever persist `execute_query` result rows, givens, or credentials.\n\n`targets` records the request; `rankedSummary` records the response. Without\nboth, a low per-attempt recall has two readings that cannot be told apart: the\nanswerer searched for the wrong thing, or it searched well and retrieval ranked\nthe right entity too low. Those have opposite owners -- `agent-skill` and\n`retrieval` -- so a ledger holding only the response cannot attribute the\nfailure, and any recall computed from it is a blend of answerer behaviour and\nretrieval quality.\n\nThat blend is also why per-attempt recall is **not** comparable across arms: a\nstronger answerer searches better and scores higher retrieval recall without\nretrieval having changed. Use it for attribution within an arm. Comparing\nretrieval itself between engine versions is `eval-retrieval`'s job, with fixed\nterms, and is not something a customer run reports.\n\n### `score`\n\nThe answer judge's verdict for one attempt (protocol in\n`skill:eval-judge`). Every attempt in a scored run gets exactly one.\n\n| Field | Type | Notes |\n|---|---|---|\n| `verdict` | string or null | `match` / `near_match` / `no_match` / `needs_human`; null when the attempt is not scorable. Only `match` and `no_match` are decisions; see below. |\n| `reason` | string | Why, from the judge; for a null verdict, why not scorable, and it is ALWAYS one of these: `not_submitted`, `answerer_truncated`, `contaminated`, `judge_unparseable`, `no_saved_verdict`, `judge_error: <text>`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. A null verdict never arrives without one: a nulling that does not say which of these it was is indistinguishable from the field being dropped on the write path, and was read as exactly that from a real run. The `golden_*` reasons and `answerer_truncated` are decided before the judge is called, so neither an unestablished key nor a cut-off attempt costs a judge call. |\n| `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |\n| `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |\n| `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |\n| `golden_revision` | int | From the case at score time. |\n| `contaminated` | bool or `\"unknown\"` | Copied from the attempt; true or unknown means `verdict: null`. |\n| `must_not_use_hits` | list or null | Entries of `golden.mustNotUse` a script found in the final query. A hit forces `verdict: no_match`. Decided by `check_must_not_use.py`, never by the judge: it is a question about query text. Only a BARE name vetoes; an entry with a connective (`X as ...`, `X through ...`) objects to a use of X rather than to X, and goes to the judge with the prose entries and a path's bare leaf. |\n| `judge_verdict` | string or null | What the judge said when a MECHANICAL override replaced it: a `must_not_use_hits` veto, or contamination voiding the verdict. Null otherwise, so the judge's own agreement rate stays measurable across both. When a case is vetoed AND contaminated this still holds the judge's read, not the veto's `no_match`. |\n| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `gold_status` | string | `verified` / `verified_benign` / `suspect` / `verified_wrong`, and never anything else: the case file's `golden.status` is a DIFFERENT vocabulary that shares only `verified` and `verified_wrong`, and only those two fall back into this field. A `provisional` / `invalid` / `ambiguous` key is reported through the verdict's `reason` (`golden_<status>`) instead, so nothing downstream has to match on two vocabularies in one field. **From the judge**, which scored against the golden as written and reports separately whether it believes it; falls back to the case's standing `golden.status` when the judge does not say, written onto the verdict itself so the run summary and the aggregates see it too. `verified_wrong` excludes the case from run aggregates -- from the judge OR from the case file; assigning the fallback into a copy of the verdict meant a key the set had marked wrong stayed in the aggregates unless the judge independently agreed. `suspect` and `verified_wrong` route to the golden side door as `dataset` issues, never to improve. |\n| `gold_note` | string or null | The judge's evidence for a non-`verified` status: the two values, or the model line against the rubric sentence. Null when `verified`. |\n\nA `golden_*` reason means the ANSWER KEY is not something a verdict can be\nissued against. A golden the set stamps `provisional`, `invalid` or `ambiguous`\nis refused, as is a case with no golden at all and one the set marks\n`verified_wrong`; a golden with NO `status` is unguarded rather than\nunscorable, because sets that predate the field would otherwise score zero of\nzero. A golden holding no value is not refused either: that is how a\n`kind: unanswerable` case and a `kind: criteria` golden are written. This\nmatters most for an imported set, because `skill:eval-import` stamps\n`provisional` on every golden it writes and enforces it -- so a set is\nunscorable by construction until its keys are re-derived, and `run_baseline`\nrefuses to spend at all when every case in a set is in that state.\n\n`not_submitted` means the attempt produced NOTHING to judge: no answer text and\nno query. An attempt that wrote prose without querying **is judged**, and\nagainst a golden that holds a value an answer containing none of it is\n`no_match` (`skill:eval-judge`, `reference/refusal.md` rule 10). Excusing those\nas unscorable dropped a confident refusal on an answerable case out of the pass\nrate, which is the one thing the answerable-sounds-unanswerable cases exist to\nmeasure. A refusal is exempt only where `golden.kind` is `unanswerable`, and\nthere naming the gap is the pass and a confident number is the fail.\n\n`answerer_truncated` means the answerer hit the run's `maxTurns` cap and the\nCLI returned whatever it had, which is usually a sentence stopping mid-thought.\nIt is refused BEFORE the golden gate and before a saved verdict is reused, so\n`--rebuild` over a run made without this re-derives the right ledger from its\ntranscripts. A cap is a harness setting, so a case cut off by one says nothing\nabout whether the model could answer it: measured on one 29-case arm, four\nattempts ended at exactly 31 turns and each was judged as a wrong answer, one\nof them on the text \"Let me build the correct monthly trend query.\"\n\n`contaminated` means the attempt breached isolation (`contamination_reasons`\nnames how) and the verdict is withheld rather than lost. The judge's own read\nmoves to `judge_verdict`. Before this reason was written, a voided verdict kept\nthe judge's prose in `reason` beside `verdict: null`, and a run report\nreasonably concluded the ledger was dropping the field -- it recovered nine\n\"lost\" verdicts by re-parsing the stored judge replies, every one of which had\nbeen voided on purpose.\n\nBoth are HARNESS-owned, not model-owned, and a run containing either reports\n`status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`\n(no established golden) and `judge_unparseable` do not suppress the rate: the\nfirst is a dataset state no arm can fix, and the second already has a retry.\n\nAggregates count decided verdicts only. `match` and `no_match` are the pass and\nthe fail; **`near_match`, `needs_human` and null are none of the above** and stay\nout of acceptance arithmetic. `near_match` is excluded because it means \"defensibly\ndifferent\", and an arguable verdict that moves a pass rate is a measurement\nartefact rather than a result (`skill:eval-judge` rule 7). Report its count:\nit rising is how a set tells you its rubrics are going vague.\n\nThis applies to `score`. On `retrieval_score` below, `near_match` **is** counted\ntowards recall and precision, because an overlapping entity is a genuine\nretrieval success. The two are different questions that share a word.\n\n### `retrieval_score` (written by `eval-retrieval`, not by a customer run)\n\nOne per term judged in a fixed-term replay (run-level; no `caseId`).\n\n| Field | Type | Notes |\n|---|---|---|\n| `intentId` / `term` / `entityType` | string | From the term file. |\n| `in_scope` | bool | Does THIS model version represent the concept? False is a coverage gap, not a retrieval failure. |\n| `judgments` | list | Per returned entity: `entityId`, `rank`, `level` (`match` / `near_match` / `no_match`), `confidence`, `why`. Empty when nothing returned. |\n| `judge_version` / `rubric_sha` | string | |\n| `traceId` | string or null | The `get_context` call judged. |\n\nRun-level metrics fall out by counting:\n\n- `coverage` = in-scope terms / valid terms -- a property of the model and its\n data, reported on its own, never as a retrieval number.\n- `recall` (on in-scope terms) = fraction whose judgments contain a `match`.\n- `precision@N` = `match` judgments / judged, with N stated.\n\n### `issue`\n\n| Field | Type | Notes |\n|---|---|---|\n| `issue_id` | string | Stable across status events. |\n| `qids` | list | Affected cases. |\n| `primary_code` / `contributing_codes` | string / list | From `skill:eval-diagnose`, verbatim. |\n| `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |\n| `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |\n| `severity` / `confidence` | string | |\n| `sufficiency` | string | `sufficient` / `insufficient` / `unknown`. |\n| `traceIds` | list | |\n| `diagnosis` | string | Written before any edit exists. |\n\n### `issue_status`\n\n`issue_id` plus `status`: `open` / `batched` / `fixed` / `rejected` /\n`deferred`. Readers take the latest event for that `issue_id`. Do not invent\na status column.\n\n### `candidate`\n\nWritten by `eval-improve`, for every proposed edit, accepted or not. A\nrejected direction keeps its record.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `files` | Paths the edit touched. |\n| `diffSummary` | One line per file. |\n| `probes` | Query and result for each factual claim. |\n| `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |\n| `goldenSuspect` | Each `{qid, entity, stored, rederived}`: a golden this edit may have invalidated. Reported by the improver, never repaired by it. **Non-empty halts the acceptance check** until adjudicated through the golden side door. |\n| `goldenAudit` | The set's `verify_goldens.py` run against the edited model: `{ran, clean, model, tail}`. Catches drift and rubric-vs-model contradictions only; a golden whose value silently moved is invisible to it, which is why `goldenSuspect` exists alongside. |\n\n### `acceptance_check`\n\nCalled `gate` before 2026-09-03. `ledger.py` reads the old kind as this one, so\nrun directories written earlier still validate and nothing rewrites them.\n\nWritten by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint commit.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `decision` | `accepted` / `rejected`. |\n| `class` | `docs` / `definition` / `retrieval` / `justified`. |\n| `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |\n| `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |\n| `holdoutDelta` | Confident-verdict delta on the holdout slice. |\n| `reason` | Including independent deterministic justification when that is the basis. |\n\n### `checkpoint`\n\nWritten by `eval-loop` after an accepted acceptance check, or when a restore runs. The\nmodel bytes live in git, not in this payload.\n\n| Field | Notes |\n|---|---|\n| `action` | `created` / `restored`. |\n| `label` | |\n| `modelGitSha` | The commit this checkpoint names (create), or the commit restored to. |\n| `issueIds` | Issues the accepted edit closed. Empty on restore. |\n\n## `judge-regressions.jsonl`\n\nAppend a line whenever a human overrules a judge verdict: the case or intent,\nthe judge's verdict, the human's, and why. Re-run this file against the judge\nwhenever `skill:eval-judge` or the judge model changes; a rubric change that\nflips old human-settled verdicts is a judge regression, not new truth."
321495
+ body: "# The eval ledger: files and events\n\nThe ledger is plain files in the model package's git repository. There is no\neval API and no eval database. The conductor (`skill:eval-loop`) reads and\nwrites these files directly; the stages share them as their contract.\n`eval-answer` writes `attempt`, `tool_call`, and `score`. An engine-side\n`eval-retrieval` skill, which does not ship here, writes `retrieval_score` and\n`probe`; both stay in this contract so one validator covers every run\ndirectory.\n`eval-diagnose` writes `issue` and `issue_status`. `eval-improve` writes\n`candidate`. `eval-loop` writes `acceptance_check`, further `issue_status`, and\n`checkpoint`.\n\n**The contract is code: `eval-answer/scripts/ledger.py`.** Every script that\nwrites `run.json` or `events.jsonl` builds its lines through that module, so a\nfield rename that reaches only one writer fails at write time. This document\nis the human-readable rendering; where the two disagree, the module is right\nand this file is the bug. Check any run directory with:\n\n```\npython3 skills/eval-answer/scripts/ledger.py validate <runDir>\n```\n\nErrors are broken identity (missing `run.json`, a missing required field, two\nscores for one attempt); warnings are missing comparison pins (a run without\n`skillsVersion` still measures, but cannot anchor an A/B on that pin) and\ngrandfathered unknown fields on old runs.\n\n## Layout\n\n```\nevals/<set>/\n set.json # set metadata (below)\n cases.jsonl # one case per line\n judge-regressions.jsonl # judge verdicts a human overruled\n runs/<runId>/\n run.json # run config, the attribution pins\n events.jsonl # append-only event lines\n artifacts/ # prediction CSVs, judge outputs, transcripts\n```\n\nThe set directory lives in the SAME git repository as the model it evaluates,\nso a checkpoint (a git commit) pins the model and the ledger together. Never\nplace `evals/` inside the directory tree the answerer's package serves: gold\nin the served tree is a contamination path.\n\nRules that make the ledger trustworthy:\n\n- `events.jsonl` is append-only. Never edit a line. To void one, append a new\n event whose payload marks the old one `voided`. One sanctioned exception: a\n re-runnable stage (diagnose, improve) replaces ITS OWN prior events, keyed by\n issue id, through `ledger.replace_events()` -- so a crash-and-rerun does not\n duplicate issues. A stage never touches another stage's lines.\n- A stage never rewrites another stage's fields.\n- One artifact directory per attempt (`qid` plus `sample`). Never overwrite a\n previous attempt's prediction CSV.\n- End-of-run numbers come from counting event lines (`jq` over\n `events.jsonl`), never from arithmetic recalled in prose.\n\n## `set.json`\n\n| Field | Notes |\n|---|---|\n| `name` | Set name; also the directory name. |\n| `description` | |\n| `datasetVersion` | Integer. Bump on any golden repair or case change. Runs record the version they scored against. |\n| `targetModelPath` | Model path within the package. |\n| `truthPackage` | Name of the package holding the semantics-free sources every golden is re-derived from -- a package NAME, not an object. Without it `verify_goldens.py` re-derives nothing: it reports \"nothing to re-derive against\" and **exits 3**, which a caller must read as unverified rather than as a pass. The checks that need no truth server -- rubric figures, verification axis, stale rubric claims, set names -- still run and still report. `init_truth_package.py` scaffolds the package. |\n| `truthModel` | Model file inside that package. Default `truth.malloy`. |\n| `targetPackage` | Name of the package UNDER TEST. `verify_goldens.py` refuses a \"truth\" server that also serves it, because an answerer on that server can retrieve the raw truth sources beside the model and quietly change what is under test. `run_baseline.py` and `improve.py` pass the package they were given, so this field is the fallback for a direct call; without either the guard cannot fire. |\n| `truthTableRewrite` | Boolean, default false. Rewrites `duckdb.table('data/x.parquet')` refs to bare `x` in canonical queries, for a truth package whose tables are registered rather than read from files. |\n\n## `cases.jsonl`\n\nOne JSON object per line:\n\n| Field | Notes |\n|---|---|\n| `qid` | Stable case id. |\n| `question` | Exact text the answerer will see. |\n| `split` | `dev` or `holdout`. Frozen at import. Diagnose and improve read `dev` only; the acceptance check runs both. |\n| `tags` | list |\n| `state` | `candidate` / `selected` / `excluded`. |\n| `source` | Where the case came from. |\n| `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (artifact under the set directory), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |\n| `golden.kind` | What the key IS. A value (`scalar`, rows), `unanswerable`, or `criteria`: prose with no value, where the author's criteria are the whole key. `criteria` is the one kind that is legitimately `verified` on arrival, because there is no derivation to perform and nothing that can be numerically wrong; an imported key holding a VALUE is `provisional` until re-derived (`skill:eval-import`). **`verify_goldens.py --promote` is the only thing that writes `golden.status`**: it marks a `provisional` golden `verified` when its value re-derived cleanly from the truth package AND a second derivation exists. `--refresh` rewrites a drifted VALUE and never changes a status; `invalid` and `ambiguous` are settled by a person through the golden side door. |\n| `golden.rubric` | **On the golden, not on the case.** The prose the judge is shown as `CASE RUBRIC`, naming what counts as correct and what does not. `run_baseline.py` reads `golden.rubric`; a rubric written at the case's top level is silently not passed, the judge is told `CASE RUBRIC: none`, and it then scores from the golden value and the answer alone -- which reads as a judge that ignores its instructions. |\n| `golden.mustState` | **On the golden, not on the case.** What the answer has to say out loud. Read from `golden.mustState` by the run-package builder. |\n| `goldenRevision` | Integer. Bump on every golden change; `score` events stamp the revision they compared against. |\n| `expectedEntities` | `required`: entity ids (`kind:source:name`) the answer cannot be produced without. `requiredAnyOf`: a list of **groups**, each a list of ids of which any one suffices -- for a case the model can answer through more than one route. Naming only one route scores the other as a retrieval miss and steers the fix to the docs for an entity whose docs were never the problem. `acceptable`: ids that are not noise if returned. An entity counts as delivered when returned under its exact id, under the same type and name on a sibling source, or by name in a returned source's documentation; `score_retrieval.py` records the route per entity as `delivery`. |\n\n## Term files (`eval-retrieval` only)\n\nA customer set has no term file. The engine-side `eval-retrieval` skill keeps\n`intents.jsonl` fixtures -- search term, entity type, a description of what the\nsearcher meant, a validity flag -- beside its own scripts, for evaluating the\nengine against fixed inputs. Their shape and the `retrieval_score` events they\nproduce are documented there and validated here.\n\n## `run.json`\n\nThe attribution pins. Two runs are comparable only when these match where it\nmatters. Two runs with different `target` values are comparable on answer\nverdicts only if the same model version reached both, which is rarely worth\nassuming:\n\nRequired fields are the run's **identity** -- every run ever written carries\nthem. The rest are optional in the schema, but the **comparison pins** are\nwarned about when absent, because a run missing one cannot take part in an A/B\non that axis.\n\n| Field | Notes |\n|---|---|\n| `runId` *(required)* | Directory name. |\n| `target` *(required)* | Which server answered, and how it reached the data: `local`, `local-proxied`, or `platform`. Decides what an improve step is even allowed to do (see `skill:eval-loop`). |\n| `answererModel` *(required)* | |\n| `phase` / `started` *(required)* | |\n| `judgeModel` / `judgeVersion` / `rubricSha` *(pins)* | The judge's model, and the version + content sha of `skill:eval-judge`. |\n| `judgePromptSha` *(pin)* | Content sha of the prompt the judge is SHOWN, which is the harness's own template and not the skill file `rubricSha` covers. The template carries the golden rendering, the evidence blocks and the tie-breaking rules, so two runs whose prompts differed compared as the same judge -- the blind spot that let a golden-rendering defect run through three arms with every test passing, because the tests covered the code paths and not the text the model sees. Absent on runs written before it existed. |\n| `datasetVersion` *(pin)* | From `set.json` at run time. |\n| `modelSha` *(pin)* | Content sha of the `model.malloy` snapshot in the run directory -- the bytes the answerer actually queried. A git sha is not enough: a snapshot host serves a copy, often of a dirty tree no commit names. |\n| `skillsVersion` *(pin)* | HEAD of the checkout the agents' skills were loaded from, dirty-marked (`ledger.skills_git_sha(root)`). The skills are the doctrine the agents load; a run that cannot name their version cannot anchor a skills A/B. |\n| `skillsRoot` / `harnessVersion` | Which checkout supplied the doctrine (`--skills-root`, e.g. a Publisher checkout for the open-source skills; default this one), and this checkout's own HEAD. The eval-* skills always come from the harness checkout, whatever `skillsRoot` says. Two runs whose `skillsRoot` differ are a skills A/B only if their manifests name the same skills. |\n| `diagnoserModel` / `improverModel` | Written by `diagnose.py` / `improve.py` when those stages run, so the run names every LLM that touched it. Absent on a run that was only answered and judged. |\n| `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when that path had uncommitted changes -- fine for a band measurement, not for an A/B pin. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\n| `modelDir` | The package directory `--model-dir` named, when it was: the path the `-dirty` marker on `modelGitSha` was decided over. Absent means the whole repo, where any unrelated untracked file marks the model dirty. |\n| `environment` / `package` / `modelPath` | What was served, and from where. On a platform target `environment` is the organization and `package` is the workspace the MCP URL is scoped to. |\n| `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |\n| `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |\n| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |\n| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |\n| `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |\n| `retrievalCalls` | `{semantic, lexical, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |\n| `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart are lexical by design and an arm started immediately measures two retrievers. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |\n| `coverageReport` | The `check_coverage.py --out` report this run read, when `--coverage` named one: its path, the model version it was stamped with, its `agentModel`, and `decided` of `cases`. Coverage is a read of the MODEL, so it is measured separately and costs a judge call per case; the run does not run it, it consumes it. Its per-case verdict then beats the authored `coverage` label in retrieval attribution, and each `score_retrieval` row records `coverage_source` (`measured`, `authored`, `none`) so nobody has to guess which one charged the failure. Absent when no report was given, which is a different fact from a report that found everything covered. |\n| `label` | `<set>-<phase>-<nn>`, assigned by `run_baseline.py` from the set name, the run's phase and the next free number beside it (`ecommerce-baseline-01`, `ecommerce-baseline-02`, `ecommerce-blind_gate-01`). The A/A pair is two runs of the same phase; the post-edit arms are two runs of `blind_gate`. Hand-typed names do not survive one afternoon of runs -- `base`, `rejudged2`, `r3`, `post1` sort wrongly, group not at all, and cannot be matched to an arm. `--label` overrides for a run that genuinely needs a human name. |\n| `effort` | |\n| `maxTurns` / `answererTimeout` *(pins)* | The answerer's turn cap and per-attempt timeout, as the arm ran them. `skill:eval-loop` step 6 lists the call budget among the pins to freeze for a whole run, and nothing wrote one, so no two runs could be compared on the setting that decides whether a case got to finish at all. Distinct from `callBudget`, which no harness has ever written. Size the cap from a pilot rather than taking the default: on a 29-case arm at the default 30, completed attempts had a median of 17 turns and a 90th percentile of 26, and the four cases that ran past it were recorded as wrong answers. |\n| `truncated` / `contaminated` | The qids excluded for a HARNESS-owned reason: the cap cut the answer off, or the attempt breached isolation. Both leave the denominator, both are named here rather than only in the console, and either one makes `status` `incomplete`. |\n| `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every \"cost per arm\" quoted before then was the answerer alone. Per-case judge cost is on each `score` event. |\n| `answererCostCopiedFrom` | The run whose attempts `answererCostUsd` was COPIED from, when this run spawned no answerer -- a `--rejudge`, a `--rebuild`, a `--from`. Those copy the source's attempt events verbatim, `cost_usd` included, so the figure is right about the attempts and wrong as this run's spend: one re-judge reported $17.56 of answering that never happened. Null when this run paid, so a total over several run files can skip the copies. |\n| `goldenCheck` | What `verify_goldens.py` said before the run started: `N ok, M drifted, K other finding(s)`, or why it did not run (no truth server on a platform target; `--skip-golden-check`; rebuild). A run that started on a drifted set says so here rather than pretending its verdicts mean something. `run_baseline` calls `verify()` without a model, so the set-name audit (check 5) does not run in-arm; the trailing `N stale entity name(s)` is the in-arm lint's own count, and `entity-name lint not run` says there was no model text to lint against. Without it the field read `0 other finding(s)` for a check that never happened. |\n| `staleEntityNames` | The `expectedEntities` names that appear nowhere in the served model, from the in-arm lint. Each one depresses the run's own recall on every case listing it, so a stale set reads as a retrieval failure until someone checks by hand. `null` when there was no model text to lint against (a platform target), which is a different fact from `[]`. |\n| `status` | `complete`; `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust; or `incomplete` when the arm ran to the end and still cannot report a rate, because `truncated` or `contaminated` is non-empty. `incomplete` is a third state, not a soft `complete`: the arm did everything asked of it and the number is still unearned. It does NOT stop the run being compared: `flip_table.py` scores the cases both arms did, and names the ones this arm left unscored -- an excluded case carries no verdict, so it can never be counted as a flip. |\n| `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package, so an edit to an imported file moves it where a sha of the one `--model-path` does not; `servedRevision` is minted per load, so it identifies a load rather than content and is a poor pin alone. Measured: `publisher.json`'s `version` moves neither, and nothing in Publisher reads it -- it is not in the Package API schema and never returned, so it pins nothing. |\n| `datasetSha` *(pin)* | Content hash of `set.json` + `cases.jsonl`. Deliberately SEPARATE from the model's sha: a golden repair is not a model change, and one pin covering both would make every answer-key fix read as an edit to the model, which is the distinction an A/B rests on. Automatic, so nobody has to remember it; `datasetVersion` stays beside it as the human-readable sequence. **Local targets only.** A hosted target that publishes IMMUTABLE versions needs none of this: the set rides inside the version, and immutability -- not hashing -- is what makes a pin trustworthy. There, `targetVersion` alone identifies model and set together. |\n| `doubtedGoldens` | The cases whose golden is not believed: `qid`, `gold_status` (`suspect` or `verified_wrong`), `gold_note`, and `declaredBy` (`judge` or `set`). A status the SET declared is a key somebody already settled, not an opinion the judge formed this run, and attributing both to the judge claimed a judgement that never happened. **Read this before diagnose.** These are dataset issues, not model failures, and they go through the golden side door in `skill:eval-loop`. Empty list when no key is in doubt from either source. Written from the same scan that prints the end-of-run warning, because a warning that lives only in console text is one scrollback away from sending a modelling agent at a model that is already right. |\n| `mode` / `setName` / `serverVersion` / `traceMode` / `callBudget` | Defined and accepted, **not yet written by any harness** -- kept in the schema for the platform target and the conductor, which need them. (`targetVersion` and `status` were in this list and are both written now.) |\n\n## Events\n\nEvery line in `events.jsonl` is **flat**: `{ \"kind\": ..., <fields> }`, one\nJSON object per line, with an optional `at` ISO timestamp. Case-scoped kinds\n(`attempt`, `tool_call`, `score`) carry `qid`, `sample`, `phase` on the line;\nrun-level kinds do not. (An earlier draft of this document nested fields under\na `payload` with a `caseId`; no writer ever did that, and 23 run directories\nexist in the flat shape, so the flat shape is the contract.) `kind` is one of:\n`attempt`, `tool_call`, `score`, `retrieval_score`, `issue`, `issue_status`,\n`candidate`, `acceptance_check`, `checkpoint`.\n\n### `attempt`\n\n| Field | Type | Notes |\n|---|---|---|\n| `qid` | string | |\n| `sample` | int or null | Which repeat. Required even when null. |\n| `phase` | string | `baseline` / `loop` / `blind_gate` / `canary` / `final`. `phase` lives here, on the attempt, not in run config. |\n| `question_sha` | string | Hash of the exact text the answerer saw, so two runs can be compared on whether they asked the same thing. A case may carry `questionSha`, the seal stamped at conversion, and that wins when present; the ecommerce set writes a 16-char prefix of it, and a set without one left this null. Neither form proves the question was not edited, since both follow the case as it now reads: that comparison is check 6 of `verify_goldens.py`. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |\n| `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |\n| `servedRevision` | string or null | From the package actually queried. |\n| `n_get_context` / `n_execute` / `n_execute_errors` | int | |\n| `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |\n| `mcp_tool_uses` | int or null | The MCP subset: `get_context` plus `execute_query`. Null on a run written before the split. |\n| `reported_calls` | int | MCP calls the answerer claimed. `reported_calls > host_tool_uses` is the under-report floor from `skill:eval-answer`: the answerer cannot have made more calls than the host logged. `validate_run` warns on it, and only for runs that carry `mcp_tool_uses`, because the field meant something narrower before. |\n| `contaminated` | bool or `\"unknown\"` | `\"unknown\"` when no host log exists. Read it with `ledger.is_contaminated`, never for truthiness: runs written before 2026-09-03 carry the strings `\"true\"`/`\"false\"`, and `bool(\"false\")` is True. `ledger.event` now rejects those strings on write; `validate_run` grandfathers them on read as a warning. |\n| `contamination_reasons` | list | Empty when clean. |\n| `input_tokens` / `output_tokens` / `cache_read_tokens` / `cache_write_tokens` | int or null | Answerer token usage, the four counts that reprice a run from its own ledger. Null when the host does not report it. `cost_usd` beside them comes from the CLI's `total_cost_usd`, which prices cache reads and writes at their rates, so it was always cache-inclusive; what was missing was the breakdown that reproduces it. `cache_write_tokens` (`cache_creation_input_tokens` upstream) was never captured before 2026-09-15, and on one analysed run cache writes were 44% of the agent's cost, the single largest line. **Read `input_tokens` with care:** it counts only the tokens after the last cache breakpoint. One run recorded 224 for 24 questions beside 2.4M cache reads; the 224 is not the context volume, and a token total that omits the cache columns is not a size. |\n| `cost_usd` | float or null | Answerer cost for this attempt. |\n| `num_turns` / `wall_seconds` | int, float or null | |\n| `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |\n| `skills_invoked` | list of string | The skills the answerer invoked through the `Skill` tool, deduplicated and sorted, counted from its own transcript. Always a list on a run that recorded it, so an EMPTY list is the finding: the arm granted a manifest and the agent reached for none of it. Absent on runs written before 2026-09-15, which is why every reader takes `or []`. Only explicit `Skill` calls are counted, and only skills the arm granted -- one outside `--answerer-skills` is a contamination breach and lands in `contamination_reasons` instead. What it is for: a manifest names what an arm GRANTED, and comparing two arms' doctrine is meaningless if the doctrine never loaded. |\n| `transcriptPath` | string | The answerer's transcript under `artifacts/`. |\n\nToken counts sit here rather than being derived later because the claim a\nsemantic model makes is about cost as well as correctness -- that a documented\nmodel reaches a good answer in fewer turns and tokens than working from raw\nschema. A ledger that counts calls but not tokens can state half of that.\n\n### `tool_call`\n\nOne event per MCP `get_context` or `execute_query` the attempt made.\n\n| Field | Type | Notes |\n|---|---|---|\n| `tool` | string | `get_context` or `execute_query`. |\n| `traceId` | string or null | `get_context` only; look up in your host's trace store. |\n| `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |\n| `rankedSummary` | object | Copied at capture so evidence survives trace eviction: `entityIds` (identity, in response order), `hits` (one row per entity: `entity_id`, `source`, `relevance`, `matched_targets`, `position`, `position_in_source`), `resultCount`, `docTokens`. **There is no rank.** This field previously documented `ranks` and \"per-target `targets` with within-target ranks\"; no writer ever wrote either, and what `ranks` did hold was `range(1, n+1)` over a list already flattened source-card by source-card -- a position, not a rank, and not even a relevance order, since the response sorts globally and then buckets into cards. Ask relevance questions of `hits[].relevance`. |\n| `rankedSummary.hits[].matched_targets` | list or null | Which of the caller's search targets matched this entity, each with the server's own relevance. **`null` means no attribution was published**, which is not the same as `[]`: the server attaches it to entities only (a source card's attribution is discarded before an entity object exists) and only on the semantic path, because a lexical score is relative to its own query and comparing two of them means nothing. A lexical run therefore cannot say which target found what, and a reader must report that as unavailable rather than as \"never asked\". |\n| `retrieval_mode` | string or null | `get_context` only: the `retrieval` field of the response that answered, `semantic` or `lexical`. Null when the server named none, which means no embedding provider. Recorded per call because the semantic path can fall over partway through a run. |\n| `query` | string or null | `execute_query` only: the Malloy the call sent, so the final query can be chosen by which call the server actually answered. |\n| `modelPath` | string or null | `execute_query` only: the model file the call named. A source does not resolve outside the file that declares it, so re-execution needs this. |\n| `error` | string or null | |\n\nNever persist `execute_query` result rows, givens, or credentials.\n\n`targets` records the request; `rankedSummary` records the response. Without\nboth, a low per-attempt recall has two readings that cannot be told apart: the\nanswerer searched for the wrong thing, or it searched well and retrieval ranked\nthe right entity too low. Those have opposite owners -- `agent-skill` and\n`retrieval` -- so a ledger holding only the response cannot attribute the\nfailure, and any recall computed from it is a blend of answerer behaviour and\nretrieval quality.\n\nThat blend is also why per-attempt recall is **not** comparable across arms: a\nstronger answerer searches better and scores higher retrieval recall without\nretrieval having changed. Use it for attribution within an arm. Comparing\nretrieval itself between engine versions is `eval-retrieval`'s job, with fixed\nterms, and is not something a customer run reports.\n\n### `score`\n\nThe answer judge's verdict for one attempt (protocol in\n`skill:eval-judge`). Every attempt in a scored run gets exactly one.\n\n| Field | Type | Notes |\n|---|---|---|\n| `verdict` | string or null | `match` / `near_match` / `no_match` / `needs_human`; null when the attempt is not scorable. Only `match` and `no_match` are decisions; see below. |\n| `reason` | string | Why, from the judge; for a null verdict, why not scorable, and it is ALWAYS one of these: `not_submitted`, `answerer_truncated`, `contaminated`, `judge_unparseable`, `no_saved_verdict`, `judge_error: <text>`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. A null verdict never arrives without one: a nulling that does not say which of these it was is indistinguishable from the field being dropped on the write path, and was read as exactly that from a real run. The `golden_*` reasons and `answerer_truncated` are decided before the judge is called, so neither an unestablished key nor a cut-off attempt costs a judge call. |\n| `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |\n| `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |\n| `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |\n| `golden_revision` | int | From the case at score time. |\n| `contaminated` | bool or `\"unknown\"` | Copied from the attempt; true or unknown means `verdict: null`. |\n| `must_not_use_hits` | list or null | Entries of `golden.mustNotUse` a script found in the final query. A hit forces `verdict: no_match`. Decided by `check_must_not_use.py`, never by the judge: it is a question about query text. Only a BARE name vetoes; an entry with a connective (`X as ...`, `X through ...`) objects to a use of X rather than to X, and goes to the judge with the prose entries and a path's bare leaf. |\n| `judge_verdict` | string or null | What the judge said when a MECHANICAL override replaced it: a `must_not_use_hits` veto, or contamination voiding the verdict. Null otherwise, so the judge's own agreement rate stays measurable across both. When a case is vetoed AND contaminated this still holds the judge's read, not the veto's `no_match`. |\n| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `gold_status` | string | `verified` / `verified_benign` / `suspect` / `verified_wrong`, and never anything else: the case file's `golden.status` is a DIFFERENT vocabulary that shares only `verified` and `verified_wrong`, and only those two fall back into this field. A `provisional` / `invalid` / `ambiguous` key is reported through the verdict's `reason` (`golden_<status>`) instead, so nothing downstream has to match on two vocabularies in one field. **From the judge**, which scored against the golden as written and reports separately whether it believes it; falls back to the case's standing `golden.status` when the judge does not say, written onto the verdict itself so the run summary and the aggregates see it too. `verified_wrong` excludes the case from run aggregates -- from the judge OR from the case file; assigning the fallback into a copy of the verdict meant a key the set had marked wrong stayed in the aggregates unless the judge independently agreed. `suspect` and `verified_wrong` route to the golden side door as `dataset` issues, never to improve. |\n| `gold_note` | string or null | The judge's evidence for a non-`verified` status: the two values, or the model line against the rubric sentence. Null when `verified`. |\n\nA `golden_*` reason means the ANSWER KEY is not something a verdict can be\nissued against. A golden the set stamps `provisional`, `invalid` or `ambiguous`\nis refused, as is a case with no golden at all and one the set marks\n`verified_wrong`; a golden with NO `status` is unguarded rather than\nunscorable, because sets that predate the field would otherwise score zero of\nzero. A golden holding no value is not refused either: that is how a\n`kind: unanswerable` case and a `kind: criteria` golden are written. This\nmatters most for an imported set, because `skill:eval-import` stamps\n`provisional` on every golden it writes and enforces it -- so a set is\nunscorable by construction until its keys are re-derived, and `run_baseline`\nrefuses to spend at all when every case in a set is in that state.\n\n`not_submitted` means the attempt produced NOTHING to judge: no answer text and\nno query. An attempt that wrote prose without querying **is judged**, and\nagainst a golden that holds a value an answer containing none of it is\n`no_match` (`skill:eval-judge`, `reference/refusal.md` rule 10). Excusing those\nas unscorable dropped a confident refusal on an answerable case out of the pass\nrate, which is the one thing the answerable-sounds-unanswerable cases exist to\nmeasure. A refusal is exempt only where `golden.kind` is `unanswerable`, and\nthere naming the gap is the pass and a confident number is the fail.\n\n`answerer_truncated` means the answerer hit the run's `maxTurns` cap and the\nCLI returned whatever it had, which is usually a sentence stopping mid-thought.\nIt is refused BEFORE the golden gate and before a saved verdict is reused, so\n`--rebuild` over a run made without this re-derives the right ledger from its\ntranscripts. A cap is a harness setting, so a case cut off by one says nothing\nabout whether the model could answer it: measured on one 29-case arm, four\nattempts ended at exactly 31 turns and each was judged as a wrong answer, one\nof them on the text \"Let me build the correct monthly trend query.\"\n\n`contaminated` means the attempt breached isolation (`contamination_reasons`\nnames how) and the verdict is withheld rather than lost. The judge's own read\nmoves to `judge_verdict`. Before this reason was written, a voided verdict kept\nthe judge's prose in `reason` beside `verdict: null`, and a run report\nreasonably concluded the ledger was dropping the field -- it recovered nine\n\"lost\" verdicts by re-parsing the stored judge replies, every one of which had\nbeen voided on purpose.\n\nBoth are HARNESS-owned, not model-owned, and a run containing either reports\n`status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`\n(no established golden) and `judge_unparseable` do not suppress the rate: the\nfirst is a dataset state no arm can fix, and the second already has a retry.\n\nAggregates count decided verdicts only. `match` and `no_match` are the pass and\nthe fail; **`near_match`, `needs_human` and null are none of the above** and stay\nout of acceptance arithmetic. `near_match` is excluded because it means \"defensibly\ndifferent\", and an arguable verdict that moves a pass rate is a measurement\nartefact rather than a result (`skill:eval-judge` rule 7). Report its count:\nit rising is how a set tells you its rubrics are going vague.\n\nThis applies to `score`. On `retrieval_score` below, `near_match` **is** counted\ntowards recall and precision, because an overlapping entity is a genuine\nretrieval success. The two are different questions that share a word.\n\n### `retrieval_score` (written by `eval-retrieval`, not by a customer run)\n\nOne per term judged in a fixed-term replay (run-level; no `caseId`).\n\n| Field | Type | Notes |\n|---|---|---|\n| `intentId` / `term` / `entityType` | string | From the term file. |\n| `in_scope` | bool | Does THIS model version represent the concept? False is a coverage gap, not a retrieval failure. |\n| `judgments` | list | Per returned entity: `entityId`, `rank`, `level` (`match` / `near_match` / `no_match`), `confidence`, `why`. Empty when nothing returned. |\n| `judge_version` / `rubric_sha` | string | |\n| `traceId` | string or null | The `get_context` call judged. |\n\nRun-level metrics fall out by counting:\n\n- `coverage` = in-scope terms / valid terms -- a property of the model and its\n data, reported on its own, never as a retrieval number.\n- `recall` (on in-scope terms) = fraction whose judgments contain a `match`.\n- `precision@N` = `match` judgments / judged, with N stated.\n\n### `issue`\n\n| Field | Type | Notes |\n|---|---|---|\n| `issue_id` | string | Stable across status events. |\n| `qids` | list | Affected cases. |\n| `primary_code` / `contributing_codes` | string / list | From `skill:eval-diagnose`, verbatim. An event written before the codes were renamed may carry a retired one: `COVERAGE` is now `MISSING`, `NO-DISAMBIG` is `AMBIGUOUS`, and `CONVENTION` is `RULE_UNWRITTEN`. Read those as the new name; nothing rewrites old events. |\n| `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |\n| `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |\n| `severity` / `confidence` | string | |\n| `sufficiency` | string | `sufficient` / `insufficient` / `unknown`. |\n| `traceIds` | list | |\n| `diagnosis` | string | Written before any edit exists. |\n\n### `issue_status`\n\n`issue_id` plus `status`: `open` / `batched` / `fixed` / `rejected` /\n`deferred`. Readers take the latest event for that `issue_id`. Do not invent\na status column.\n\n### `candidate`\n\nWritten by `eval-improve`, for every proposed edit, accepted or not. A\nrejected direction keeps its record.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `files` | Paths the edit touched. |\n| `diffSummary` | One line per file. |\n| `probes` | Query and result for each factual claim. |\n| `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |\n| `goldenSuspect` | Each `{qid, entity, stored, rederived}`: a golden this edit may have invalidated. Reported by the improver, never repaired by it. **Non-empty halts the acceptance check** until adjudicated through the golden side door. |\n| `goldenAudit` | The set's `verify_goldens.py` run against the edited model: `{ran, clean, model, tail}`. Catches drift and rubric-vs-model contradictions only; a golden whose value silently moved is invisible to it, which is why `goldenSuspect` exists alongside. |\n\n### `acceptance_check`\n\nCalled `gate` before 2026-09-03. `ledger.py` reads the old kind as this one, so\nrun directories written earlier still validate and nothing rewrites them.\n\nWritten by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint commit.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `decision` | `accepted` / `rejected`. |\n| `class` | `docs` / `definition` / `retrieval` / `justified`. |\n| `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |\n| `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |\n| `holdoutDelta` | Confident-verdict delta on the holdout slice. |\n| `reason` | Including independent deterministic justification when that is the basis. |\n\n### `checkpoint`\n\nWritten by `eval-loop` after an accepted acceptance check, or when a restore runs. The\nmodel bytes live in git, not in this payload.\n\n| Field | Notes |\n|---|---|\n| `action` | `created` / `restored`. |\n| `label` | |\n| `modelGitSha` | The commit this checkpoint names (create), or the commit restored to. |\n| `issueIds` | Issues the accepted edit closed. Empty on restore. |\n\n## `judge-regressions.jsonl`\n\nAppend a line whenever a human overrules a judge verdict: the case or intent,\nthe judge's verdict, the human's, and why. Re-run this file against the judge\nwhenever `skill:eval-judge` or the judge model changes; a rubric change that\nflips old human-settled verdicts is a judge regression, not new truth."
319374
321496
  },
319375
321497
  {
319376
321498
  name: "eval-diagnose",
@@ -319575,7 +321697,7 @@ Prior \`score\` events are not rewritten. They keep the old \`golden_revision\`.
319575
321697
  | \`NEVER-ASKED\` | no utterance targeted a needed concept | agent-skill, and model if nothing would have prompted the ask |
319576
321698
  | \`VAGUE\` | compound or generic utterances, so nothing could rank | agent-skill |
319577
321699
  | \`QUESTION-VOCAB\` | utterances parroted the question where the data uses other words | agent-skill, and model if that vocabulary is undocumented |
319578
- | \`NO-DISAMBIG\` | two plausible candidates, never resolved | model: docs should answer, not require the question |
321700
+ | \`RULE_UNWRITTEN\` | the data is present and the model does not encode the rule for combining or filtering it, so whoever answers invents one. Qualified \`(guessable)\` when the data forces the rule -- list price minus sale price -- and \`(arbitrary)\` when it is a business decision nobody could derive, such as a season window or whether revenue is net of tax. Only the stated conventions tell those apart: from the model alone the two look identical, and an unqualified reading defaults to the harmless one | model: write the rule down |
319579
321701
  | \`ASSUMED\` | assumed a scope or convention instead of checking | model if nothing warned; agent-skill otherwise |
319580
321702
  | \`WRONG-TYPE-OR-SCOPE\` | asked, but with the wrong target type or an empty/wrong scope | agent-skill |
319581
321703
 
@@ -319585,10 +321707,10 @@ If the agent could not reasonably have known to ask, that is a model gap.
319585
321707
 
319586
321708
  | Code | When | Owner |
319587
321709
  |---|---|---|
319588
- | \`COVERAGE\` | no representing entity anywhere | model |
321710
+ | \`MISSING\` | no query over this model could produce the concept. Not merely "no named measure": if the parts are present and only the formula is absent, that is \`RULE_UNWRITTEN\` | model: add an entity, a dimension value, or a data source |
319589
321711
  | \`NOT-RETURNED\` | it exists, the ask was on target, it never came back | model: labels, docs, synonyms, index |
319590
321712
  | \`LOW-RANK\` | returned, buried under noise the agent reasonably skipped | model |
319591
- | \`AMBIGUOUS\` | several near-identical candidates | model: "use X for …, Y when …" |
321713
+ | \`AMBIGUOUS\` | several candidates and nothing says which this question means. Judged over the entities AND their docs: a \`#(doc)\` that resolves the choice makes it \`MODELLED\`, which is why a sentence of documentation is usually the whole fix | model: "use X for …, Y when …" |
319592
321714
  | \`GUIDANCE-NOT-RETRIEVED\` | entities came back, governing guidance did not | model: put guidance on the entities agents search for |
319593
321715
  | \`GUIDANCE-DECLINED\` | guidance was retrieved and judged inapplicable | model: state the business default, not a caveat |
319594
321716
 
@@ -319629,7 +321751,7 @@ with both queries. Otherwise it is still \`NOT-RETURNED\` / \`LOW-RANK\`.
319629
321751
  | \`SCOPE\` | right entities, wrong population | model if the scope rule was undocumented |
319630
321752
  | \`GRAIN\` | right entities, wrong grain | model or agent-skill |
319631
321753
  | \`FILTER-LITERAL\` | filter literal did not match stored values | model (document the stored form) and agent-skill |
319632
- | \`CONVENTION\` | right data, wrong statistical or business convention | model: expose a named measure |
321754
+ | \`UNDERSPECIFIED\` | the QUESTION is unclear, not the model -- "adjusted sales" that never says what the adjustment is, or a business that has not decided whether revenue includes tax | the asker, or whoever owns the definition. Never scored against the model |
319633
321755
  | \`SYNTAX\` | could not express it; execute errors; never submitted | agent-skill |
319634
321756
 
319635
321757
  ### model-definition
@@ -319720,7 +321842,7 @@ the case, and one issue event covers all of its cases rather than one per case.
319720
321842
  **Group on shared cause, not shared symptom.** Two cases that both returned a
319721
321843
  wrong revenue number belong together only if the same entity, doc gap, or
319722
321844
  convention explains both. Same \`owner\` and same \`component\` is a hint, never a
319723
- criterion: two \`COVERAGE\` issues about different missing entities are two
321845
+ criterion: two \`MISSING\` issues about different missing entities are two
319724
321846
  clusters, and merging them produces an edit that fixes neither cleanly.
319725
321847
 
319726
321848
  Order clusters by how many cases they would fix. Cluster the non-model owners
@@ -320314,6 +322436,24 @@ usually absent at import, and until it exists no golden can reach \`verified\`;
320314
322436
  \`verify_goldens.py --promote\` is what takes it there afterwards. \`init_truth_package.py\` in
320315
322437
  \`skill:eval-answer\` scaffolds one.
320316
322438
 
322439
+ \`conventions\` is optional: a list of strings, each defining one term the way
322440
+ the business uses it, for a term the question does not define itself. The
322441
+ coverage check (\`check_coverage.py\` in \`skill:eval-answer\`) shows them to its
322442
+ judge as definitions it may not replace with its own reading. End each one with
322443
+ the questions it governs, as an "Applies to questions that ..." sentence:
322444
+
322445
+ \`\`\`json
322446
+ "conventions": [
322447
+ "\\"Summer\\" means 25 May to 15 September inclusive. Applies to questions that ask about summer."
322448
+ ]
322449
+ \`\`\`
322450
+
322451
+ The scope sentence is what stops the judge applying a definition to every
322452
+ question. Without it, one set came back with nine of twelve cases as gaps, and
322453
+ the check warns about any convention that has none. Record only a definition
322454
+ someone gave you. A convention you invent turns your guess into the business's
322455
+ rule, and scores the model against it.
322456
+
320317
322457
  ## Their field names will not be your field names
320318
322458
 
320319
322459
  An arriving JSONL is somebody else's schema. Map it explicitly rather than
@@ -322934,9 +325074,9 @@ Two things stop it:
322934
325074
 
322935
325075
  **Label a rung for what it measures, not for what a reader will assume.**
322936
325076
  "Can the model express an answer?" is wrong for most of what lands on the \`no\`
322937
- side of the first rung. \`CONVENTION\` means the data is present and no named
322938
- measure encodes the statistical or business convention; \`NO-DISAMBIG\` means two
322939
- plausible candidates and no doc saying which the question means. The model can
325077
+ side of the first rung. \`RULE_UNWRITTEN\` means the data is present and the
325078
+ model does not encode the rule for combining or filtering it; \`AMBIGUOUS\` means
325079
+ several candidates and no doc saying which the question means. The model can
322940
325080
  express an answer in both -- it does not say WHICH answer is meant. So the rung
322941
325081
  asks whether the model NAMES what the question needs.
322942
325082
 
@@ -323590,17 +325730,14 @@ Scanned at a glance is a dashboard; read top to bottom is a notebook.
323590
325730
 
323591
325731
  ## Build sequence
323592
325732
 
323593
- 0. **CHECK THE PACKAGE CAN SERVE A DASHBOARD AT ALL.** If its root holds an \`index.malloy\`, that
323594
- file is the package's surface, and a dashboard's file is not something it can \`export\`, so the
323595
- dashboard you are about to write will compile and then be withheld. Every package made by the
323596
- scaffolder has that file, so this is the common case, not the corner one. Serving dashboards
323597
- there means declaring an \`explores\` in \`publisher.json\` naming \`index.malloy\` and every dashboard
323598
- file, which overrides the convention. Settle that first: it is a different curation shape for the
323599
- package, not a line to add at the end. See "Read the lint" for the warning it produces otherwise.
323600
325733
  1. **READ THE MODEL FIRST.** Get the real source, view, dimension, and given names from the package:
323601
- \`get_context\` if you have it, otherwise the REST model endpoint or the \`.malloy\` files.
325734
+ \`get_context\` if you have it, otherwise the REST model endpoint for \`index.malloy\` (it answers
325735
+ 404 for a file off the surface) or the \`.malloy\` files.
323602
325736
  Never guess a name. A guessed field in a query fails the whole package load, not just that one
323603
325737
  dashboard; a guessed tile or suggest source is quieter, and only shows up in the package warnings.
325738
+ If the package root holds an \`index.malloy\` (every scaffolded package does), a tile reads only the
325739
+ sources that file exports. A source the dashboard file declares on top of an exported one works;
325740
+ one on top of a hidden source does not. See "Read the lint" for the warning you get otherwise.
323604
325741
  2. **PICK THE VIEWS TO SHOW.** A dashboard is \`## artifact { tiles=[…] }\` naming existing views, so
323605
325742
  this is the design step: which views, how wide each sits, what each is called. There is one form,
323606
325743
  so there is no form to choose.
@@ -324010,8 +326147,7 @@ Package warnings after a reload are the dashboard's test suite. Fix all of them:
324010
326147
  Findings carry a \`severity\`, but \`warn\` is the ordinary default and tells you nothing about how bad
324011
326148
  one is. Read the text, not the severity and not the count. One message is worth recognising because
324012
326149
  it changes what the rest of the list means: **"Dashboard lint stopped early, so this list is
324013
- incomplete"**. A dashboard withheld from the package's surface also loses its own findings, so a
324014
- short list for a withheld file is not a clean bill of health.
326150
+ incomplete"**.
324015
326151
 
324016
326152
  **Read the status the reload itself returns, not the listing.** One dashboard that fails to compile
324017
326153
  fails the whole package load, and the reload answers **424** with the compile error. A package that
@@ -324024,24 +326160,23 @@ failed, and the entry clears on the next reload that compiles. That is the one c
324024
326160
  the fact, so make it the first thing you run when a page will not change. A package that never loaded
324025
326161
  at all appears there too, without \`stale\`, and is absent from the listing entirely.
324026
326162
 
324027
- If the reload is 200 and the others are listed but yours is not, discovery skipped the file instead,
324028
- usually a missing or misspelled \`# artifact\` tag, which is the same mechanism that deliberately skips
324029
- an untagged shared include. There is a second cause whenever the package has a curated surface: a
324030
- dashboard whose file is off it is withheld rather than served, and the warning says so and names the
324031
- fix. A package has a surface in two ways, and the second is easy to miss because no manifest field
324032
- records it:
324033
-
324034
- - the package's \`publisher.json\` carries an \`explores\` list, and the dashboard's file is not on it;
324035
- - the package root holds an **\`index.malloy\`**, which IS the surface. This bites in both
324036
- directions, and a dashboard's file is not something an \`index.malloy\` exports either way: adding
324037
- that file to a package that has dashboards withholds every one of them, and adding a dashboard to
324038
- a package that already has one (every scaffolded package does) means it is never served. The fix
324039
- is the same in both directions, and it is not an edit to \`index.malloy\`: declare an \`explores\` in
324040
- \`publisher.json\` naming \`index.malloy\` and every dashboard file.
324041
-
324042
- Either way the surface is what matters, not the \`queryableSources\` setting, which is \`declared\` by
324043
- default; a package with neither withholds nothing. Where there is a surface, a \`suggest\` source has
324044
- to be queryable as well as resolvable, so it needs to be reachable through it too.
326163
+ If the reload is 200 and the others are listed but yours is not, discovery skipped the file,
326164
+ usually a missing or misspelled \`# artifact\` tag. That is the same mechanism that deliberately skips
326165
+ an untagged shared include, and it is also how you hide a dashboard on purpose: drop its tag. The
326166
+ package surface never hides a dashboard. Every tagged dashboard is listed and served.
326167
+
326168
+ **A tile over a hidden source answers 404.** When the package has an \`index.malloy\`, tiles, a
326169
+ single-query dashboard's query, and filter \`suggest\` lists read only the sources it exports. The
326170
+ dashboard file admits nothing of its own, so importing a source into it is not enough. The package
326171
+ load warns once per tile that will fail, for example:
326172
+
326173
+ \`\`\`
326174
+ Tile orders_staging -> by_flag on dashboard overview reads orders_staging, which index.malloy doesn't export, so it won't load. Fix: add orders_staging to the export { ... } in index.malloy.
326175
+ \`\`\`
326176
+
326177
+ The fix is the one the warning names: add the source to the \`export { ... }\` in \`index.malloy\`. A
326178
+ \`suggest\` over a hidden source gets the same warning, ending "so its list will be empty". Do not
326179
+ add an \`explores\` to \`publisher.json\` to serve a dashboard; it is deprecated and no longer needed.
324045
326180
 
324046
326181
  **A clean reload is not proof the tags are right.** The checks above read names and resolve them; the
324047
326182
  separate warning for a tag that does not _parse_ is syntax only: it carries no
@@ -325727,12 +327862,11 @@ For date-range filters, declare two filters with distinct \`name\` values target
325727
327862
 
325728
327863
  ### When to use \`required\`
325729
327864
 
325730
- \`required\` filters are a correctness, latency, and governance mechanism, not just UX. Mark a filter \`required\` when:
327865
+ \`required\` filters are a correctness and latency mechanism, not just UX. Mark a filter \`required\` when:
325731
327866
 
325732
327867
  1. **Modeling correctness, the source's \`primary_key:\` is only unique under a filter.** If a high-cardinality key is not unique across the whole table but is unique within a scoping dimension, then that scoping dimension MUST be supplied for symmetric aggregation to produce correct numbers. For example, if \`events.id\` repeats across days but is unique within a single \`event_date\`, queries that don't pin the date can fan out and return hash-collision-sized garbage (~10²¹). Declare \`#(filter) name=Event_Date dimension=event_date type=equal required\` so the server refuses queries that don't provide it.
325733
327868
  2. **Query latency, the source spans more data than any single query should scan.** A multi-year, multi-region table where every reasonable analysis is scoped to a date range or region: making the date filter required prevents accidental full-table scans.
325734
327869
  3. **Partial views** that are only meaningful inside a date range, region, or business segment.
325735
- 4. **Governance**, an analyst should never query the raw source without a scoping filter applied.
325736
327870
 
325737
327871
  For (1), pair the required filter with a comment explaining the cardinality dependency, and consider also declaring \`#(doc)\` on the source noting the constraint.
325738
327872
 
@@ -325746,7 +327880,7 @@ Publisher formats values based on the dimension's data type, \`string\` → \`'v
325746
327880
 
325747
327881
  ### Bypass
325748
327882
 
325749
- Pass \`bypass_filters=true\` (REST) or \`bypassFilters: true\` (POST body) to skip filter injection entirely. Use sparingly, required-filter governance only works if bypass is restricted to trusted callers.
327883
+ Pass \`bypass_filters=true\` (REST) or \`bypassFilters: true\` (POST body) to skip filter injection entirely. \`#(filter)\` is not a security boundary against caller-authored query text or \`bypassFilters\`; use givens + \`#(authorize)\`.
325750
327884
 
325751
327885
  ## Access Control: \`#(authorize)\` and \`#(access_filter)\`
325752
327886
 
@@ -326466,7 +328600,7 @@ Steps to follow when the user asks a question:
326466
328600
  {
326467
328601
  name: "malloy-notebooks",
326468
328602
  description: 'Create Malloy notebooks (.malloynb) for interactive dashboards and data stories. Use when user asks to "create a notebook", "make a dashboard notebook", "write a malloynb", "data story", or needs to build reports/visualizations.',
326469
- body: "# Malloy Notebooks\n\nFiles: `.malloynb`\n\n## Scope: when to use this skill vs `skill:malloy-analysis-report`\n\nThis skill is for **curated `.malloynb` notebooks**. Interactive parameter widgets render automatically from the `given:` declarations on the models it imports (or, in older models, from deprecated `#(filter)` annotations). The notebook itself never declares parameters or filters.\n\nFor **ad-hoc reports** generated by an analysis agent, combining queries into a quick narrative artifact, use `skill:malloy-analysis-report` instead. Ad-hoc reports also inherit whatever the source declares.\n\n## Important Limitations\n\n**Compile errors in `.malloynb` files are NOT shown in the IDE linter.** You will only see errors when cells are executed. Be extra careful with syntax and test queries in the model first if unsure.\n\n## Multiple Models & Cross-Model Joins\n\nA published `.malloynb` runs each `run:` cell against the whole notebook, which compiles with **all** its `import`s in scope. So a notebook can import several `.malloy` models, and a single cell can reference, and **join**, sources from different imported files. Import each model you need in a setup cell at the top; no \"re-export\" wrapper model is required.\n\n```\n>>>malloy\nimport \"flights.malloy\"\nimport \"carriers.malloy\"\n\n>>>malloy\n// joins `flights` (from flights.malloy) to `carriers` (from carriers.malloy)\nrun: flights extend {\n join_one: carriers on carrier = carriers.code\n} -> {\n group_by: carriers.nickname\n aggregate: flight_count\n}\n```\n\nTwo things still hold:\n\n- **Imports are notebook-wide, declare them at the top, never inside a `run:` cell.** A cell's query runs as a *restricted query* against the already-compiled notebook, so its imports must already be in place. You can't `import` a model inside a query cell to scope it there; an in-query import is rejected (`file imports are not permitted in a restricted query`).\n- **Compile errors surface only at publish/render, not in the IDE linter** (see above). Verify a multi-model notebook by publishing to a scratch environment and loading it, rather than relying only on single-model query checks.\n\n## No Data-Specific Insights in Markdown\n\n**Notebooks must NOT contain findings about current data values.** Data refreshes will make these stale. Use markdown for framing questions and structural narrative, not for stating results.\n\n```\n>>>markdown\n## WRONG: will become stale when data refreshes\nRevenue spiked 23% in March. Electronics drove 60% of the spike.\n\n>>>markdown\n## RIGHT: frames the question, lets the query answer it\nHow is revenue trending? Which categories are driving changes?\n```\n\n## Cell Syntax\n\nCells delimited by `>>>markdown` or `>>>malloy`:\n\n```\n>>>markdown\n# Sales Analysis\nOverview of sales trends.\n\n>>>malloy\nimport \"sales_model.malloy\"\n\n>>>markdown\n## Summary\n\n>>>malloy\nrun: orders -> summary\n\n>>>markdown\n## Monthly Trend\n\n>>>malloy\n# line_chart\nrun: orders -> by_month\n```\n\n**NEVER create `>>>malloysql` cells.** Only use `>>>malloy` and `>>>markdown`.\n\n## Interactive Parameters (`given:`)\n\nInteractive parameters are common and important. Add them to most notebooks. **Use `given:` for anything new.** The older `#(filter)` mechanism still works and is documented below, but it is deprecated: do not reach for it just because you saw it in an existing model.\n\nA notebook's parameter surface comes from the `given:` declarations on the models it imports, not from anything declared in the `.malloynb` itself. When an imported model declares givens, Publisher's notebook UI automatically renders a Parameters panel above the notebook: declared givens become parameter inputs, the values a user sets are forwarded to Malloy's runtime, and every cell re-executes against the model with those values applied.\n\nThe widget for each parameter follows the given's declared Malloy type (`string`, `string[]`, `number`, `boolean`, `date`/`timestamp`, `filter<T>`); see `docs/givens.md` for the full type table. A `#(description=\"...\")` annotation on the given renders as helper text under its input.\n\n```malloy\n##! experimental.givens\n\n#(description=\"Two-letter IATA carrier code to spotlight\")\ngiven: carrier :: string is 'WN'\n\nsource: spotlight_carrier is duckdb.table('data/carriers.parquet') extend {\n view: by_name is {\n where: code = $carrier\n select: code, name, nickname\n limit: 1\n }\n}\n```\n\nDeclare givens at the **top of the model file**, before the source that uses them, not in the notebook. `malloy-model` § Access Control covers the syntax and the `#(authorize)`/`#(access_filter)` gating story built on top of givens.\n\n## Legacy: `#(filter)` annotations\n\n> **Deprecated. Read this before copying the example below.** `#(filter)` is server-side `where:` injection, superseded by native Malloy `given:` above. Do NOT add new `#(filter)` annotations; declare runtime parameters as givens instead. `docs/givens.md` has the migration recipes. What follows is here so you can read and maintain models that already use it.\n\nFilters are declared **once, on the model's source**. The notebook itself does not declare or list filters. Publisher reads the source's filter metadata and renders the widgets above the notebook automatically, then injects `where:` clauses server-side on every cell.\n\nDeclarations live in the `.malloy` model file, above the source, using key=value parameters. The full reference (syntax, filter types, `required` / `implicit` flags) lives with the `#(filter)` guidance in the `malloy-model` skill's Parameterizable Filters section. Short example:\n\n```malloy\n#(filter) name=Manufacturer dimension=Manufacturer type=in\n#(filter) name=OrderStatus dimension=order_status type=in\n#(filter) name=Recall_After dimension=\"Report Received Date\" type=greater_than\n#(filter) name=Recall_Before dimension=\"Report Received Date\" type=less_than\nsource: recalls is duckdb.table('data/auto_recalls.csv') extend {\n measure:\n recall_count is count()\n}\n```\n\n`#(filter)` lines go **above** the `source:` declaration and reference dimensions by name (use `dimension=`). `name` defaults to the dimension name and only needs to be set when two filters target the same dimension (e.g. a date range). `type` is the comparator: `equal`, `in`, `like`, `greater_than`, `less_than`.\n\n## Notebook Style: Iterative Analysis (Default)\n\n**Build a story.** Each query should reveal something that motivates the next query. Start broad, then drill into what's interesting. Frame questions in markdown, let queries answer them.\n\n**Pattern:** Question, Query, Next Question, Drill\n\n```\n>>>markdown\n## How is revenue trending?\n\n>>>malloy\n# line_chart\nrun: orders -> { group_by: order_month; aggregate: revenue }\n\n>>>markdown\n## What's driving the biggest changes?\n\n>>>malloy\n# bar_chart\nrun: orders -> { group_by: category; aggregate: revenue; order_by: revenue desc; limit: 10 }\n\n>>>markdown\n## How does the top category break down?\n\n>>>malloy\nrun: orders -> { aggregate: order_count, avg_order_value; where: category = 'Electronics' }\n```\n\n**When to use other styles:**\n- **Dashboard with filters:** User asks for \"filterable dashboard\". Show key KPIs and breakdowns with interactive filters.\n- **EDA/summary:** User asks for \"overview\". Run views like `summary`, `by_month`, `by_category` in sequence.\n\n## View Refinement\n\nTo add `limit`, `where`, or other options to an existing view, use `+`:\n\n```malloy\nrun: source -> my_view + { limit: 15 }\nrun: source -> my_view + { where: status = 'active', limit: 10 }\n```\n\n## Template\n\n```\n>>>markdown\n# [Title]\n[Framing question: what are we trying to understand?]\n\n>>>malloy\nimport \"model.malloy\"\n\n>>>markdown\n## [First question: start broad]\n\n>>>malloy\nrun: main_source -> [broad query]\n\n>>>markdown\n## [Next question: motivated by what the first query reveals]\n\n>>>malloy\nrun: main_source -> [drill into finding]\n\n>>>markdown\n## [Deeper question]\n\n>>>malloy\nrun: main_source -> [further breakdown]\n```\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| `view_name { limit: 10 }` | Use `+`: `view_name + { limit: 10 }` |\n| `# currency` on non-money | Only use `# currency` for monetary values |\n| `#(filter) {\"type\": \"Star\"}` (JSON-blob form, on a dimension) | Unsupported legacy syntax. `#(filter)` goes **above the source** with key=value parameters (`name=`, `dimension=`, `type=`). See the `malloy-model` skill's Parameterizable Filters section. |\n| `##(filters) [...]` annotation in the notebook | Unsupported. The notebook does not declare or list filters. Publisher renders widgets automatically from the source's `#(filter)` annotations. |\n| Creating MalloySQL cells | **NEVER use `>>>malloysql`**, only `>>>malloy` and `>>>markdown` |\n| Data-specific insights in markdown | Don't write \"Revenue grew 23%.\" Frame questions instead. Data refreshes will make findings stale. |\n| `import` inside a `run:` cell (to scope a model to that cell) | Imports are notebook-wide, put them in a setup cell at the top. An in-query import is rejected: `file imports are not permitted in a restricted query` (see Multiple Models & Cross-Model Joins). |\n| Cross-model join inside a single ad-hoc report cell | A `skill:malloy-analysis-report` cell renders against a single model, so a cross-model join there won't render. Build a published `.malloynb` (which runs against the whole notebook) for a cross-model join. |\n\n## Best Practices\n\n1. Start with markdown framing the question\n2. Import each model the notebook needs in a setup cell at the top, cells can reference and join sources across all imports (see Multiple Models & Cross-Model Joins); verify a multi-model notebook by publishing to a scratch environment\n3. Each query should follow from what the previous one could reveal\n4. One query per cell\n5. Charts render only the FIRST aggregate\n6. Add interactive filters to most notebooks by declaring `#(filter)` on the model's source (see Interactive Filters), or prefer `given:` runtime parameters for new models (see Runtime Parameters)\n7. Never include data-specific findings in markdown. Frame questions, let queries answer\n\n## Done\n\nStep complete. Output: `.malloynb` notebook file."
328603
+ body: "# Malloy Notebooks\n\nFiles: `.malloynb`\n\n## Scope: when to use this skill vs `skill:malloy-analysis-report`\n\nThis skill is for **curated `.malloynb` notebooks**. Interactive parameter widgets render automatically from the `given:` declarations on the models it imports (or, in older models, from deprecated `#(filter)` annotations). The notebook itself never declares parameters or filters.\n\nFor **ad-hoc reports** generated by an analysis agent, combining queries into a quick narrative artifact, use `skill:malloy-analysis-report` instead. Ad-hoc reports also inherit whatever the source declares.\n\n## Important Limitations\n\n**Compile errors in `.malloynb` files are NOT shown in the IDE linter.** You will only see errors when cells are executed. Be extra careful with syntax and test queries in the model first if unsure.\n\n## Multiple Models & Cross-Model Joins\n\nA published `.malloynb` runs each `run:` cell against the whole notebook, which compiles with **all** its `import`s in scope. So a notebook can import several `.malloy` models, and a single cell can reference, and **join**, sources from different imported files. Import each model you need in a setup cell at the top; no \"re-export\" wrapper model is required.\n\n**When the package root holds an `index.malloy`, cells read only what it exports.** A cell over a source `index.malloy` does not export answers 404, even when the notebook imports that source's file. A source an earlier cell derives from an exported one (`source: mine is customers extend { ... }`) still works. A cell's own source over a raw table (`duckdb.table(...)`) is refused. To use a source in a notebook, add it to the `export { ... }` in `index.malloy`. A package with no `index.malloy` and no legacy `explores` has no such limit.\n\n```\n>>>malloy\nimport \"flights.malloy\"\nimport \"carriers.malloy\"\n\n>>>malloy\n// joins `flights` (from flights.malloy) to `carriers` (from carriers.malloy)\nrun: flights extend {\n join_one: carriers on carrier = carriers.code\n} -> {\n group_by: carriers.nickname\n aggregate: flight_count\n}\n```\n\nTwo things still hold:\n\n- **Imports are notebook-wide, declare them at the top, never inside a `run:` cell.** A cell's query runs as a *restricted query* against the already-compiled notebook, so its imports must already be in place. You can't `import` a model inside a query cell to scope it there; an in-query import is rejected (`file imports are not permitted in a restricted query`).\n- **Compile errors surface only at publish/render, not in the IDE linter** (see above). Verify a multi-model notebook by publishing to a scratch environment and loading it, rather than relying only on single-model query checks.\n\n## No Data-Specific Insights in Markdown\n\n**Notebooks must NOT contain findings about current data values.** Data refreshes will make these stale. Use markdown for framing questions and structural narrative, not for stating results.\n\n```\n>>>markdown\n## WRONG: will become stale when data refreshes\nRevenue spiked 23% in March. Electronics drove 60% of the spike.\n\n>>>markdown\n## RIGHT: frames the question, lets the query answer it\nHow is revenue trending? Which categories are driving changes?\n```\n\n## Cell Syntax\n\nCells delimited by `>>>markdown` or `>>>malloy`:\n\n```\n>>>markdown\n# Sales Analysis\nOverview of sales trends.\n\n>>>malloy\nimport \"sales_model.malloy\"\n\n>>>markdown\n## Summary\n\n>>>malloy\nrun: orders -> summary\n\n>>>markdown\n## Monthly Trend\n\n>>>malloy\n# line_chart\nrun: orders -> by_month\n```\n\n**NEVER create `>>>malloysql` cells.** Only use `>>>malloy` and `>>>markdown`.\n\n## Interactive Parameters (`given:`)\n\nInteractive parameters are common and important. Add them to most notebooks. **Use `given:` for anything new.** The older `#(filter)` mechanism still works and is documented below, but it is deprecated: do not reach for it just because you saw it in an existing model.\n\nA notebook's parameter surface comes from the `given:` declarations on the models it imports, not from anything declared in the `.malloynb` itself. When an imported model declares givens, Publisher's notebook UI automatically renders a Parameters panel above the notebook: declared givens become parameter inputs, the values a user sets are forwarded to Malloy's runtime, and every cell re-executes against the model with those values applied.\n\nThe widget for each parameter follows the given's declared Malloy type (`string`, `string[]`, `number`, `boolean`, `date`/`timestamp`, `filter<T>`); see `docs/givens.md` for the full type table. A `#(description=\"...\")` annotation on the given renders as helper text under its input.\n\n```malloy\n##! experimental.givens\n\n#(description=\"Two-letter IATA carrier code to spotlight\")\ngiven: carrier :: string is 'WN'\n\nsource: spotlight_carrier is duckdb.table('data/carriers.parquet') extend {\n view: by_name is {\n where: code = $carrier\n select: code, name, nickname\n limit: 1\n }\n}\n```\n\nDeclare givens at the **top of the model file**, before the source that uses them, not in the notebook. `malloy-model` § Access Control covers the syntax and the `#(authorize)`/`#(access_filter)` gating story built on top of givens.\n\n## Legacy: `#(filter)` annotations\n\n> **Deprecated. Read this before copying the example below.** `#(filter)` is server-side `where:` injection, superseded by native Malloy `given:` above. Do NOT add new `#(filter)` annotations; declare runtime parameters as givens instead. `docs/givens.md` has the migration recipes. What follows is here so you can read and maintain models that already use it.\n\nFilters are declared **once, on the model's source**. The notebook itself does not declare or list filters. Publisher reads the source's filter metadata and renders the widgets above the notebook automatically, then injects `where:` clauses server-side on every cell.\n\nDeclarations live in the `.malloy` model file, above the source, using key=value parameters. The full reference (syntax, filter types, `required` / `implicit` flags) lives with the `#(filter)` guidance in the `malloy-model` skill's Parameterizable Filters section. Short example:\n\n```malloy\n#(filter) name=Manufacturer dimension=Manufacturer type=in\n#(filter) name=OrderStatus dimension=order_status type=in\n#(filter) name=Recall_After dimension=\"Report Received Date\" type=greater_than\n#(filter) name=Recall_Before dimension=\"Report Received Date\" type=less_than\nsource: recalls is duckdb.table('data/auto_recalls.csv') extend {\n measure:\n recall_count is count()\n}\n```\n\n`#(filter)` lines go **above** the `source:` declaration and reference dimensions by name (use `dimension=`). `name` defaults to the dimension name and only needs to be set when two filters target the same dimension (e.g. a date range). `type` is the comparator: `equal`, `in`, `like`, `greater_than`, `less_than`.\n\n## Notebook Style: Iterative Analysis (Default)\n\n**Build a story.** Each query should reveal something that motivates the next query. Start broad, then drill into what's interesting. Frame questions in markdown, let queries answer them.\n\n**Pattern:** Question, Query, Next Question, Drill\n\n```\n>>>markdown\n## How is revenue trending?\n\n>>>malloy\n# line_chart\nrun: orders -> { group_by: order_month; aggregate: revenue }\n\n>>>markdown\n## What's driving the biggest changes?\n\n>>>malloy\n# bar_chart\nrun: orders -> { group_by: category; aggregate: revenue; order_by: revenue desc; limit: 10 }\n\n>>>markdown\n## How does the top category break down?\n\n>>>malloy\nrun: orders -> { aggregate: order_count, avg_order_value; where: category = 'Electronics' }\n```\n\n**When to use other styles:**\n- **Dashboard with filters:** User asks for \"filterable dashboard\". Show key KPIs and breakdowns with interactive filters.\n- **EDA/summary:** User asks for \"overview\". Run views like `summary`, `by_month`, `by_category` in sequence.\n\n## View Refinement\n\nTo add `limit`, `where`, or other options to an existing view, use `+`:\n\n```malloy\nrun: source -> my_view + { limit: 15 }\nrun: source -> my_view + { where: status = 'active', limit: 10 }\n```\n\n## Template\n\n```\n>>>markdown\n# [Title]\n[Framing question: what are we trying to understand?]\n\n>>>malloy\nimport \"model.malloy\"\n\n>>>markdown\n## [First question: start broad]\n\n>>>malloy\nrun: main_source -> [broad query]\n\n>>>markdown\n## [Next question: motivated by what the first query reveals]\n\n>>>malloy\nrun: main_source -> [drill into finding]\n\n>>>markdown\n## [Deeper question]\n\n>>>malloy\nrun: main_source -> [further breakdown]\n```\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| `view_name { limit: 10 }` | Use `+`: `view_name + { limit: 10 }` |\n| `# currency` on non-money | Only use `# currency` for monetary values |\n| `#(filter) {\"type\": \"Star\"}` (JSON-blob form, on a dimension) | Unsupported legacy syntax. `#(filter)` goes **above the source** with key=value parameters (`name=`, `dimension=`, `type=`). See the `malloy-model` skill's Parameterizable Filters section. |\n| `##(filters) [...]` annotation in the notebook | Unsupported. The notebook does not declare or list filters. Publisher renders widgets automatically from the source's `#(filter)` annotations. |\n| Creating MalloySQL cells | **NEVER use `>>>malloysql`**, only `>>>malloy` and `>>>markdown` |\n| Data-specific insights in markdown | Don't write \"Revenue grew 23%.\" Frame questions instead. Data refreshes will make findings stale. |\n| `import` inside a `run:` cell (to scope a model to that cell) | Imports are notebook-wide, put them in a setup cell at the top. An in-query import is rejected: `file imports are not permitted in a restricted query` (see Multiple Models & Cross-Model Joins). |\n| Cross-model join inside a single ad-hoc report cell | A `skill:malloy-analysis-report` cell renders against a single model, so a cross-model join there won't render. Build a published `.malloynb` (which runs against the whole notebook) for a cross-model join. |\n\n## Best Practices\n\n1. Start with markdown framing the question\n2. Import each model the notebook needs in a setup cell at the top, cells can reference and join sources across all imports (see Multiple Models & Cross-Model Joins); verify a multi-model notebook by publishing to a scratch environment\n3. Each query should follow from what the previous one could reveal\n4. One query per cell\n5. Charts render only the FIRST aggregate\n6. Add interactive filters to most notebooks by declaring `#(filter)` on the model's source (see Interactive Filters), or prefer `given:` runtime parameters for new models (see Runtime Parameters)\n7. Never include data-specific findings in markdown. Frame questions, let queries answer\n\n## Done\n\nStep complete. Output: `.malloynb` notebook file."
326470
328604
  },
326471
328605
  {
326472
328606
  name: "malloy-patterns",
@@ -326541,7 +328675,7 @@ import "staging.malloy"
326541
328675
  export { orders, customers }
326542
328676
  \`\`\`
326543
328677
 
326544
- What it exports is what agents discover **and** what may be queried. Everything else still compiles, and other models can import, join and extend it, but a direct query against it is refused with a 404 - indistinguishable from a source that does not exist. Reach for this when you have raw/staging/scaffolding sources that exist to build a curated entry point and you don't want agents landing on, or querying, them directly.
328678
+ What it exports is what agents discover **and** what may be queried. Everything else still compiles, and other models can import, join and extend it, but a direct query against it is refused with a 404. Where nothing in the model is gated, the 404 says the source is off the surface and how to publish it; where a gate is in play, it reads exactly like a source that does not exist. Reach for this when you have raw/staging/scaffolding sources that exist to build a curated entry point and you don't want agents landing on, or querying, them directly.
326545
328679
 
326546
328680
  **Address queries to the surface.** Once a package has an \`index.malloy\`, \`.../models/staging.malloy/query\` is no longer a query entry point, *even for a source that file declares itself*. Use \`.../models/index.malloy/query\`. If you are debugging a refusal rather than authoring, \`skill:malloy-source-unreachable\` covers the three ways a source can be out of reach and how to tell them apart.
326547
328681
 
@@ -326555,19 +328689,20 @@ What it exports is what agents discover **and** what may be queried. Everything
326555
328689
 
326556
328690
  ### The older manifest fields
326557
328691
 
326558
- Both still work and are not going away in this release. Both are deprecated where \`index.malloy\` replaces them, and a package using them that way gets a load-time warning naming the replacement. The two uses it cannot replace, an \`explores\` naming several files and \`queryableSources: "all"\`, stay supported without a warning. Do not add either key to a new package that does not need one of those two.
328692
+ \`publisher.json\` has two older keys for this, \`explores\` and \`queryableSources\`. A new package uses neither: \`index.malloy\` does the job.
326559
328693
 
326560
- \`\`\`json
326561
- {
326562
- "name": "ecommerce",
326563
- "explores": ["order_analysis.malloy", "customer_health.malloy"],
326564
- "queryableSources": "all"
326565
- }
326566
- \`\`\`
328694
+ - **\`explores\`** (\`string[]\`) is deprecated in every form. The files it lists are listed and queryable, and what they export is the surface, wherever they live. The one exception is a tagged dashboard it lists, which reads the surface and adds nothing to it. A package that sets it gets a load-time warning naming the edit that replaces it. A surface spanning several files needs no \`explores\`: import them all into one \`index.malloy\` and export what you publish.
326567
328695
 
326568
- - **\`explores\`** (\`string[]\`) - model file paths relative to the package root, naming the surface. Reach for it for the one thing \`index.malloy\` cannot express: a surface spanning **several** files. An explicit \`explores\` always wins over the convention, and a package with both an \`index.malloy\` and an \`explores\` that omits it carries a warning saying so. An entry that doesn't resolve to a real \`.malloy\` file surfaces in \`exploresWarnings\`; publishing a package that has any is rejected, so fix the path before publishing. An explicit \`"explores": []\` means "do not curate" and suppresses the convention.
326569
- - **\`queryableSources\`** (\`"declared"\` | \`"all"\`, default \`"declared"\`) - the query boundary. \`"declared"\` is already the default, so the key changes nothing. **\`"all"\` is the exception, and \`index.malloy\` does not replace it:** it curates listings while leaving every source queryable by name, and a surface derived from an \`index.malloy\` always enforces the boundary. If you want listings-only curation, keep both keys.
328696
+ \`\`\`malloy
328697
+ // index.malloy
328698
+ import "order_analysis.malloy"
328699
+ import "customer_health.malloy"
326570
328700
 
328701
+ export { orders, customers, customer_health }
328702
+ \`\`\`
328703
+
328704
+ \`"explores": []\` is deprecated too. It used to mean "do not curate". To publish everything now, rename or remove \`index.malloy\`, and point any file that imports it at the new name first: a broken import fails the whole package. An entry that doesn't resolve to a real \`.malloy\` file surfaces in \`exploresWarnings\`, and publishing a package that has any is rejected. When \`explores\` is set and does not list \`index.malloy\`, the index file is ignored, and a warning says so.
328705
+ - **\`queryableSources\`** (\`"declared"\` | \`"all"\`) is deprecated too. \`"declared"\` is the default, so setting it does nothing and draws a warning. **\`"all"\` has one use:** hiding an \`#(authorize)\`-gated source from listings while authorized callers still query it by name. It keeps the listings \`index.malloy\` curates and leaves every source queryable by name. It needs no \`explores\` beside it and draws no warning. Leave it out unless you have that case.
326571
328706
 
326572
328707
  > **Not access control.** The surface gates the query surface (the query endpoints, REST and MCP alike), not compile and not raw file retrieval by exact path: \`/compile\` and \`compile_model\` are deliberately exempt, because compile is the authoring loop and the boundary is discovery curation. It doesn't restrict *who* may query, only *what* is queryable by name. Queryable sources are the union of every listed file's \`export {}\` closure, whichever listed model path a query addresses them through. To gate access by caller-supplied identity/role, use \`#(authorize)\` on the source (and \`#(access_filter)\` to scope rows), see \`skill:malloy-model\` § Access Control and \`docs/authorize.md\`. Discovery curation and these gates are independent layers.
326573
328708
 
@@ -327576,70 +329711,7 @@ Scope confirmed, in the conversation and in \`modeling-notes.md\`: tables in sco
327576
329711
  {
327577
329712
  name: "malloy-source-unreachable",
327578
329713
  description: `Work out why a source is missing from get_context, or why a query against it was refused with a 404 or a 403, and what to do next. Use when a source you expect is absent from discovery, when execute_query answers "No queryable source", when a query that used to work stops working, or when deciding whether a package's curation is hiding something you need.`,
327579
- body: `# A source you cannot reach
327580
-
327581
- > **Tool names** are written bare here - \`get_context\`, \`execute_query\`, \`get_status\`, \`compile_model\`. The exact prefixed name depends on the host surface; match each against the tools you actually have.
327582
-
327583
- Most packages publish a curated surface, so a source being absent or refused is usually the package working as its author intended, not a fault. Read the refusal before working around it, because the three things that can refuse you need three different responses and they are told apart by the status code.
327584
-
327585
- ## The three answers
327586
-
327587
- | what you got | what it means | what to do |
327588
- | --- | --- | --- |
327589
- | **404**, "No queryable source", "No queryable model" or "Query target is not queryable" | the source is not on the package's published surface, or does not exist. The two are deliberately indistinguishable so that a refusal cannot be used to probe for hidden names | query something on the surface, or ask the package's author to publish it |
327590
- | **403**, "Access denied" | the source exists and is on the surface, but an \`#(authorize)\` gate did not admit you | supply the givens the gate reads, or accept that this caller may not read it |
327591
- | **200** with zero rows | you were admitted, and an \`#(access_filter)\` narrowed the rows to none of them | this is a real answer. Report it as "no matching rows", never as an error |
327592
-
327593
- A 403 names a source, so it tells you the source exists. A 404 tells you nothing at all. That asymmetry is deliberate: a source that is both hidden AND gated answers **404**, so you can never use a 403 to discover that a hidden name is real.
327594
-
327595
- ## Addressing the surface, which is the most common 404
327596
-
327597
- A query names a model file. When a package curates its surface, only files on that surface are valid entry points, **even for a source that file declares itself**:
327598
-
327599
- \`\`\`
327600
- POST .../packages/sales/models/index.malloy/query {"sourceName": "orders"} -> 200
327601
- POST .../packages/sales/models/orders.malloy/query {"sourceName": "orders"} -> 404
327602
- \`\`\`
327603
-
327604
- Same source, same package. \`orders.malloy\` is off the surface, so it is not an entry point.
327605
-
327606
- **Use the \`model_path\` that \`get_context\` gave you, verbatim.** Its \`source_info.resource_id\` holds \`environment\`, \`package\`, \`model_path\` and \`source\`, and those are exactly \`execute_query\`'s \`environmentName\`, \`packageName\`, \`modelPath\` and \`sourceName\`. On a curated package the \`model_path\` is the surface file, not the file that declares the source. Substituting the declaring file because it looks more correct is how this 404 happens.
327607
-
327608
- ## When the source is not in \`get_context\` at all
327609
-
327610
- Work down this list. The first three are far more common than the last.
327611
-
327612
- 1. **It is off the surface.** The package publishes a curated set and this source is not in it. Nothing you can do from the query side; it is a package edit.
327613
- 2. **The package failed to load, or is serving a stale model.** Call \`get_status\`. A package that never loaded is absent from listings entirely, which reads exactly like "does not exist". A package whose last reload failed to compile is listed and answering, from the model it compiled *before* that save, and carries \`stale: true\`. Neither is visible from a listing alone.
327614
- 3. **You are talking to a different server than you think.** Call \`list_packages\` and check the environment and package names are the ones you expect. A stale \`.mcp.json\` outlives the server that wrote it, and another Publisher may hold that port.
327615
- 4. **Your search phrasing missed it.** Retry with a bare target (no \`search_text\`) to enumerate rather than rank. If it appears there, the source exists and was a ranking miss.
327616
-
327617
- ## What curation does not do
327618
-
327619
- - **It does not hide fields.** A published source may \`join\` an unpublished one, and a query grouping by a joined field returns that field's values normally. Hiding a source removes it as a landing point; it does not redact columns a published source pulls in.
327620
- - **It does not gate \`/compile\`.** \`compile_model\` is exempt, because compile is the authoring loop. A hidden source can still be compile-checked, and that is intended. The exception is a hidden source that is also gated, which answers 404 at compile too.
327621
- - **It is not access control.** Curation answers "what is queryable by name", not "who may query it". Identity is \`#(authorize)\` and \`#(access_filter)\`. A source is not protected by being hidden.
327622
-
327623
- ## Reaching an unpublished source from your own model
327624
-
327625
- If you are writing Malloy rather than just querying, an unpublished source is still usable, with one condition that trips people.
327626
-
327627
- \`export { ... }\` decides what an **importing file** can see, which is Malloy's rule and not Publisher's:
327628
-
327629
- - a file that declares **no** \`export\` hands an importer everything it declares, so import the file that declares the source and join it normally;
327630
- - a file that **does** declare one hands over exactly that list. Importing a file whose \`export\` omits the source and then naming it fails to compile with \`Reference to undefined object\`.
327631
-
327632
- So an unpublished source is reachable through a file that exports it, or that exports nothing, and not through a file whose \`export\` leaves it out. Re-export chains are fine at any depth: a source re-exported through several files stays queryable through the surface, because admission follows the declaration rather than the path taken to it.
327633
-
327634
- ## Before you report a source as missing
327635
-
327636
- Confirm all three, in this order. Each one has been mistaken for "the data is not there".
327637
-
327638
- 1. \`get_status\` shows the package loaded and not \`stale\`.
327639
- 2. \`list_packages\` names the environment and package you meant.
327640
- 3. An enumerating \`get_context\` call (a target with no \`search_text\`) does not list it.
327641
-
327642
- Only then is it genuinely not published, and the answer to the user is that the package does not expose it, not that the data does not exist.`
329714
+ body: '# A source you cannot reach\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `get_status`, `compile_model`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\nMost packages publish a curated surface, so a source being absent or refused is usually the package working as its author intended, not a fault. Read the refusal before working around it, because the three things that can refuse you need three different responses and they are told apart by the status code.\n\n## The three answers\n\n| what you got | what it means | what to do |\n| --- | --- | --- |\n| **404** that says "not on this package\'s published surface" | the name is real and the package does not publish it. It is curation, not a typo | follow the fix the message names (usually: name it in the `export { ... }` of `index.malloy` and address the query there), or query what IS published |\n| **404**, plain "No queryable source", "No queryable model" or "Query target is not queryable" | the name does not exist, or it is hidden in a model that carries an `#(authorize)` or `#(access_filter)` gate. In a gated model the two are deliberately indistinguishable, so a refusal cannot be used to probe for hidden names | check the name against `get_context`, then query something on the surface or ask the package\'s author to publish it |\n| **403**, "Access denied" | the source exists and is on the surface, but an `#(authorize)` gate did not admit you | supply the givens the gate reads, or accept that this caller may not read it |\n| **200** with zero rows | you were admitted, and an `#(access_filter)` narrowed the rows to none of them | this is a real answer. Report it as "no matching rows", never as an error |\n\nA 403 names a source, so it tells you the source exists. A plain 404 tells you nothing at all. That asymmetry is deliberate: a source that is both hidden AND gated answers a plain **404**, so you can never use a 403, or the wording of a 404, to discover that a hidden gated name is real.\n\n## Addressing the surface, which is the most common 404\n\nA query names a model file. When a package curates its surface, only files on that surface are valid entry points, **even for a source that file declares itself**:\n\n```\nPOST .../packages/sales/models/index.malloy/query {"sourceName": "orders"} -> 200\nPOST .../packages/sales/models/orders.malloy/query {"sourceName": "orders"} -> 404\n```\n\nSame source, same package. `orders.malloy` is off the surface, so it is not an entry point.\n\n**Use the `model_path` that `get_context` gave you, verbatim.** Its `source_info.resource_id` holds `environment`, `package`, `model_path` and `source`, and those are exactly `execute_query`\'s `environmentName`, `packageName`, `modelPath` and `sourceName`. On a curated package the `model_path` is the surface file, not the file that declares the source. Substituting the declaring file because it looks more correct is how this 404 happens.\n\n## Other places a hidden source answers 404\n\nThe query route is not the only one held to the surface. On a curated package, a source or file off it also shows up as a 404 here:\n\n- **Reading a model.** `GET .../models/{path}` answers 404 for a file off the surface, with the same words as the query route. The file still exists and still compiles; read `index.malloy` instead. A model that is on the surface lists only the names it publishes, not everything it imports.\n- **A dashboard tile.** A tile, a single-query dashboard\'s query, or a filter `suggest` over a hidden source answers 404. The dashboard itself is still listed. The package load warns once per tile, for example `Tile orders_staging -> by_flag on dashboard overview reads orders_staging, which index.malloy doesn\'t export, so it won\'t load. Fix: add orders_staging to the export { ... } in index.malloy.` It is in the `warnings` on the package\'s own response, `GET .../packages/{pkg}`.\n- **A notebook cell.** A cell over a hidden source answers 404, even when the notebook imports its file. A source an earlier cell derives from a published one still works.\n\nThe fix is the same in each case: add the source to the `export { ... }` in `index.malloy`, or use what is already published.\n\n## When the source is not in `get_context` at all\n\nWork down this list. The first three are far more common than the last.\n\n1. **It is off the surface.** The package publishes a curated set and this source is not in it. Nothing you can do from the query side; it is a package edit.\n2. **The package failed to load, or is serving a stale model.** Call `get_status`. A package that never loaded is absent from listings entirely, which reads exactly like "does not exist". A package whose last reload failed to compile is listed and answering, from the model it compiled *before* that save, and carries `stale: true`. Neither is visible from a listing alone.\n3. **You are talking to a different server than you think.** Call `list_packages` and check the environment and package names are the ones you expect. A stale `.mcp.json` outlives the server that wrote it, and another Publisher may hold that port.\n4. **Your search phrasing missed it.** Retry with a bare target (no `search_text`) to enumerate rather than rank. If it appears there, the source exists and was a ranking miss.\n\n## What curation does not do\n\n- **It does not hide fields.** A published source may `join` an unpublished one, and a query grouping by a joined field returns that field\'s values normally. Hiding a source removes it as a landing point; it does not redact columns a published source pulls in.\n- **It does not gate `/compile`.** `compile_model` is exempt, because compile is the authoring loop. A hidden source can still be compile-checked, and that is intended. The exception is a hidden source that is also gated, which answers 404 at compile too.\n- **It is not access control.** Curation answers "what is queryable by name", not "who may query it". Identity is `#(authorize)` and `#(access_filter)`. A source is not protected by being hidden.\n\n## Reaching an unpublished source from your own model\n\nIf you are writing Malloy rather than just querying, an unpublished source is still usable, with one condition that trips people.\n\n`export { ... }` decides what an **importing file** can see, which is Malloy\'s rule and not Publisher\'s:\n\n- a file that declares **no** `export` hands an importer everything it declares, so import the file that declares the source and join it normally;\n- a file that **does** declare one hands over exactly that list. Importing a file whose `export` omits the source and then naming it fails to compile with `Reference to undefined object`.\n\nSo an unpublished source is reachable through a file that exports it, or that exports nothing, and not through a file whose `export` leaves it out. Re-export chains are fine at any depth: a source re-exported through several files stays queryable through the surface, because admission follows the declaration rather than the path taken to it.\n\n## Before you report a source as missing\n\nConfirm all three, in this order. Each one has been mistaken for "the data is not there".\n\n1. `get_status` shows the package loaded and not `stale`.\n2. `list_packages` names the environment and package you meant.\n3. An enumerating `get_context` call (a target with no `search_text`) does not list it.\n\nOnly then is it genuinely not published, and the answer to the user is that the package does not expose it, not that the data does not exist.'
327643
329715
  }
327644
329716
  ]
327645
329717
  };