@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.
- package/dist/app/api-doc.yaml +105 -70
- package/dist/app/assets/{EnvironmentPage-Be-JAMS6.js → EnvironmentPage-dgZIJhMw.js} +1 -1
- package/dist/app/assets/{HomePage-DsdQpZuT.js → HomePage-D2FGM4cV.js} +1 -1
- package/dist/app/assets/{LightMode-DyjqECoH.js → LightMode-emBuHCdz.js} +1 -1
- package/dist/app/assets/{MainPage-DAaQnh6y.js → MainPage-BVqdH0TB.js} +1 -1
- package/dist/app/assets/{ModelPage-B7yaRDmM.js → ModelPage-DAJxkTmk.js} +2 -2
- package/dist/app/assets/{PackagePage-7b0klbWy.js → PackagePage-BGGcyDeJ.js} +1 -1
- package/dist/app/assets/{RenderedResult-CYyhQekT.es-DJ3BRrLl.js → RenderedResult-CDA9mN_6.es-C4f_ZzvB.js} +2 -2
- package/dist/app/assets/{RouteError-kdHl1qpH.js → RouteError-CtRFTa_q.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DIt45eWv.js → ThemeEditorPage-Bnd5dgI0.js} +1 -1
- package/dist/app/assets/{ToggleButtonGroup-Dl_oTC8r.js → ToggleButtonGroup-CxU1OIGN.js} +1 -1
- package/dist/app/assets/{index-t9-rAE4l.js → index-BWGMOIxQ.js} +1 -1
- package/dist/app/assets/{index-C1qGlR2a.js → index-CNQ_NxFx.js} +4 -4
- package/dist/app/assets/{index-T1Cl-rQv.js → index-OsUlLBi_.js} +1 -1
- package/dist/app/assets/{index-C5hv_y6G.js → index-aXk5ykLp.js} +1 -1
- package/dist/app/assets/{index-Bzqs2UuN.js → index-xgxo45Xn.js} +1 -1
- package/dist/app/assets/{index.es-BlAu_Rj1.js → index.es-BCvB2VgL.js} +22 -22
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +1328 -776
- package/dist/server.mjs +2469 -397
- 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.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
274612
|
-
const
|
|
274613
|
-
|
|
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
|
-
|
|
274618
|
-
|
|
274619
|
-
|
|
274620
|
-
|
|
274621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274630
|
-
|
|
274631
|
-
|
|
274632
|
-
|
|
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
|
-
|
|
274636
|
-
|
|
274637
|
-
|
|
274638
|
-
|
|
274639
|
-
|
|
274640
|
-
|
|
274641
|
-
|
|
274642
|
-
|
|
274643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274649
|
-
i = end
|
|
274751
|
+
trivia(i, end);
|
|
274752
|
+
i = end;
|
|
274650
274753
|
continue;
|
|
274651
274754
|
}
|
|
274652
|
-
|
|
274653
|
-
|
|
274654
|
-
|
|
274655
|
-
|
|
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 (
|
|
274659
|
-
|
|
274660
|
-
|
|
274661
|
-
|
|
274662
|
-
|
|
274663
|
-
|
|
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
|
|
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
|
|
274674
|
-
|
|
274675
|
-
|
|
274676
|
-
|
|
274677
|
-
|
|
274678
|
-
|
|
274679
|
-
|
|
274680
|
-
|
|
274681
|
-
|
|
274682
|
-
|
|
274683
|
-
|
|
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
|
|
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/
|
|
275908
|
+
// src/service/filter_binding_guard.ts
|
|
274921
275909
|
import {
|
|
274922
275910
|
isJoined as isJoined4,
|
|
274923
|
-
isSourceDef as
|
|
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 &&
|
|
276343
|
+
if (declared && isSourceDef8(declared))
|
|
274936
276344
|
return false;
|
|
274937
276345
|
}
|
|
274938
276346
|
return true;
|
|
274939
276347
|
}
|
|
274940
|
-
function
|
|
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 ||
|
|
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) =>
|
|
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 (!
|
|
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 (!
|
|
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) =>
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
275797
|
-
|
|
275798
|
-
|
|
275799
|
-
|
|
275800
|
-
|
|
275801
|
-
|
|
275802
|
-
|
|
275803
|
-
|
|
275804
|
-
|
|
275805
|
-
|
|
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
|
|
275808
|
-
|
|
275809
|
-
|
|
275810
|
-
|
|
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
|
|
275814
|
-
|
|
275815
|
-
|
|
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
|
|
275829
|
-
if (
|
|
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
|
|
275845
|
-
|
|
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.
|
|
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 || !
|
|
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
|
-
|
|
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 || !
|
|
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
|
|
277771
|
+
return;
|
|
276024
277772
|
if (struct.filterList?.some((f) => f.code === code))
|
|
276025
|
-
return
|
|
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 &&
|
|
276031
|
-
return this.
|
|
277778
|
+
if (base && isSourceDef9(base)) {
|
|
277779
|
+
return this.resolveFilterListLandingStruct(base, modelDef, code, depth + 1);
|
|
276032
277780
|
}
|
|
276033
277781
|
}
|
|
276034
|
-
return
|
|
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 (
|
|
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
|
|
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
|
|
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 =
|
|
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 || !
|
|
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
|
|
278147
|
+
assertQueryBoundaryEarly(sourceName, queryName, query, derivationContext) {
|
|
278148
|
+
const { mode, exploresDeclared } = this.queryBoundary;
|
|
276302
278149
|
if (mode === "all" || !exploresDeclared)
|
|
276303
278150
|
return "cleared";
|
|
276304
|
-
|
|
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
|
-
|
|
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
|
|
278168
|
+
throw this.notQueryable(`No queryable source "${sourceName}".`, this.declaresSource(sourceName), "source");
|
|
276318
278169
|
}
|
|
276319
|
-
|
|
276320
|
-
|
|
276321
|
-
|
|
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
|
|
278177
|
+
assertQueryBoundaryCompiled(compiledSource, query, derivationContext) {
|
|
278178
|
+
const { mode, exploresDeclared } = this.queryBoundary;
|
|
276329
278179
|
if (mode === "all" || !exploresDeclared)
|
|
276330
278180
|
return;
|
|
276331
|
-
|
|
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
|
|
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
|
-
|
|
276788
|
-
|
|
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
|
-
|
|
276964
|
-
|
|
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
|
-
|
|
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 || !
|
|
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.
|
|
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
|
-
|
|
277147
|
-
|
|
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
|
|
277151
|
-
|
|
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
|
-
|
|
277167
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 || !
|
|
313700
|
+
if (!model || !this.queryBoundaryActive())
|
|
311603
313701
|
return [];
|
|
311604
|
-
const
|
|
311605
|
-
const
|
|
311606
|
-
const
|
|
311607
|
-
|
|
311608
|
-
|
|
311609
|
-
|
|
311610
|
-
|
|
311611
|
-
|
|
311612
|
-
|
|
311613
|
-
|
|
311614
|
-
|
|
311615
|
-
|
|
311616
|
-
|
|
311617
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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" ?
|
|
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(
|
|
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
|
-
\`
|
|
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
|
-
\`
|
|
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 \`
|
|
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 \`
|
|
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
|
|
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": "
|
|
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
|
-
\`
|
|
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
|
-
| \`
|
|
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
|
-
| \`
|
|
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
|
|
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
|
-
| \`
|
|
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 \`
|
|
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. \`
|
|
322938
|
-
|
|
322939
|
-
|
|
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
|
|
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"**.
|
|
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
|
|
324028
|
-
usually a missing or misspelled \`# artifact\` tag
|
|
324029
|
-
an untagged shared include
|
|
324030
|
-
|
|
324031
|
-
|
|
324032
|
-
|
|
324033
|
-
|
|
324034
|
-
|
|
324035
|
-
|
|
324036
|
-
|
|
324037
|
-
|
|
324038
|
-
|
|
324039
|
-
|
|
324040
|
-
|
|
324041
|
-
|
|
324042
|
-
|
|
324043
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326569
|
-
|
|
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:
|
|
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
|
};
|