@malloy-publisher/server 0.6.0 → 0.8.0
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 +136 -37
- package/dist/app/assets/EnvironmentPage-Be-JAMS6.js +1 -0
- package/dist/app/assets/HomePage-DsdQpZuT.js +1 -0
- package/dist/app/assets/{LightMode-BpHRmI5M.js → LightMode-DyjqECoH.js} +1 -1
- package/dist/app/assets/{MainPage-BHleF-Kb.js → MainPage-DAaQnh6y.js} +2 -2
- package/dist/app/assets/{ModelPage-Cgw-SbZi.js → ModelPage-B7yaRDmM.js} +2 -2
- package/dist/app/assets/PackagePage-7b0klbWy.js +1 -0
- package/dist/app/assets/{RenderedResult-DSgqZUiN.es-CL0FEank.js → RenderedResult-CYyhQekT.es-DJ3BRrLl.js} +3 -3
- package/dist/app/assets/{RouteError-DBMc9ccU.js → RouteError-kdHl1qpH.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-ByJlsmNu.js → ThemeEditorPage-DIt45eWv.js} +1 -1
- package/dist/app/assets/{ToggleButtonGroup-BHEVw4Ti.js → ToggleButtonGroup-Dl_oTC8r.js} +1 -1
- package/dist/app/assets/{index-rWWmF7hn.js → index-Bzqs2UuN.js} +1 -1
- package/dist/app/assets/{index-C5BtJ1GX.js → index-C1qGlR2a.js} +105 -105
- package/dist/app/assets/{index-c3i6ScGn.js → index-C5hv_y6G.js} +1 -1
- package/dist/app/assets/{index-BdwkgpbO.js → index-T1Cl-rQv.js} +1 -1
- package/dist/app/assets/{index-Dyxfz1yO.js → index-t9-rAE4l.js} +1 -1
- package/dist/app/assets/{index.es-VI5lesX5.js → index.es-BlAu_Rj1.js} +4 -4
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +54 -5
- package/dist/server.mjs +1293 -356
- package/dist/{sshcrypto-xqan60jb.node → sshcrypto-cqb52edz.node} +0 -0
- package/package.json +1 -1
- package/dist/app/assets/EnvironmentPage-C4IrQrKG.js +0 -1
- package/dist/app/assets/HomePage-DDyGPzmK.js +0 -1
- package/dist/app/assets/PackagePage-Cy0c37D0.js +0 -1
package/dist/server.mjs
CHANGED
|
@@ -175849,7 +175849,7 @@ import os from "os";
|
|
|
175849
175849
|
function normalizeModelPath(p) {
|
|
175850
175850
|
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
175851
175851
|
}
|
|
175852
|
-
var API_PREFIX = "/api/v0", README_NAME = "README.md", PUBLISHER_CONFIG_NAME = "publisher.config.json", PACKAGE_MANIFEST_NAME = "publisher.json", MODEL_FILE_SUFFIX = ".malloy", NOTEBOOK_FILE_SUFFIX = ".malloynb", DEFAULT_QUERY_ROW_LIMIT = 1000, DEFAULT_MAX_QUERY_ROWS = 1e5, DEFAULT_MAX_RESPONSE_BYTES = 50000000, DEFAULT_QUERY_TIMEOUT_MS = 300000, DEFAULT_MAX_CONCURRENT_QUERIES = 32, TEMP_DIR_PATH, PUBLISHER_DATA_DIR = "publisher_data";
|
|
175852
|
+
var API_PREFIX = "/api/v0", README_NAME = "README.md", PUBLISHER_CONFIG_NAME = "publisher.config.json", PACKAGE_MANIFEST_NAME = "publisher.json", MODEL_FILE_SUFFIX = ".malloy", NOTEBOOK_FILE_SUFFIX = ".malloynb", INDEX_MODEL_NAME = "index.malloy", DEFAULT_QUERY_ROW_LIMIT = 1000, DEFAULT_MAX_QUERY_ROWS = 1e5, DEFAULT_MAX_RESPONSE_BYTES = 50000000, DEFAULT_QUERY_TIMEOUT_MS = 300000, DEFAULT_MAX_CONCURRENT_QUERIES = 32, TEMP_DIR_PATH, PUBLISHER_DATA_DIR = "publisher_data";
|
|
175853
175853
|
var init_constants = __esm(() => {
|
|
175854
175854
|
TEMP_DIR_PATH = os.tmpdir();
|
|
175855
175855
|
});
|
|
@@ -233150,7 +233150,7 @@ var require_utils92 = __commonJS((exports, module) => {
|
|
|
233150
233150
|
|
|
233151
233151
|
// ../../node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
|
|
233152
233152
|
var require_sshcrypto = __commonJS((exports, module) => {
|
|
233153
|
-
module.exports = __require("./sshcrypto-
|
|
233153
|
+
module.exports = __require("./sshcrypto-cqb52edz.node");
|
|
233154
233154
|
});
|
|
233155
233155
|
|
|
233156
233156
|
// ../../node_modules/ssh2/lib/protocol/crypto/poly1305.js
|
|
@@ -270853,10 +270853,10 @@ function recordMaterializationRun(mode, outcome, durationMs) {
|
|
|
270853
270853
|
runCounter().add(1, { mode, outcome });
|
|
270854
270854
|
runDuration().record(durationMs, { mode });
|
|
270855
270855
|
}
|
|
270856
|
-
function recordSourcesOutcome(outcome, count) {
|
|
270856
|
+
function recordSourcesOutcome(outcome, count, mode) {
|
|
270857
270857
|
if (count <= 0)
|
|
270858
270858
|
return;
|
|
270859
|
-
sourcesCounter().add(count, { outcome });
|
|
270859
|
+
sourcesCounter().add(count, { outcome, mode });
|
|
270860
270860
|
}
|
|
270861
270861
|
function recordIncrementalStep(step, reason) {
|
|
270862
270862
|
incrementalStepCounter().add(1, reason ? { step, reason } : { step });
|
|
@@ -270930,7 +270930,7 @@ var init_materialization_metrics = __esm(() => {
|
|
|
270930
270930
|
resetHooks3 = [];
|
|
270931
270931
|
runCounter = lazyCounter3("publisher_materialization_runs_total", "Materialization builds completed. Labels: mode ('auto'|'orchestrated'), outcome ('success'|'partial'|'failed'|'cancelled').");
|
|
270932
270932
|
runDuration = lazyHistogram2("publisher_materialization_run_duration_ms", "Wall-clock duration of a materialization build. Label: mode ('auto'|'orchestrated').", "ms");
|
|
270933
|
-
sourcesCounter = lazyCounter3("publisher_materialization_sources_total", "Persist sources processed by a materialization run.
|
|
270933
|
+
sourcesCounter = lazyCounter3("publisher_materialization_sources_total", "Persist sources processed by a materialization run. Labels: outcome ('built'|'reused'|'failed'|'refused'), mode ('auto'|'orchestrated').");
|
|
270934
270934
|
incrementalStepCounter = lazyCounter3("publisher_materialization_incremental_step_total", 'Refreshes of a source declared refresh="incremental". Labels: step ' + "('delta'|'seed'|'skip'), and for seed/skip a bounded reason code " + "(IncrementalStepReasonCode). A 'seed' is a full rebuild the delta path " + "declined, so a rising seed rate means the feature is not engaging — and " + "the reason label says why without a log dive.");
|
|
270935
270935
|
buildPlanComputeDuration = lazyHistogram2("publisher_materialization_build_plan_compute_duration_ms", "Wall-clock duration of compiling a package's build plan (Package.buildPlan).", "ms");
|
|
270936
270936
|
buildPlanComputeFailedCounter = lazyCounter3("publisher_materialization_build_plan_compute_failed_total", "Package loads whose build plan failed to compute. The ROOT-CAUSE signal for " + "colocated_bind_dropped{reason='build_plan_unavailable'}, which fires only " + "per dropped entry per load -- a package that loads once and is never " + "reloaded ticks that counter once and then sits with its colocated tier " + "off and a flat total, indistinguishable from healthy.");
|
|
@@ -270949,7 +270949,7 @@ var init_materialization_metrics = __esm(() => {
|
|
|
270949
270949
|
storageBuildFailureCounter = lazyCounter3("publisher_storage_build_failures_total", "storage= build failures (federation/passthrough/attach/CTAS), distinct from " + "in-warehouse build failures. Labels: destination (connection name), " + "reason ('build_failed'|'billed_read_not_captured'). The second is the " + "expensive one: the warehouse read ran and was charged, and the rows could " + "not be captured — so a re-drive pays for it again. Worth alerting on " + "separately from a failure that costs only a retry.");
|
|
270950
270950
|
attributionSkippedCounter = lazyCounter3("publisher_storage_build_attribution_skipped_total", "storage= builds whose warehouse read went out UNATTRIBUTED while tagging was " + "on. Label: reason ('job_listing_unavailable'|'tag_failed'|" + "'read_row_not_found'|'read_row_ambiguous'|'cost_query_failed'). " + "The read still ran and the " + "build still succeeded — what was lost is the label in the customer's own " + "query history, and the cost on this side. Without this an operator who " + "turns tagging on and sees nothing has a single log line to go on.");
|
|
270951
270951
|
eligibilityRefusedCounter = lazyCounter3("publisher_materialization_eligibility_refused_total", "materialization-eligibility refusals, both tiers. Label: reason " + "('free_parameter'|'given_in_persisted_query'|'dynamic_projection'|" + "'dynamic_join'|'dynamic_joined_where'|'partition_without_storage'|" + "'partition_column_unknown'|'partition_column_not_public'|" + "'merge_key_scope_unresolved'|'preaggregate_over_dynamic_source'|" + "'authorize'|'partition'|'not_duckdb_portable'|" + "'public_surface_unknown'). 'given' is retained on the enum for records " + "that carry it and is no longer produced: where a given sits decides the " + "outcome, so one reason covering every placement cannot be raised.");
|
|
270952
|
-
serveShapeTierDropCounter = lazyCounter3("publisher_storage_serve_shape_tier_drop_total", "storage serve-shape compile escalations: a refinement tier failed to " + "compile and the riskiest category was dropped. Label: tier (the
|
|
270952
|
+
serveShapeTierDropCounter = lazyCounter3("publisher_storage_serve_shape_tier_drop_total", "storage serve-shape compile escalations: a refinement tier failed to " + "compile and the riskiest category was dropped, or one or more lifted " + "entry points failed to compile and were dropped alone. Label: tier (the " + "failed tier index, 0=full; 'lifts' for dropped lifts).");
|
|
270953
270953
|
serveShapeTypeFallbackCounter = lazyCounter3("publisher_storage_serve_shape_type_fallback_total", "Captured DuckDB column types mapped to json in the serve shape (type " + "fidelity loss). Label: kind ('array'|'unrecognized').");
|
|
270954
270954
|
chainedStorageBuildCounter = lazyCounter3("publisher_storage_chained_build_total", "Chained storage= source builds (a source reading a storage-materialized " + "upstream). Label: outcome ('parent_reuse'|'inline_fallback'|" + "'strict_refused'). The parent_reuse share is the headline signal for how " + "far the stack-on-the-parent path gets us vs recompute-from-raw.");
|
|
270955
270955
|
colocatedBindDroppedCounter = lazyCounter3("publisher_materialization_colocated_bind_dropped_total", "Colocated serve-manifest entries dropped by bindColocatedServeManifest. " + "Label: reason ('build_plan_unavailable' when the package's build plan " + "failed to compute, so no source was examined at all; 'refused' when the " + "source itself was examined and found ineligible). 'build_plan_unavailable' " + "is a whole-package regression -- colocated is the default tier and is NOT " + "gated by PERSIST_STORAGE_MODE, so every colocated binding for the package " + "reverts to live recompute until a load succeeds.");
|
|
@@ -271455,6 +271455,291 @@ var init_preaggregation_serve_bindings = __esm(() => {
|
|
|
271455
271455
|
init_preaggregation_synthesis();
|
|
271456
271456
|
});
|
|
271457
271457
|
|
|
271458
|
+
// src/service/persist_dynamic_terms.ts
|
|
271459
|
+
import { Annotations as Annotations4 } from "@malloydata/malloy";
|
|
271460
|
+
function givenNames(node) {
|
|
271461
|
+
const names = new Set;
|
|
271462
|
+
collectGivenRefs(node, names);
|
|
271463
|
+
return [...names];
|
|
271464
|
+
}
|
|
271465
|
+
function readsAGiven(node, seen, depth = 0) {
|
|
271466
|
+
if (depth > MAX_WALK_DEPTH) {
|
|
271467
|
+
throw new Error("given walk exceeded max depth");
|
|
271468
|
+
}
|
|
271469
|
+
if (node === null || typeof node !== "object")
|
|
271470
|
+
return false;
|
|
271471
|
+
if (seen.has(node))
|
|
271472
|
+
return false;
|
|
271473
|
+
seen.add(node);
|
|
271474
|
+
if (Array.isArray(node)) {
|
|
271475
|
+
return node.some((item) => readsAGiven(item, seen, depth + 1));
|
|
271476
|
+
}
|
|
271477
|
+
const record = node;
|
|
271478
|
+
if (record.node === "given" || record.node === "givenReference")
|
|
271479
|
+
return true;
|
|
271480
|
+
if (record.givenRef !== undefined)
|
|
271481
|
+
return true;
|
|
271482
|
+
const summary = record.refSummary;
|
|
271483
|
+
if (summary?.givenUsage && summary.givenUsage.length > 0)
|
|
271484
|
+
return true;
|
|
271485
|
+
if (Array.isArray(record.givenUsage) && record.givenUsage.length > 0) {
|
|
271486
|
+
return true;
|
|
271487
|
+
}
|
|
271488
|
+
return Object.values(record).some((v) => readsAGiven(v, seen, depth + 1));
|
|
271489
|
+
}
|
|
271490
|
+
function buildSubstitutesAGiven(persistSource) {
|
|
271491
|
+
let def;
|
|
271492
|
+
try {
|
|
271493
|
+
def = persistSource._sourceDef;
|
|
271494
|
+
} catch {
|
|
271495
|
+
return true;
|
|
271496
|
+
}
|
|
271497
|
+
return defSubstitutesAGiven(def);
|
|
271498
|
+
}
|
|
271499
|
+
function defSubstitutesAGiven(def) {
|
|
271500
|
+
try {
|
|
271501
|
+
if (def === null || typeof def !== "object") {
|
|
271502
|
+
throw new Error("compiled source definition is not readable");
|
|
271503
|
+
}
|
|
271504
|
+
const query = def.query;
|
|
271505
|
+
if (query !== null && typeof query === "object") {
|
|
271506
|
+
const usage = query.givenUsage;
|
|
271507
|
+
if (Array.isArray(usage) && usage.length > 0)
|
|
271508
|
+
return true;
|
|
271509
|
+
if (argumentBindsAGiven(query))
|
|
271510
|
+
return true;
|
|
271511
|
+
if (Array.isArray(usage))
|
|
271512
|
+
return false;
|
|
271513
|
+
}
|
|
271514
|
+
const {
|
|
271515
|
+
filterList: _readTimeFilters,
|
|
271516
|
+
fields: _readTimeFields,
|
|
271517
|
+
...relation
|
|
271518
|
+
} = def;
|
|
271519
|
+
return readsAGiven(relation, new WeakSet);
|
|
271520
|
+
} catch {
|
|
271521
|
+
return true;
|
|
271522
|
+
}
|
|
271523
|
+
}
|
|
271524
|
+
function argumentBindsAGiven(node, depth = 0) {
|
|
271525
|
+
if (depth > MAX_WALK_DEPTH) {
|
|
271526
|
+
throw new Error("argument walk exceeded max depth");
|
|
271527
|
+
}
|
|
271528
|
+
if (node === null || typeof node !== "object")
|
|
271529
|
+
return false;
|
|
271530
|
+
if (Array.isArray(node)) {
|
|
271531
|
+
return node.some((item) => argumentBindsAGiven(item, depth + 1));
|
|
271532
|
+
}
|
|
271533
|
+
for (const [key, value] of Object.entries(node)) {
|
|
271534
|
+
if ((key === "arguments" || key === "sourceArguments") && readsAGiven(value, new WeakSet)) {
|
|
271535
|
+
return true;
|
|
271536
|
+
}
|
|
271537
|
+
if (argumentBindsAGiven(value, depth + 1))
|
|
271538
|
+
return true;
|
|
271539
|
+
}
|
|
271540
|
+
return false;
|
|
271541
|
+
}
|
|
271542
|
+
function classifyDynamicTerms(persistSource) {
|
|
271543
|
+
let def;
|
|
271544
|
+
try {
|
|
271545
|
+
def = persistSource._sourceDef;
|
|
271546
|
+
} catch (err) {
|
|
271547
|
+
return unreadable(err, "which givens the build would bake in");
|
|
271548
|
+
}
|
|
271549
|
+
return classifyDef(def, 0);
|
|
271550
|
+
}
|
|
271551
|
+
function classifyDef(def, depth) {
|
|
271552
|
+
if (defSubstitutesAGiven(def)) {
|
|
271553
|
+
return {
|
|
271554
|
+
ok: false,
|
|
271555
|
+
reason: "given_in_persisted_query",
|
|
271556
|
+
detail: `a given is read while the persisted relation is BUILT, so its ` + `value is substituted at build time — from the declaration's ` + `default, the only value available then — and every caller is ` + `served that one slice. Move the given out of the persisted query ` + `and into the source's extend block (\`where: …\`), where it is ` + `left out of the build and applied per caller when the artifact ` + `is read`
|
|
271557
|
+
};
|
|
271558
|
+
}
|
|
271559
|
+
if (def === null || typeof def !== "object") {
|
|
271560
|
+
return unreadable(new Error("compiled source definition is not readable"), "which givens the build would bake in");
|
|
271561
|
+
}
|
|
271562
|
+
const record = def;
|
|
271563
|
+
try {
|
|
271564
|
+
const joinedTerms = [];
|
|
271565
|
+
const fieldRefusal = classifyFields(record.fields, joinedTerms, depth);
|
|
271566
|
+
if (fieldRefusal)
|
|
271567
|
+
return fieldRefusal;
|
|
271568
|
+
const terms = collectDynamicTerms(record.filterList);
|
|
271569
|
+
const {
|
|
271570
|
+
filterList: _accounted,
|
|
271571
|
+
fields: _alsoAccounted,
|
|
271572
|
+
query: _cleared,
|
|
271573
|
+
...rest
|
|
271574
|
+
} = record;
|
|
271575
|
+
if (readsAGiven(rest, new WeakSet)) {
|
|
271576
|
+
return {
|
|
271577
|
+
ok: false,
|
|
271578
|
+
reason: "given_in_persisted_query",
|
|
271579
|
+
detail: `a given sits in a part of the source this pass cannot place ` + `— it is neither an extend-block \`where:\` nor a field ` + `declared on the source — so the publisher cannot prove the ` + `build leaves it out`
|
|
271580
|
+
};
|
|
271581
|
+
}
|
|
271582
|
+
return joinedTerms.length > 0 ? { ok: true, terms, joinedTerms } : { ok: true, terms };
|
|
271583
|
+
} catch (err) {
|
|
271584
|
+
return unreadable(err, "the build leaves every given out");
|
|
271585
|
+
}
|
|
271586
|
+
}
|
|
271587
|
+
function unreadable(err, cannotProve) {
|
|
271588
|
+
return {
|
|
271589
|
+
ok: false,
|
|
271590
|
+
reason: "given_in_persisted_query",
|
|
271591
|
+
detail: `its given usage could not be determined ` + `(${err instanceof Error ? err.message : String(err)}), so the ` + `publisher cannot prove ${cannotProve}`
|
|
271592
|
+
};
|
|
271593
|
+
}
|
|
271594
|
+
function classifyFields(fields, joinedTerms, depth) {
|
|
271595
|
+
if (!Array.isArray(fields))
|
|
271596
|
+
return;
|
|
271597
|
+
for (const field of fields) {
|
|
271598
|
+
if (field === null || typeof field !== "object")
|
|
271599
|
+
continue;
|
|
271600
|
+
const f = field;
|
|
271601
|
+
const label = typeof f.as === "string" ? f.as : typeof f.name === "string" ? f.name : "(unnamed)";
|
|
271602
|
+
if (f.join !== undefined) {
|
|
271603
|
+
if (readsAGiven(f.onExpression, new WeakSet)) {
|
|
271604
|
+
return {
|
|
271605
|
+
ok: false,
|
|
271606
|
+
reason: "dynamic_join",
|
|
271607
|
+
detail: `the join '${label}' has a given in its \`on:\` condition, ` + `which decides which rows join per caller`
|
|
271608
|
+
};
|
|
271609
|
+
}
|
|
271610
|
+
if (readsAGiven(f, new WeakSet)) {
|
|
271611
|
+
const joined = joinedSourceRefusal(f, label, depth);
|
|
271612
|
+
if (!joined.ok)
|
|
271613
|
+
return joined;
|
|
271614
|
+
joinedTerms.push(...joined.joinedTerms);
|
|
271615
|
+
}
|
|
271616
|
+
continue;
|
|
271617
|
+
}
|
|
271618
|
+
if (readsAGiven(f, new WeakSet)) {
|
|
271619
|
+
return {
|
|
271620
|
+
ok: false,
|
|
271621
|
+
reason: "dynamic_projection",
|
|
271622
|
+
detail: `the field '${label}' reads a given, so its value would ` + `differ per caller`
|
|
271623
|
+
};
|
|
271624
|
+
}
|
|
271625
|
+
}
|
|
271626
|
+
return;
|
|
271627
|
+
}
|
|
271628
|
+
function joinedSourceRefusal(f, label, depth) {
|
|
271629
|
+
const refuse = (why) => ({
|
|
271630
|
+
ok: false,
|
|
271631
|
+
reason: "dynamic_joined_where",
|
|
271632
|
+
detail: `the source joined as '${label}' is itself scoped by a given, ${why}`
|
|
271633
|
+
});
|
|
271634
|
+
if (depth >= MAX_JOIN_DEPTH) {
|
|
271635
|
+
return refuse(`and is joined more than ${MAX_JOIN_DEPTH} levels deep`);
|
|
271636
|
+
}
|
|
271637
|
+
const sourceID = f.sourceID;
|
|
271638
|
+
if (typeof sourceID !== "string" || sourceID.length === 0) {
|
|
271639
|
+
return refuse(`and is joined through a refinement (\`… extend { … }\`) rather than ` + `by name, so the serve shape cannot bind the join to that source's ` + `stored table. Declare the refinement on a named source and join ` + `that`);
|
|
271640
|
+
}
|
|
271641
|
+
const source = sourceID.split("@")[0] || sourceID;
|
|
271642
|
+
if (!declaresStorage(f.annotations)) {
|
|
271643
|
+
return refuse(`and '${source}' is not materialized into a storage destination, so ` + `the join has no stored table to read per caller. Give '${source}' ` + `\`#@ persist … storage=\`, or drop \`storage=\` from this source`);
|
|
271644
|
+
}
|
|
271645
|
+
const target = { ...f };
|
|
271646
|
+
for (const key of JOIN_ONLY_KEYS)
|
|
271647
|
+
delete target[key];
|
|
271648
|
+
const inner = classifyDef(target, depth + 1);
|
|
271649
|
+
if (!inner.ok) {
|
|
271650
|
+
return refuse(`and '${source}' could not itself be materialized per caller: ` + inner.detail);
|
|
271651
|
+
}
|
|
271652
|
+
return {
|
|
271653
|
+
ok: true,
|
|
271654
|
+
joinedTerms: [
|
|
271655
|
+
{ alias: label, source, terms: inner.terms },
|
|
271656
|
+
...(inner.joinedTerms ?? []).map((nested) => ({
|
|
271657
|
+
...nested,
|
|
271658
|
+
alias: `${label}.${nested.alias}`
|
|
271659
|
+
}))
|
|
271660
|
+
]
|
|
271661
|
+
};
|
|
271662
|
+
}
|
|
271663
|
+
function declaresStorage(annotations) {
|
|
271664
|
+
if (annotations === undefined || annotations === null)
|
|
271665
|
+
return false;
|
|
271666
|
+
try {
|
|
271667
|
+
const tag = new Annotations4(annotations).parseAsTag("@").tag;
|
|
271668
|
+
if (!tag.has("persist"))
|
|
271669
|
+
return false;
|
|
271670
|
+
const storage = tag.text("storage");
|
|
271671
|
+
return typeof storage === "string" && storage.trim().length > 0;
|
|
271672
|
+
} catch {
|
|
271673
|
+
return false;
|
|
271674
|
+
}
|
|
271675
|
+
}
|
|
271676
|
+
function givenScopedJoinTargets(fields) {
|
|
271677
|
+
if (!Array.isArray(fields))
|
|
271678
|
+
return [];
|
|
271679
|
+
const out = [];
|
|
271680
|
+
for (const field of fields) {
|
|
271681
|
+
if (field === null || typeof field !== "object")
|
|
271682
|
+
continue;
|
|
271683
|
+
const f = field;
|
|
271684
|
+
if (f.join === undefined || !readsAGiven(f, new WeakSet))
|
|
271685
|
+
continue;
|
|
271686
|
+
const restricted = f.accessModifier != null && f.accessModifier !== "public";
|
|
271687
|
+
out.push(!restricted && typeof f.sourceID === "string" && f.sourceID.length > 0 ? f.sourceID : undefined);
|
|
271688
|
+
}
|
|
271689
|
+
return out;
|
|
271690
|
+
}
|
|
271691
|
+
function localFieldNames(entry) {
|
|
271692
|
+
const usage = entry?.refSummary?.fieldUsage;
|
|
271693
|
+
if (!Array.isArray(usage))
|
|
271694
|
+
return [];
|
|
271695
|
+
const names = new Set;
|
|
271696
|
+
for (const use of usage) {
|
|
271697
|
+
const path9 = use?.path;
|
|
271698
|
+
if (!Array.isArray(path9) || path9.length !== 1 || typeof path9[0] !== "string") {
|
|
271699
|
+
return [];
|
|
271700
|
+
}
|
|
271701
|
+
names.add(path9[0]);
|
|
271702
|
+
}
|
|
271703
|
+
return [...names];
|
|
271704
|
+
}
|
|
271705
|
+
function readTimeDynamicTerms(persistSource) {
|
|
271706
|
+
try {
|
|
271707
|
+
const def = persistSource._sourceDef;
|
|
271708
|
+
return collectDynamicTerms(def?.filterList);
|
|
271709
|
+
} catch {
|
|
271710
|
+
return [{ code: "<unreadable>", givens: [], columns: [] }];
|
|
271711
|
+
}
|
|
271712
|
+
}
|
|
271713
|
+
function collectDynamicTerms(filterList) {
|
|
271714
|
+
if (!Array.isArray(filterList))
|
|
271715
|
+
return [];
|
|
271716
|
+
const terms = [];
|
|
271717
|
+
for (const entry of filterList) {
|
|
271718
|
+
if (entry === null || typeof entry !== "object")
|
|
271719
|
+
continue;
|
|
271720
|
+
if (!readsAGiven(entry, new WeakSet))
|
|
271721
|
+
continue;
|
|
271722
|
+
const code = entry.code;
|
|
271723
|
+
terms.push({
|
|
271724
|
+
code: typeof code === "string" ? code : "",
|
|
271725
|
+
givens: givenNames(entry),
|
|
271726
|
+
columns: localFieldNames(entry)
|
|
271727
|
+
});
|
|
271728
|
+
}
|
|
271729
|
+
return terms;
|
|
271730
|
+
}
|
|
271731
|
+
var MAX_WALK_DEPTH = 200, JOIN_ONLY_KEYS, MAX_JOIN_DEPTH = 4;
|
|
271732
|
+
var init_persist_dynamic_terms = __esm(() => {
|
|
271733
|
+
init_given();
|
|
271734
|
+
JOIN_ONLY_KEYS = [
|
|
271735
|
+
"join",
|
|
271736
|
+
"onExpression",
|
|
271737
|
+
"matrixOperation",
|
|
271738
|
+
"refSummary",
|
|
271739
|
+
"referenceID"
|
|
271740
|
+
];
|
|
271741
|
+
});
|
|
271742
|
+
|
|
271458
271743
|
// src/service/materialization_serve_transform.ts
|
|
271459
271744
|
import {
|
|
271460
271745
|
InMemoryURLReader,
|
|
@@ -271639,6 +271924,8 @@ function serveShapeDiagnostics(allBindings, freshBindings) {
|
|
|
271639
271924
|
};
|
|
271640
271925
|
}
|
|
271641
271926
|
function derivedSourceFragment(lift) {
|
|
271927
|
+
if (lift.text !== undefined)
|
|
271928
|
+
return `source: ${lift.text}`;
|
|
271642
271929
|
const lines = refinementLines(lift.refinements);
|
|
271643
271930
|
const body = lines.length > 0 ? ` extend {
|
|
271644
271931
|
${lines.join(`
|
|
@@ -271653,7 +271940,7 @@ function optsOutOfPersist(def) {
|
|
|
271653
271940
|
function liftDerivedSources(ctx) {
|
|
271654
271941
|
const lifts = [];
|
|
271655
271942
|
const available = new Set(ctx.shapeSourceNames);
|
|
271656
|
-
const pending = Object.entries(ctx.contents).filter(([name, def]) =>
|
|
271943
|
+
const pending = Object.entries(ctx.contents).filter(([name, def]) => derivedFrom(def) !== undefined && !(ctx.boundSourceNames ?? ctx.shapeSourceNames).has(name) && def?.persistent !== true && !optsOutOfPersist(def));
|
|
271657
271944
|
let progressed = true;
|
|
271658
271945
|
while (progressed) {
|
|
271659
271946
|
progressed = false;
|
|
@@ -271662,10 +271949,10 @@ function liftDerivedSources(ctx) {
|
|
|
271662
271949
|
if (!entry)
|
|
271663
271950
|
continue;
|
|
271664
271951
|
const [name, def] = entry;
|
|
271665
|
-
const base = ctx.sourceNameById.get(def
|
|
271952
|
+
const base = ctx.sourceNameById.get(derivedFrom(def));
|
|
271666
271953
|
if (!base || !available.has(base))
|
|
271667
271954
|
continue;
|
|
271668
|
-
const lift = liftOneDerivedSource(name, def, base, available, ctx);
|
|
271955
|
+
const lift = typeof def.extends === "string" ? liftOneDerivedSource(name, def, base, available, ctx) : liftQueryDerivedSource(name, def, base, available, ctx);
|
|
271669
271956
|
pending[i] = undefined;
|
|
271670
271957
|
progressed = true;
|
|
271671
271958
|
if (!lift)
|
|
@@ -271676,6 +271963,74 @@ function liftDerivedSources(ctx) {
|
|
|
271676
271963
|
}
|
|
271677
271964
|
return lifts;
|
|
271678
271965
|
}
|
|
271966
|
+
function derivedFrom(def) {
|
|
271967
|
+
if (typeof def?.extends === "string")
|
|
271968
|
+
return def.extends;
|
|
271969
|
+
if (def?.type !== "query_source")
|
|
271970
|
+
return;
|
|
271971
|
+
return structRefSourceId(def.query?.structRef);
|
|
271972
|
+
}
|
|
271973
|
+
function structRefSourceId(structRef) {
|
|
271974
|
+
if (typeof structRef === "string")
|
|
271975
|
+
return structRef;
|
|
271976
|
+
const sourceID = structRef?.sourceID;
|
|
271977
|
+
return typeof sourceID === "string" && sourceID.length > 0 ? sourceID : undefined;
|
|
271978
|
+
}
|
|
271979
|
+
function liftQueryDerivedSource(sourceName, def, base, available, ctx) {
|
|
271980
|
+
if (!def.location)
|
|
271981
|
+
return;
|
|
271982
|
+
const text = ctx.liftText(def.location);
|
|
271983
|
+
if (!text)
|
|
271984
|
+
return;
|
|
271985
|
+
const reached = referencedSourceIds(def.query?.pipeline);
|
|
271986
|
+
for (const sourceID of reached) {
|
|
271987
|
+
const name = ctx.sourceNameById.get(sourceID);
|
|
271988
|
+
if (!name || !available.has(name))
|
|
271989
|
+
return;
|
|
271990
|
+
}
|
|
271991
|
+
const declaredJoins = (def.fields ?? []).filter((f) => typeof f.join === "string");
|
|
271992
|
+
for (const join5 of declaredJoins) {
|
|
271993
|
+
const sourceID = join5.sourceID;
|
|
271994
|
+
const name = typeof sourceID === "string" ? ctx.sourceNameById.get(sourceID) : undefined;
|
|
271995
|
+
if (!name || !available.has(name))
|
|
271996
|
+
return;
|
|
271997
|
+
}
|
|
271998
|
+
return { sourceName, base, refinements: [], text };
|
|
271999
|
+
}
|
|
272000
|
+
function referencedSourceIds(pipeline) {
|
|
272001
|
+
const out = [];
|
|
272002
|
+
let tooDeep = false;
|
|
272003
|
+
const walk2 = (node, depth) => {
|
|
272004
|
+
if (tooDeep)
|
|
272005
|
+
return;
|
|
272006
|
+
if (depth > 200) {
|
|
272007
|
+
tooDeep = true;
|
|
272008
|
+
return;
|
|
272009
|
+
}
|
|
272010
|
+
if (node === null || typeof node !== "object")
|
|
272011
|
+
return;
|
|
272012
|
+
if (Array.isArray(node)) {
|
|
272013
|
+
for (const item of node)
|
|
272014
|
+
walk2(item, depth + 1);
|
|
272015
|
+
return;
|
|
272016
|
+
}
|
|
272017
|
+
const record = node;
|
|
272018
|
+
if (typeof record.join === "string") {
|
|
272019
|
+
out.push(typeof record.sourceID === "string" ? record.sourceID : "");
|
|
272020
|
+
return;
|
|
272021
|
+
}
|
|
272022
|
+
if (record.structRef !== undefined) {
|
|
272023
|
+
out.push(structRefSourceId(record.structRef) ?? "");
|
|
272024
|
+
}
|
|
272025
|
+
for (const [key, value] of Object.entries(record)) {
|
|
272026
|
+
if (key === "structRef")
|
|
272027
|
+
continue;
|
|
272028
|
+
walk2(value, depth + 1);
|
|
272029
|
+
}
|
|
272030
|
+
};
|
|
272031
|
+
walk2(pipeline, 0);
|
|
272032
|
+
return tooDeep ? [""] : out;
|
|
272033
|
+
}
|
|
271679
272034
|
function liftOneDerivedSource(sourceName, def, base, available, ctx) {
|
|
271680
272035
|
const baseDef = ctx.contents[base];
|
|
271681
272036
|
const baseFieldNames = new Set((baseDef?.fields ?? []).map((f) => fieldKey(f)).filter(Boolean));
|
|
@@ -271978,10 +272333,29 @@ async function assertServesInDuckDB(sourceName, binding, connections) {
|
|
|
271978
272333
|
});
|
|
271979
272334
|
}
|
|
271980
272335
|
}
|
|
272336
|
+
function withholdUnreproducibleCallerScopedJoins(bindings, contents, sourceNameById) {
|
|
272337
|
+
let kept = bindings;
|
|
272338
|
+
for (;; ) {
|
|
272339
|
+
const destinationOf = new Map(kept.map((b) => [b.sourceName, b.destinationName]));
|
|
272340
|
+
const next = kept.filter((b) => givenScopedJoinTargets(contents?.[b.sourceName]?.fields).every((sourceID) => {
|
|
272341
|
+
const target = sourceID === undefined ? undefined : sourceNameById.get(sourceID);
|
|
272342
|
+
return target !== undefined && destinationOf.has(target) && destinationOf.get(target) === b.destinationName;
|
|
272343
|
+
}));
|
|
272344
|
+
if (next.length === kept.length) {
|
|
272345
|
+
const keptNames = new Set(kept.map((b) => b.sourceName));
|
|
272346
|
+
return {
|
|
272347
|
+
kept,
|
|
272348
|
+
withheld: bindings.map((b) => b.sourceName).filter((name) => !keptNames.has(name))
|
|
272349
|
+
};
|
|
272350
|
+
}
|
|
272351
|
+
kept = next;
|
|
272352
|
+
}
|
|
272353
|
+
}
|
|
271981
272354
|
var BARE_IDENTIFIER, NEVER_THINNED, UNREPRODUCIBLE_FILTER = "__unreproducible_filter__";
|
|
271982
272355
|
var init_materialization_serve_transform = __esm(() => {
|
|
271983
272356
|
init_errors();
|
|
271984
272357
|
init_logger();
|
|
272358
|
+
init_persist_dynamic_terms();
|
|
271985
272359
|
init_materialization_metrics();
|
|
271986
272360
|
init_quoting();
|
|
271987
272361
|
BARE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -272673,11 +273047,11 @@ async function resolveGateShape(entry, originModelDef, graftScope, deps) {
|
|
|
272673
273047
|
if (name)
|
|
272674
273048
|
givenNamesById.set(id, name);
|
|
272675
273049
|
}
|
|
272676
|
-
const
|
|
273050
|
+
const givenNames2 = Array.from(givenNamesById.values());
|
|
272677
273051
|
const literalNames = referencedGivenNames(filterText);
|
|
272678
|
-
const accountedFor = new Set(
|
|
273052
|
+
const accountedFor = new Set(givenNames2);
|
|
272679
273053
|
const unaccounted = literalNames.filter((name) => !accountedFor.has(name));
|
|
272680
|
-
if (
|
|
273054
|
+
if (givenNames2.length !== expansion.givenIds.size || unaccounted.length > 0) {
|
|
272681
273055
|
classification = {
|
|
272682
273056
|
shape: "rejected",
|
|
272683
273057
|
cause: "unreachable_given",
|
|
@@ -272686,7 +273060,7 @@ async function resolveGateShape(entry, originModelDef, graftScope, deps) {
|
|
|
272686
273060
|
} else {
|
|
272687
273061
|
classification = {
|
|
272688
273062
|
shape: "row_level",
|
|
272689
|
-
givenNames,
|
|
273063
|
+
givenNames: givenNames2,
|
|
272690
273064
|
givenNamesById
|
|
272691
273065
|
};
|
|
272692
273066
|
}
|
|
@@ -272920,209 +273294,6 @@ var init_gate_classification = __esm(() => {
|
|
|
272920
273294
|
init_gate_registry_walk();
|
|
272921
273295
|
});
|
|
272922
273296
|
|
|
272923
|
-
// src/service/persist_dynamic_terms.ts
|
|
272924
|
-
function givenNames(node) {
|
|
272925
|
-
const names = new Set;
|
|
272926
|
-
collectGivenRefs(node, names);
|
|
272927
|
-
return [...names];
|
|
272928
|
-
}
|
|
272929
|
-
function readsAGiven(node, seen, depth = 0) {
|
|
272930
|
-
if (depth > MAX_WALK_DEPTH) {
|
|
272931
|
-
throw new Error("given walk exceeded max depth");
|
|
272932
|
-
}
|
|
272933
|
-
if (node === null || typeof node !== "object")
|
|
272934
|
-
return false;
|
|
272935
|
-
if (seen.has(node))
|
|
272936
|
-
return false;
|
|
272937
|
-
seen.add(node);
|
|
272938
|
-
if (Array.isArray(node)) {
|
|
272939
|
-
return node.some((item) => readsAGiven(item, seen, depth + 1));
|
|
272940
|
-
}
|
|
272941
|
-
const record = node;
|
|
272942
|
-
if (record.node === "given" || record.node === "givenReference")
|
|
272943
|
-
return true;
|
|
272944
|
-
if (record.givenRef !== undefined)
|
|
272945
|
-
return true;
|
|
272946
|
-
const summary = record.refSummary;
|
|
272947
|
-
if (summary?.givenUsage && summary.givenUsage.length > 0)
|
|
272948
|
-
return true;
|
|
272949
|
-
if (Array.isArray(record.givenUsage) && record.givenUsage.length > 0) {
|
|
272950
|
-
return true;
|
|
272951
|
-
}
|
|
272952
|
-
return Object.values(record).some((v) => readsAGiven(v, seen, depth + 1));
|
|
272953
|
-
}
|
|
272954
|
-
function buildSubstitutesAGiven(persistSource) {
|
|
272955
|
-
try {
|
|
272956
|
-
const def = persistSource._sourceDef;
|
|
272957
|
-
if (def === null || typeof def !== "object") {
|
|
272958
|
-
throw new Error("compiled source definition is not readable");
|
|
272959
|
-
}
|
|
272960
|
-
const query = def.query;
|
|
272961
|
-
if (query !== null && typeof query === "object") {
|
|
272962
|
-
const usage = query.givenUsage;
|
|
272963
|
-
if (Array.isArray(usage) && usage.length > 0)
|
|
272964
|
-
return true;
|
|
272965
|
-
if (argumentBindsAGiven(query))
|
|
272966
|
-
return true;
|
|
272967
|
-
if (Array.isArray(usage))
|
|
272968
|
-
return false;
|
|
272969
|
-
}
|
|
272970
|
-
const {
|
|
272971
|
-
filterList: _readTimeFilters,
|
|
272972
|
-
fields: _readTimeFields,
|
|
272973
|
-
...relation
|
|
272974
|
-
} = def;
|
|
272975
|
-
return readsAGiven(relation, new WeakSet);
|
|
272976
|
-
} catch {
|
|
272977
|
-
return true;
|
|
272978
|
-
}
|
|
272979
|
-
}
|
|
272980
|
-
function argumentBindsAGiven(node, depth = 0) {
|
|
272981
|
-
if (depth > MAX_WALK_DEPTH) {
|
|
272982
|
-
throw new Error("argument walk exceeded max depth");
|
|
272983
|
-
}
|
|
272984
|
-
if (node === null || typeof node !== "object")
|
|
272985
|
-
return false;
|
|
272986
|
-
if (Array.isArray(node)) {
|
|
272987
|
-
return node.some((item) => argumentBindsAGiven(item, depth + 1));
|
|
272988
|
-
}
|
|
272989
|
-
for (const [key, value] of Object.entries(node)) {
|
|
272990
|
-
if ((key === "arguments" || key === "sourceArguments") && readsAGiven(value, new WeakSet)) {
|
|
272991
|
-
return true;
|
|
272992
|
-
}
|
|
272993
|
-
if (argumentBindsAGiven(value, depth + 1))
|
|
272994
|
-
return true;
|
|
272995
|
-
}
|
|
272996
|
-
return false;
|
|
272997
|
-
}
|
|
272998
|
-
function classifyDynamicTerms(persistSource) {
|
|
272999
|
-
if (buildSubstitutesAGiven(persistSource)) {
|
|
273000
|
-
return {
|
|
273001
|
-
ok: false,
|
|
273002
|
-
reason: "given_in_persisted_query",
|
|
273003
|
-
detail: `a given is read while the persisted relation is BUILT, so its ` + `value is substituted at build time — from the declaration's ` + `default, the only value available then — and every caller is ` + `served that one slice. Move the given out of the persisted query ` + `and into the source's extend block (\`where: …\`), where it is ` + `left out of the build and applied per caller when the artifact ` + `is read`
|
|
273004
|
-
};
|
|
273005
|
-
}
|
|
273006
|
-
let def;
|
|
273007
|
-
try {
|
|
273008
|
-
def = persistSource._sourceDef;
|
|
273009
|
-
if (def === null || typeof def !== "object") {
|
|
273010
|
-
throw new Error("compiled source definition is not readable");
|
|
273011
|
-
}
|
|
273012
|
-
} catch (err) {
|
|
273013
|
-
return {
|
|
273014
|
-
ok: false,
|
|
273015
|
-
reason: "given_in_persisted_query",
|
|
273016
|
-
detail: `its given usage could not be determined ` + `(${err instanceof Error ? err.message : String(err)}), so the ` + `publisher cannot prove which givens the build would bake in`
|
|
273017
|
-
};
|
|
273018
|
-
}
|
|
273019
|
-
try {
|
|
273020
|
-
const fieldRefusal = classifyFields(def.fields);
|
|
273021
|
-
if (fieldRefusal)
|
|
273022
|
-
return fieldRefusal;
|
|
273023
|
-
const terms = collectDynamicTerms(def.filterList);
|
|
273024
|
-
const {
|
|
273025
|
-
filterList: _accounted,
|
|
273026
|
-
fields: _alsoAccounted,
|
|
273027
|
-
query: _cleared,
|
|
273028
|
-
...rest
|
|
273029
|
-
} = def;
|
|
273030
|
-
if (readsAGiven(rest, new WeakSet)) {
|
|
273031
|
-
return {
|
|
273032
|
-
ok: false,
|
|
273033
|
-
reason: "given_in_persisted_query",
|
|
273034
|
-
detail: `a given sits in a part of the source this pass cannot place ` + `— it is neither an extend-block \`where:\` nor a field ` + `declared on the source — so the publisher cannot prove the ` + `build leaves it out`
|
|
273035
|
-
};
|
|
273036
|
-
}
|
|
273037
|
-
return { ok: true, terms };
|
|
273038
|
-
} catch (err) {
|
|
273039
|
-
return {
|
|
273040
|
-
ok: false,
|
|
273041
|
-
reason: "given_in_persisted_query",
|
|
273042
|
-
detail: `its given usage could not be determined ` + `(${err instanceof Error ? err.message : String(err)}), so the ` + `publisher cannot prove the build leaves every given out`
|
|
273043
|
-
};
|
|
273044
|
-
}
|
|
273045
|
-
}
|
|
273046
|
-
function classifyFields(fields) {
|
|
273047
|
-
if (!Array.isArray(fields))
|
|
273048
|
-
return;
|
|
273049
|
-
for (const field of fields) {
|
|
273050
|
-
if (field === null || typeof field !== "object")
|
|
273051
|
-
continue;
|
|
273052
|
-
const f = field;
|
|
273053
|
-
const label = typeof f.as === "string" ? f.as : typeof f.name === "string" ? f.name : "(unnamed)";
|
|
273054
|
-
if (f.join !== undefined) {
|
|
273055
|
-
if (readsAGiven(f.onExpression, new WeakSet)) {
|
|
273056
|
-
return {
|
|
273057
|
-
ok: false,
|
|
273058
|
-
reason: "dynamic_join",
|
|
273059
|
-
detail: `the join '${label}' has a given in its \`on:\` condition, ` + `which decides which rows join per caller`
|
|
273060
|
-
};
|
|
273061
|
-
}
|
|
273062
|
-
if (readsAGiven(f, new WeakSet)) {
|
|
273063
|
-
return {
|
|
273064
|
-
ok: false,
|
|
273065
|
-
reason: "dynamic_joined_where",
|
|
273066
|
-
detail: `the source joined as '${label}' is itself scoped by a ` + `given. Enter through a non-persisted extension that ` + `declares the join instead, so the given term is part of ` + `the query rather than of the artifact`
|
|
273067
|
-
};
|
|
273068
|
-
}
|
|
273069
|
-
continue;
|
|
273070
|
-
}
|
|
273071
|
-
if (readsAGiven(f, new WeakSet)) {
|
|
273072
|
-
return {
|
|
273073
|
-
ok: false,
|
|
273074
|
-
reason: "dynamic_projection",
|
|
273075
|
-
detail: `the field '${label}' reads a given, so its value would ` + `differ per caller`
|
|
273076
|
-
};
|
|
273077
|
-
}
|
|
273078
|
-
}
|
|
273079
|
-
return;
|
|
273080
|
-
}
|
|
273081
|
-
function localFieldNames(entry) {
|
|
273082
|
-
const usage = entry?.refSummary?.fieldUsage;
|
|
273083
|
-
if (!Array.isArray(usage))
|
|
273084
|
-
return [];
|
|
273085
|
-
const names = new Set;
|
|
273086
|
-
for (const use of usage) {
|
|
273087
|
-
const path9 = use?.path;
|
|
273088
|
-
if (!Array.isArray(path9) || path9.length !== 1 || typeof path9[0] !== "string") {
|
|
273089
|
-
return [];
|
|
273090
|
-
}
|
|
273091
|
-
names.add(path9[0]);
|
|
273092
|
-
}
|
|
273093
|
-
return [...names];
|
|
273094
|
-
}
|
|
273095
|
-
function readTimeDynamicTerms(persistSource) {
|
|
273096
|
-
try {
|
|
273097
|
-
const def = persistSource._sourceDef;
|
|
273098
|
-
return collectDynamicTerms(def?.filterList);
|
|
273099
|
-
} catch {
|
|
273100
|
-
return [{ code: "<unreadable>", givens: [], columns: [] }];
|
|
273101
|
-
}
|
|
273102
|
-
}
|
|
273103
|
-
function collectDynamicTerms(filterList) {
|
|
273104
|
-
if (!Array.isArray(filterList))
|
|
273105
|
-
return [];
|
|
273106
|
-
const terms = [];
|
|
273107
|
-
for (const entry of filterList) {
|
|
273108
|
-
if (entry === null || typeof entry !== "object")
|
|
273109
|
-
continue;
|
|
273110
|
-
if (!readsAGiven(entry, new WeakSet))
|
|
273111
|
-
continue;
|
|
273112
|
-
const code = entry.code;
|
|
273113
|
-
terms.push({
|
|
273114
|
-
code: typeof code === "string" ? code : "",
|
|
273115
|
-
givens: givenNames(entry),
|
|
273116
|
-
columns: localFieldNames(entry)
|
|
273117
|
-
});
|
|
273118
|
-
}
|
|
273119
|
-
return terms;
|
|
273120
|
-
}
|
|
273121
|
-
var MAX_WALK_DEPTH = 200;
|
|
273122
|
-
var init_persist_dynamic_terms = __esm(() => {
|
|
273123
|
-
init_given();
|
|
273124
|
-
});
|
|
273125
|
-
|
|
273126
273297
|
// src/service/incremental_declaration.ts
|
|
273127
273298
|
function safeTag(source) {
|
|
273128
273299
|
try {
|
|
@@ -273623,7 +273794,7 @@ var init_preaggregation_compile = __esm(() => {
|
|
|
273623
273794
|
});
|
|
273624
273795
|
|
|
273625
273796
|
// src/service/build_plan.ts
|
|
273626
|
-
import { Annotations as
|
|
273797
|
+
import { Annotations as Annotations5 } from "@malloydata/malloy";
|
|
273627
273798
|
function deriveColumns(persistSource) {
|
|
273628
273799
|
try {
|
|
273629
273800
|
return persistSource._explore.intrinsicFields.filter((f) => f.isAtomicField()).map((f) => ({
|
|
@@ -273638,6 +273809,9 @@ function deriveColumns(persistSource) {
|
|
|
273638
273809
|
return [];
|
|
273639
273810
|
}
|
|
273640
273811
|
}
|
|
273812
|
+
function wireTerm(term) {
|
|
273813
|
+
return { code: term.code, givens: term.givens };
|
|
273814
|
+
}
|
|
273641
273815
|
function planDynamicFields(source, annotationFields) {
|
|
273642
273816
|
const out = {};
|
|
273643
273817
|
const partition = resolvePartitionColumns(source, annotationFields);
|
|
@@ -273646,9 +273820,13 @@ function planDynamicFields(source, annotationFields) {
|
|
|
273646
273820
|
}
|
|
273647
273821
|
const classified = classifyDynamicTerms(source);
|
|
273648
273822
|
if (classified.ok && classified.terms.length > 0) {
|
|
273649
|
-
out.strippedTerms = classified.terms.map(
|
|
273650
|
-
|
|
273651
|
-
|
|
273823
|
+
out.strippedTerms = classified.terms.map(wireTerm);
|
|
273824
|
+
}
|
|
273825
|
+
if (classified.ok && classified.joinedTerms?.length) {
|
|
273826
|
+
out.joinedTerms = classified.joinedTerms.map((joined) => ({
|
|
273827
|
+
alias: joined.alias,
|
|
273828
|
+
source: joined.source,
|
|
273829
|
+
terms: joined.terms.map(wireTerm)
|
|
273652
273830
|
}));
|
|
273653
273831
|
}
|
|
273654
273832
|
return out;
|
|
@@ -273850,7 +274028,7 @@ function detectDroppedPersistSources(malloyModel, recognizedNames) {
|
|
|
273850
274028
|
continue;
|
|
273851
274029
|
let isPersist = false;
|
|
273852
274030
|
try {
|
|
273853
|
-
isPersist = new
|
|
274031
|
+
isPersist = new Annotations5(def.annotations).parseAsTag("@").tag.has("persist");
|
|
273854
274032
|
} catch {
|
|
273855
274033
|
continue;
|
|
273856
274034
|
}
|
|
@@ -274006,11 +274184,11 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
|
|
|
274006
274184
|
continue;
|
|
274007
274185
|
try {
|
|
274008
274186
|
const annotationFields = deriveAnnotationFields(source);
|
|
274009
|
-
const
|
|
274187
|
+
const declaresStorage2 = !!annotationFields.storage;
|
|
274010
274188
|
const rollup = options?.preaggregatePlans?.[sourceID];
|
|
274011
274189
|
if (!rollup) {
|
|
274012
274190
|
try {
|
|
274013
|
-
if (
|
|
274191
|
+
if (declaresStorage2) {
|
|
274014
274192
|
assertMaterializationEligible(source, deriveAnnotationFields(source));
|
|
274015
274193
|
} else {
|
|
274016
274194
|
assertColocatedPersistNotAuthorizeGated(source, source.name, "persist", options?.sourceGateOutcomes?.[sourceID], annotationFields);
|
|
@@ -274022,7 +274200,7 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
|
|
|
274022
274200
|
name: source.name,
|
|
274023
274201
|
sourceID: source.sourceID,
|
|
274024
274202
|
modelPath: sourceModelPaths?.[sourceID],
|
|
274025
|
-
tier:
|
|
274203
|
+
tier: declaresStorage2 ? "storage" : "colocated",
|
|
274026
274204
|
reason: err.reason || "authorize",
|
|
274027
274205
|
message: errMessage(err)
|
|
274028
274206
|
};
|
|
@@ -274032,7 +274210,7 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
|
|
|
274032
274210
|
let storageRefused = false;
|
|
274033
274211
|
try {
|
|
274034
274212
|
assertColocatedPersistNotAuthorizeGated(source, source.name, "preaggregate", options?.sourceGateOutcomes?.[sourceID], annotationFields);
|
|
274035
|
-
if (
|
|
274213
|
+
if (declaresStorage2) {
|
|
274036
274214
|
storageRefused = true;
|
|
274037
274215
|
assertMaterializationEligible(source, deriveAnnotationFields(source));
|
|
274038
274216
|
storageRefused = false;
|
|
@@ -274436,9 +274614,11 @@ function extractRunTargetSourceName(query) {
|
|
|
274436
274614
|
}
|
|
274437
274615
|
function buildSourceAliasMap(query) {
|
|
274438
274616
|
const aliasOf = new Map;
|
|
274439
|
-
const
|
|
274617
|
+
const text = stripMalloyCommentsAndLiterals(query);
|
|
274618
|
+
const ident = String.raw`(?:\x60([^\x60]+)\x60|([\p{L}\p{N}_]+))`;
|
|
274619
|
+
const declRe = new RegExp(String.raw`source\s*:\s*${ident}(?:\s*\([^)]*\))?\s+is\s*\(?\s*${ident}`, "gu");
|
|
274440
274620
|
let match;
|
|
274441
|
-
while ((match = declRe.exec(
|
|
274621
|
+
while ((match = declRe.exec(text)) !== null) {
|
|
274442
274622
|
aliasOf.set(match[1] ?? match[2], match[3] ?? match[4]);
|
|
274443
274623
|
}
|
|
274444
274624
|
return aliasOf;
|
|
@@ -274489,8 +274669,9 @@ function stripMalloyCommentsAndLiterals(text) {
|
|
|
274489
274669
|
}
|
|
274490
274670
|
return out.join("");
|
|
274491
274671
|
}
|
|
274492
|
-
function buildDerivationBaseMap(
|
|
274672
|
+
function buildDerivationBaseMap(query) {
|
|
274493
274673
|
const basesOf = new Map;
|
|
274674
|
+
const text = stripMalloyCommentsAndLiterals(query);
|
|
274494
274675
|
const ident = String.raw`(?:\x60([^\x60]+)\x60|([\p{L}\p{N}_]+))`;
|
|
274495
274676
|
const declRe = new RegExp(String.raw`(?:source|query)\s*:\s*${ident}(?:\s*\([^)]*\))?\s+is\s*\(?\s*${ident}`, "gu");
|
|
274496
274677
|
let match;
|
|
@@ -275080,8 +275261,8 @@ import {
|
|
|
275080
275261
|
InMemoryURLReader as InMemoryURLReader2,
|
|
275081
275262
|
isSourceDef as isSourceDef7,
|
|
275082
275263
|
MalloyConfig as MalloyConfig2,
|
|
275083
|
-
MalloyError as
|
|
275084
|
-
Annotations as
|
|
275264
|
+
MalloyError as MalloyError4,
|
|
275265
|
+
Annotations as Annotations6,
|
|
275085
275266
|
modelDefToModelInfo as modelDefToModelInfo2,
|
|
275086
275267
|
Runtime as Runtime2
|
|
275087
275268
|
} from "@malloydata/malloy";
|
|
@@ -275956,7 +276137,7 @@ var init_model = __esm(() => {
|
|
|
275956
276137
|
if (error instanceof Error && error.stack) {
|
|
275957
276138
|
logger.error("Error stack", error.stack);
|
|
275958
276139
|
}
|
|
275959
|
-
if (error instanceof
|
|
276140
|
+
if (error instanceof MalloyError4) {
|
|
275960
276141
|
const problems = error.problems;
|
|
275961
276142
|
for (const problem of problems) {
|
|
275962
276143
|
logger.error("Problem", problem);
|
|
@@ -276112,17 +276293,15 @@ var init_model = __esm(() => {
|
|
|
276112
276293
|
return this.admittedByPackage(name, this.queryBoundary.packageCuratedSources);
|
|
276113
276294
|
}
|
|
276114
276295
|
isCuratedQuery(name) {
|
|
276115
|
-
if ((this.getQueries() ?? []).some((q) => q.name === name))
|
|
276296
|
+
if (!this.isNotebook() && (this.getQueries() ?? []).some((q) => q.name === name))
|
|
276116
276297
|
return true;
|
|
276117
276298
|
return this.admittedByPackage(name, this.queryBoundary.packageCuratedQueries);
|
|
276118
276299
|
}
|
|
276119
276300
|
assertQueryBoundaryEarly(sourceName, queryName, query) {
|
|
276120
|
-
if (this.modelPath.endsWith(NOTEBOOK_FILE_SUFFIX))
|
|
276121
|
-
return "cleared";
|
|
276122
276301
|
const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
|
|
276123
276302
|
if (mode === "all" || !exploresDeclared)
|
|
276124
276303
|
return "cleared";
|
|
276125
|
-
if (!isQueryEntryPoint) {
|
|
276304
|
+
if (!isQueryEntryPoint && !this.isNotebook()) {
|
|
276126
276305
|
throw new NotQueryableError(`No queryable model "${this.modelPath}".`);
|
|
276127
276306
|
}
|
|
276128
276307
|
if (queryName && !query) {
|
|
@@ -276140,18 +276319,16 @@ var init_model = __esm(() => {
|
|
|
276140
276319
|
if (query) {
|
|
276141
276320
|
const target = extractRunTargetSourceName(query);
|
|
276142
276321
|
if (target && !this.isCuratedSource(target) && !this.derivesFromCurated(target, query) && this.sources?.some((s) => s.name === target)) {
|
|
276143
|
-
throw new NotQueryableError(
|
|
276322
|
+
throw new NotQueryableError("Query target is not queryable.");
|
|
276144
276323
|
}
|
|
276145
276324
|
}
|
|
276146
276325
|
return "deferred";
|
|
276147
276326
|
}
|
|
276148
276327
|
assertQueryBoundaryCompiled(compiledSource, query) {
|
|
276149
|
-
if (this.modelPath.endsWith(NOTEBOOK_FILE_SUFFIX))
|
|
276150
|
-
return;
|
|
276151
276328
|
const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
|
|
276152
276329
|
if (mode === "all" || !exploresDeclared)
|
|
276153
276330
|
return;
|
|
276154
|
-
if (!isQueryEntryPoint) {
|
|
276331
|
+
if (!isQueryEntryPoint && !this.isNotebook()) {
|
|
276155
276332
|
throw new NotQueryableError(`No queryable model "${this.modelPath}".`);
|
|
276156
276333
|
}
|
|
276157
276334
|
if (compiledSource) {
|
|
@@ -276168,7 +276345,12 @@ var init_model = __esm(() => {
|
|
|
276168
276345
|
return;
|
|
276169
276346
|
this.assertQueryBoundaryCompiled(await this.resolveAuthorizeSourceFromRunnable(runnable), query);
|
|
276170
276347
|
}
|
|
276348
|
+
isNotebook() {
|
|
276349
|
+
return this.modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
|
|
276350
|
+
}
|
|
276171
276351
|
ownCuratedSourceNames() {
|
|
276352
|
+
if (this.isNotebook())
|
|
276353
|
+
return new Set;
|
|
276172
276354
|
return new Set((this.getSources() ?? []).map((s) => s.name).filter((n) => n !== undefined));
|
|
276173
276355
|
}
|
|
276174
276356
|
derivesFromCurated(name, query) {
|
|
@@ -276381,6 +276563,44 @@ var init_model = __esm(() => {
|
|
|
276381
276563
|
async compileServeShape(enriched, rollupGroups = [], isRetry = false) {
|
|
276382
276564
|
const tiers = buildServeShapeTiers(rollupGroups);
|
|
276383
276565
|
const nothingToEscalate = rollupGroups.length === 0 && !enriched.some((b) => (b.refinements ?? []).length > 0);
|
|
276566
|
+
let lifts = this.liftedDerivedSources(enriched);
|
|
276567
|
+
if (lifts.length > 0) {
|
|
276568
|
+
const probe = async (candidate) => {
|
|
276569
|
+
const m = this.buildServeShapeMaterializer(enriched, tiers[0].groups, candidate);
|
|
276570
|
+
try {
|
|
276571
|
+
await m.getModel();
|
|
276572
|
+
return m;
|
|
276573
|
+
} catch (err) {
|
|
276574
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
276575
|
+
}
|
|
276576
|
+
};
|
|
276577
|
+
const withAll = await probe(lifts);
|
|
276578
|
+
if (!(withAll instanceof Error))
|
|
276579
|
+
return withAll;
|
|
276580
|
+
const withNone = await probe([]);
|
|
276581
|
+
const kept = [];
|
|
276582
|
+
let best;
|
|
276583
|
+
if (!(withNone instanceof Error)) {
|
|
276584
|
+
best = withNone;
|
|
276585
|
+
for (const lift of lifts) {
|
|
276586
|
+
const attempt = await probe([...kept, lift]);
|
|
276587
|
+
if (attempt instanceof Error)
|
|
276588
|
+
continue;
|
|
276589
|
+
kept.push(lift);
|
|
276590
|
+
best = attempt;
|
|
276591
|
+
}
|
|
276592
|
+
}
|
|
276593
|
+
if (kept.length < lifts.length)
|
|
276594
|
+
recordServeShapeTierDrop("lifts");
|
|
276595
|
+
logger.warn("Storage serve shape failed to compile with its lifted entry points; the ones that do not compile serve live", {
|
|
276596
|
+
model: this.modelPath,
|
|
276597
|
+
dropped: lifts.filter((l) => !kept.includes(l)).map((l) => l.sourceName),
|
|
276598
|
+
error: withAll.message
|
|
276599
|
+
});
|
|
276600
|
+
if (best)
|
|
276601
|
+
return best;
|
|
276602
|
+
lifts = [];
|
|
276603
|
+
}
|
|
276384
276604
|
const lastTier = tiers.length - 1;
|
|
276385
276605
|
for (let tier = 0;tier <= lastTier; tier++) {
|
|
276386
276606
|
const { keep, groups } = tiers[tier];
|
|
@@ -276388,7 +276608,7 @@ var init_model = __esm(() => {
|
|
|
276388
276608
|
...b,
|
|
276389
276609
|
refinements: b.refinements.filter((r) => keep.has(r.kind))
|
|
276390
276610
|
} : b);
|
|
276391
|
-
const materializer = this.buildServeShapeMaterializer(shaped, groups, tier === 0 ?
|
|
276611
|
+
const materializer = this.buildServeShapeMaterializer(shaped, groups, tier === 0 ? lifts : []);
|
|
276392
276612
|
if (tier === lastTier) {
|
|
276393
276613
|
try {
|
|
276394
276614
|
await materializer.getModel();
|
|
@@ -276515,10 +276735,17 @@ var init_model = __esm(() => {
|
|
|
276515
276735
|
}
|
|
276516
276736
|
serveBindingsWithRefinements(bindings) {
|
|
276517
276737
|
const { contents, sourceNameById, liftText } = this.authorModelLift();
|
|
276518
|
-
const
|
|
276519
|
-
|
|
276738
|
+
const onShape = bindings.map((b) => ({
|
|
276739
|
+
...b,
|
|
276740
|
+
schema: narrowSchemaToPublic(b.schema, contents?.[b.sourceName]?.fields)
|
|
276741
|
+
})).filter((b) => b.schema.length > 0);
|
|
276742
|
+
const { kept, withheld } = withholdUnreproducibleCallerScopedJoins(onShape, contents, sourceNameById);
|
|
276743
|
+
if (withheld.length > 0) {
|
|
276744
|
+
logger.warn("Withheld storage serve bindings whose caller-scoped join cannot be reproduced; those sources serve live", { model: this.modelPath, withheld });
|
|
276745
|
+
}
|
|
276746
|
+
const materializedSourceNames = new Set(kept.map((b) => b.sourceName));
|
|
276747
|
+
return kept.map((b) => {
|
|
276520
276748
|
const fields = contents?.[b.sourceName]?.fields;
|
|
276521
|
-
const schema = narrowSchemaToPublic(b.schema, fields);
|
|
276522
276749
|
const refinements = [
|
|
276523
276750
|
...extractJoins(fields, {
|
|
276524
276751
|
sourceNameById,
|
|
@@ -276529,13 +276756,13 @@ var init_model = __esm(() => {
|
|
|
276529
276756
|
...extractSourceFilters(contents?.[b.sourceName]?.filterList),
|
|
276530
276757
|
...extractViews(fields, liftText)
|
|
276531
276758
|
];
|
|
276532
|
-
return { ...b,
|
|
276533
|
-
})
|
|
276759
|
+
return { ...b, refinements };
|
|
276760
|
+
});
|
|
276534
276761
|
}
|
|
276535
276762
|
async getQueryResults(sourceName, queryName, query, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput, responseShape = "full", bypassAuthorize = false) {
|
|
276536
276763
|
const startTime = performance.now();
|
|
276537
276764
|
if (this.compilationError) {
|
|
276538
|
-
if (this.compilationError instanceof
|
|
276765
|
+
if (this.compilationError instanceof MalloyError4 || this.compilationError instanceof ModelCompilationError) {
|
|
276539
276766
|
throw this.compilationError;
|
|
276540
276767
|
}
|
|
276541
276768
|
throw new BadRequestError(`Model compilation failed: ${this.compilationError.message}`);
|
|
@@ -276660,7 +276887,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
276660
276887
|
if (error instanceof FilterValidationError) {
|
|
276661
276888
|
throw new BadRequestError(error.message);
|
|
276662
276889
|
}
|
|
276663
|
-
if (error instanceof
|
|
276890
|
+
if (error instanceof MalloyError4) {
|
|
276664
276891
|
throw error;
|
|
276665
276892
|
}
|
|
276666
276893
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -276745,7 +276972,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
276745
276972
|
});
|
|
276746
276973
|
throw new BadRequestError(err instanceof Error ? err.message : String(err));
|
|
276747
276974
|
}
|
|
276748
|
-
if (err instanceof
|
|
276975
|
+
if (err instanceof MalloyError4) {
|
|
276749
276976
|
throw err;
|
|
276750
276977
|
}
|
|
276751
276978
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
@@ -276881,7 +277108,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
276881
277108
|
if (!this.modelDef)
|
|
276882
277109
|
return;
|
|
276883
277110
|
try {
|
|
276884
|
-
return new
|
|
277111
|
+
return new Annotations6(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
|
|
276885
277112
|
} catch {
|
|
276886
277113
|
return;
|
|
276887
277114
|
}
|
|
@@ -276896,7 +277123,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
276896
277123
|
const def = entry;
|
|
276897
277124
|
if (!def.annotations)
|
|
276898
277125
|
return;
|
|
276899
|
-
return new
|
|
277126
|
+
return new Annotations6(def.annotations).parseAsTag("@").tag;
|
|
276900
277127
|
} catch {
|
|
276901
277128
|
return;
|
|
276902
277129
|
}
|
|
@@ -277041,7 +277268,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
277041
277268
|
});
|
|
277042
277269
|
throw new BadRequestError(error instanceof Error ? error.message : String(error));
|
|
277043
277270
|
}
|
|
277044
|
-
if (error instanceof
|
|
277271
|
+
if (error instanceof MalloyError4) {
|
|
277045
277272
|
throw error;
|
|
277046
277273
|
}
|
|
277047
277274
|
if (error instanceof PayloadTooLargeError) {
|
|
@@ -302208,7 +302435,7 @@ class QueryController {
|
|
|
302208
302435
|
const p = await environment.getPackage(packageName, false);
|
|
302209
302436
|
const model = p.getModel(modelPath);
|
|
302210
302437
|
if (!model) {
|
|
302211
|
-
throw new ModelNotFoundError(
|
|
302438
|
+
throw new ModelNotFoundError(`No queryable model "${modelPath}".`);
|
|
302212
302439
|
} else {
|
|
302213
302440
|
const {
|
|
302214
302441
|
result,
|
|
@@ -309249,18 +309476,59 @@ class StorageManager {
|
|
|
309249
309476
|
|
|
309250
309477
|
// src/service/environment.ts
|
|
309251
309478
|
init_telemetry();
|
|
309252
|
-
import { MalloyError as
|
|
309479
|
+
import { MalloyError as MalloyError6, Runtime as Runtime3 } from "@malloydata/malloy";
|
|
309253
309480
|
init_constants();
|
|
309254
309481
|
init_errors();
|
|
309255
309482
|
init_authorize();
|
|
309256
|
-
init_authorize_metrics();
|
|
309257
|
-
init_config();
|
|
309258
|
-
init_logger();
|
|
309259
|
-
init_pg_helpers();
|
|
309260
309483
|
import crypto4 from "crypto";
|
|
309261
309484
|
import * as fs8 from "fs";
|
|
309262
309485
|
import * as path12 from "path";
|
|
309263
309486
|
import { fileURLToPath as fileURLToPath7, pathToFileURL as pathToFileURL2 } from "url";
|
|
309487
|
+
|
|
309488
|
+
// src/service/compile_restriction.ts
|
|
309489
|
+
init_errors();
|
|
309490
|
+
import { Malloy, MalloyError as MalloyError3 } from "@malloydata/malloy";
|
|
309491
|
+
var RESTRICTED_CONSTRUCT_CODE = "restricted-construct-forbidden";
|
|
309492
|
+
var SYNTAX_ERROR_CODE = "syntax-error";
|
|
309493
|
+
function isParseFailure(problem) {
|
|
309494
|
+
return problem.code === SYNTAX_ERROR_CODE && problem.severity === "error";
|
|
309495
|
+
}
|
|
309496
|
+
function restrictedRejections(problems) {
|
|
309497
|
+
return problems.filter((problem) => problem.code === RESTRICTED_CONSTRUCT_CODE);
|
|
309498
|
+
}
|
|
309499
|
+
async function assertNoRestrictedConstructs(runtime, model, source) {
|
|
309500
|
+
let problems;
|
|
309501
|
+
try {
|
|
309502
|
+
const compiled = await Malloy.compile({
|
|
309503
|
+
source,
|
|
309504
|
+
model,
|
|
309505
|
+
restrictedMode: true,
|
|
309506
|
+
method: "extendModel",
|
|
309507
|
+
urlReader: runtime.urlReader,
|
|
309508
|
+
connections: runtime.connections,
|
|
309509
|
+
noThrowOnError: true
|
|
309510
|
+
});
|
|
309511
|
+
problems = compiled.problems ?? [];
|
|
309512
|
+
} catch (error) {
|
|
309513
|
+
if (!(error instanceof MalloyError3))
|
|
309514
|
+
throw error;
|
|
309515
|
+
problems = error.problems;
|
|
309516
|
+
}
|
|
309517
|
+
const rejected = restrictedRejections(problems);
|
|
309518
|
+
if (rejected.length === 0) {
|
|
309519
|
+
if (problems.some(isParseFailure)) {
|
|
309520
|
+
throw new CompileRefusedError(`This Malloy cannot be compiled at scope "append": the submitted ` + `text could not be parsed on its own, so it cannot be checked ` + `against the model's published surface. Fix: send text that ` + `stands alone as top-level Malloy -- a complete ` + `\`source:\`/\`query:\`/\`run:\` statement rather than a ` + `continuation of one already in the model.`);
|
|
309521
|
+
}
|
|
309522
|
+
return;
|
|
309523
|
+
}
|
|
309524
|
+
throw new CompileRefusedError(`This Malloy cannot be compiled at scope "append", which validates a ` + `fragment against the model's published surface: ` + `${rejected.map((problem) => problem.message).join(" ")} ` + `Fix: reference the model's own sources. Defining sources and ` + `imports belongs in the model file itself -- see the compile-scope ` + `documentation.`);
|
|
309525
|
+
}
|
|
309526
|
+
|
|
309527
|
+
// src/service/environment.ts
|
|
309528
|
+
init_authorize_metrics();
|
|
309529
|
+
init_config();
|
|
309530
|
+
init_logger();
|
|
309531
|
+
init_pg_helpers();
|
|
309264
309532
|
init_materialization_metrics();
|
|
309265
309533
|
init_path_safety();
|
|
309266
309534
|
init_utils4();
|
|
@@ -309378,7 +309646,7 @@ import {
|
|
|
309378
309646
|
EmptyURLReader,
|
|
309379
309647
|
FixedConnectionMap as FixedConnectionMap2,
|
|
309380
309648
|
MalloyConfig as MalloyConfig3,
|
|
309381
|
-
MalloyError as
|
|
309649
|
+
MalloyError as MalloyError5
|
|
309382
309650
|
} from "@malloydata/malloy";
|
|
309383
309651
|
|
|
309384
309652
|
// src/package_load_metrics.ts
|
|
@@ -310267,7 +310535,7 @@ function toTableNameManifest(entries) {
|
|
|
310267
310535
|
return out;
|
|
310268
310536
|
}
|
|
310269
310537
|
function packageLoadFailureStatus(error) {
|
|
310270
|
-
if (error instanceof ModelCompilationError || error instanceof
|
|
310538
|
+
if (error instanceof ModelCompilationError || error instanceof MalloyError5) {
|
|
310271
310539
|
return "compilation_error";
|
|
310272
310540
|
}
|
|
310273
310541
|
if (error instanceof BadRequestError) {
|
|
@@ -310326,6 +310594,23 @@ class Package {
|
|
|
310326
310594
|
const explores = this.packageMetadata.explores;
|
|
310327
310595
|
return !!(explores && explores.length > 0);
|
|
310328
310596
|
}
|
|
310597
|
+
surfaceWidenedWarning;
|
|
310598
|
+
noteSurfaceChangeFrom(previousSurface) {
|
|
310599
|
+
this.surfaceWidenedWarning = undefined;
|
|
310600
|
+
if (!previousSurface || previousSurface.length === 0 || this.packageMetadata.explores !== undefined) {
|
|
310601
|
+
return;
|
|
310602
|
+
}
|
|
310603
|
+
const message = `This package published "${previousSurface.join('", "')}" before the last ` + `reload and publishes no surface now, so every model in it is listed ` + `and queryable by name again, including the sources that surface was ` + `withholding. A surface file that was deleted or renamed is the usual ` + `cause. If that was intended, nothing to do. If not, restore the file ` + `(under its original name -- the name IS the surface), or declare an ` + `"explores" in publisher.json naming what this package should ` + `publish. To keep the package open deliberately, write ` + `"explores": [], which says so and stops this notice.`;
|
|
310604
|
+
this.surfaceWidenedWarning = message;
|
|
310605
|
+
logger.warn(`Package ${this.packageName} no longer publishes a surface`, {
|
|
310606
|
+
packageName: this.packageName,
|
|
310607
|
+
detail: message
|
|
310608
|
+
});
|
|
310609
|
+
}
|
|
310610
|
+
surfaceIsIndexModel() {
|
|
310611
|
+
const explores = this.packageMetadata.explores;
|
|
310612
|
+
return this.models.has(INDEX_MODEL_NAME) && explores?.length === 1 && explores[0] === INDEX_MODEL_NAME;
|
|
310613
|
+
}
|
|
310329
310614
|
exploreSet() {
|
|
310330
310615
|
const explores = this.packageMetadata.explores;
|
|
310331
310616
|
return explores && explores.length > 0 ? new Set(explores) : null;
|
|
@@ -310430,7 +310715,7 @@ class Package {
|
|
|
310430
310715
|
defaultConnectionName: "duckdb"
|
|
310431
310716
|
}).catch((err) => {
|
|
310432
310717
|
const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
|
|
310433
|
-
if (realError instanceof
|
|
310718
|
+
if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError) {
|
|
310434
310719
|
throw realError;
|
|
310435
310720
|
}
|
|
310436
310721
|
throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`);
|
|
@@ -310665,6 +310950,8 @@ class Package {
|
|
|
310665
310950
|
...this.droppedPersistWarnings(),
|
|
310666
310951
|
...this.persistRefusalWarnings(),
|
|
310667
310952
|
...this.emptyDiscoveryWarnings(),
|
|
310953
|
+
...this.brokenSurfaceWarnings(),
|
|
310954
|
+
...this.surfaceWidenedWarning !== undefined ? [{ message: this.surfaceWidenedWarning }] : [],
|
|
310668
310955
|
...this.persistenceCollisionWarnings().map((message) => ({ message })),
|
|
310669
310956
|
...this.preaggregateAccessWarnings(),
|
|
310670
310957
|
...incrementalPolicyAdvisories(this.incrementalPolicySources),
|
|
@@ -310825,7 +311112,7 @@ class Package {
|
|
|
310825
311112
|
if (entry.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
310826
311113
|
problems.push({
|
|
310827
311114
|
entry,
|
|
310828
|
-
reason: `notebooks are always
|
|
311115
|
+
reason: `notebooks are always listed and cannot be explores. ` + `Fix: remove it, and list a ${MODEL_FILE_SUFFIX} model file instead.`
|
|
310829
311116
|
});
|
|
310830
311117
|
} else if (!malloyModels.has(entry)) {
|
|
310831
311118
|
problems.push({
|
|
@@ -310996,13 +311283,39 @@ class Package {
|
|
|
310996
311283
|
if (model.hasEmptyDiscoverySurface()) {
|
|
310997
311284
|
warnings.push({
|
|
310998
311285
|
model: modelPath,
|
|
310999
|
-
message: `Model "${modelPath}" is
|
|
311286
|
+
message: `Model "${modelPath}" is on this package's discovery ` + `surface but exposes nothing: its export closure surfaces ` + `no sources or named queries (typically an import-only ` + `file, or an export {} that filters everything out). Add ` + `e.g. 'export { source_name }' to surface sources on this ` + `model, or ` + (this.surfaceIsIndexModel() ? `delete the file AND any "explores" entry naming it. ` + `Deleting the file alone returns the package to ` + `listing every model only when no "explores" was ` + `written; where one was, it would be left naming a ` + `model that no longer exists and the package would ` + `list nothing.` : `remove it from explores.`)
|
|
311000
311287
|
});
|
|
311001
311288
|
}
|
|
311002
311289
|
}
|
|
311003
311290
|
return warnings;
|
|
311004
311291
|
}
|
|
311292
|
+
brokenSurfaceWarnings() {
|
|
311293
|
+
const exploreSet = this.exploreSet();
|
|
311294
|
+
if (!exploreSet || exploreSet.size === 0)
|
|
311295
|
+
return [];
|
|
311296
|
+
const surface = Array.from(this.models.entries()).filter(([modelPath]) => modelPath.endsWith(MODEL_FILE_SUFFIX) && exploreSet.has(modelPath));
|
|
311297
|
+
if (surface.length === 0)
|
|
311298
|
+
return [];
|
|
311299
|
+
if (!surface.every(([, model]) => !!model.getCompilationError())) {
|
|
311300
|
+
return [];
|
|
311301
|
+
}
|
|
311302
|
+
const collateral = Array.from(this.models.entries()).filter(([modelPath, model]) => modelPath.endsWith(MODEL_FILE_SUFFIX) && !exploreSet.has(modelPath) && !model.getCompilationError()).length;
|
|
311303
|
+
const [firstPath] = surface[0];
|
|
311304
|
+
const subject = surface.length === 1 ? `Model "${firstPath}" is this package's whole discovery surface ` + `and failed to compile` : `Every model on this package's discovery surface ` + `(${surface.map(([p]) => `"${p}"`).join(", ")}) failed to ` + `compile`;
|
|
311305
|
+
return [
|
|
311306
|
+
{
|
|
311307
|
+
model: firstPath,
|
|
311308
|
+
message: `${subject}, so the package exposes nothing: every model in ` + `it, including the ${collateral} that compiled, is now refused ` + `by name with a 404 that reads as "does not exist". Fix the ` + `compile error${surface.length === 1 ? "" : "s"} to restore ` + `them -- ` + surface.map(([modelPath, model]) => `${modelPath}: ` + `${model.getCompilationError()?.message ?? "unknown error"}`).join("; ")
|
|
311309
|
+
}
|
|
311310
|
+
];
|
|
311311
|
+
}
|
|
311005
311312
|
logEmptyDiscoveryWarnings() {
|
|
311313
|
+
for (const warning of this.brokenSurfaceWarnings()) {
|
|
311314
|
+
logger.warn(`Package ${this.packageName} has a broken discovery surface`, {
|
|
311315
|
+
packageName: this.packageName,
|
|
311316
|
+
detail: warning.message
|
|
311317
|
+
});
|
|
311318
|
+
}
|
|
311006
311319
|
for (const warning of this.emptyDiscoveryWarnings()) {
|
|
311007
311320
|
logger.warn(`Package ${this.packageName} has a blank-looking model`, {
|
|
311008
311321
|
packageName: this.packageName,
|
|
@@ -311075,7 +311388,7 @@ class Package {
|
|
|
311075
311388
|
});
|
|
311076
311389
|
} catch (err) {
|
|
311077
311390
|
const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
|
|
311078
|
-
if (realError instanceof
|
|
311391
|
+
if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError) {
|
|
311079
311392
|
throw realError;
|
|
311080
311393
|
}
|
|
311081
311394
|
throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`);
|
|
@@ -311120,7 +311433,9 @@ class Package {
|
|
|
311120
311433
|
await this.pushPreaggregateServeModels();
|
|
311121
311434
|
this.renderTagWarnings = renderTagWarnings;
|
|
311122
311435
|
this.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
|
|
311436
|
+
const previousSurface = this.packageMetadata.explores;
|
|
311123
311437
|
this.packageMetadata.explores = outcome.packageMetadata.explores;
|
|
311438
|
+
this.noteSurfaceChangeFrom(previousSurface);
|
|
311124
311439
|
this.packageMetadata.queryableSources = outcome.packageMetadata.queryableSources;
|
|
311125
311440
|
this.packageMetadata.manifestLocation = outcome.packageMetadata.manifestLocation ?? null;
|
|
311126
311441
|
this.applyDiscoveryPolicyToModels();
|
|
@@ -311282,6 +311597,29 @@ class Package {
|
|
|
311282
311597
|
});
|
|
311283
311598
|
}
|
|
311284
311599
|
}
|
|
311600
|
+
lintTilesAgainstSurface(modelPath, manifest) {
|
|
311601
|
+
const model = this.models.get(modelPath);
|
|
311602
|
+
if (!model || !manifest.tiles)
|
|
311603
|
+
return [];
|
|
311604
|
+
const listedModel = (this.packageMetadata.explores ?? []).find((entry) => entry.endsWith(MODEL_FILE_SUFFIX) && !isDashboardModelPath(entry));
|
|
311605
|
+
const importInto = listedModel ? `a listed file such as "${listedModel}"` : `a file you list there`;
|
|
311606
|
+
const findings = [];
|
|
311607
|
+
for (const tile of manifest.tiles) {
|
|
311608
|
+
try {
|
|
311609
|
+
model.assertQueryBoundaryEarly(undefined, undefined, `run: ${tile.query}`);
|
|
311610
|
+
} catch (error) {
|
|
311611
|
+
if (!(error instanceof NotQueryableError))
|
|
311612
|
+
throw error;
|
|
311613
|
+
findings.push({
|
|
311614
|
+
model: modelPath,
|
|
311615
|
+
subject: manifest.name,
|
|
311616
|
+
message: `tile "${tile.query}" reads a source this package's surface ` + `does not publish, so the tile answers 404 once served, ` + `although the file compiles. Listing a file publishes what ` + `it declares, not what it imports. Fix: add the file that ` + `declares the source to 'explores', keeping the entries ` + `already there, or import it into ${importInto} and add ` + `it to that file's export { … }.`,
|
|
311617
|
+
severity: "error"
|
|
311618
|
+
});
|
|
311619
|
+
}
|
|
311620
|
+
}
|
|
311621
|
+
return findings;
|
|
311622
|
+
}
|
|
311285
311623
|
async lintDashboards(factsByPath, allFacts, heldBack, unconventionalSlugs, knownSlugs, droppedByError) {
|
|
311286
311624
|
const warnings = [];
|
|
311287
311625
|
const withheldDrills = new Map;
|
|
@@ -311315,10 +311653,11 @@ class Package {
|
|
|
311315
311653
|
});
|
|
311316
311654
|
}
|
|
311317
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.`;
|
|
311318
311657
|
warnings.push({
|
|
311319
311658
|
model: modelPath,
|
|
311320
311659
|
subject: name,
|
|
311321
|
-
message: `is a dashboard, but "${modelPath}" is not
|
|
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.`,
|
|
311322
311661
|
severity: "warn"
|
|
311323
311662
|
});
|
|
311324
311663
|
}
|
|
@@ -311328,6 +311667,11 @@ class Package {
|
|
|
311328
311667
|
for (const finding of findings) {
|
|
311329
311668
|
warnings.push({ model: modelPath, ...finding });
|
|
311330
311669
|
}
|
|
311670
|
+
if (manifest) {
|
|
311671
|
+
for (const finding of this.lintTilesAgainstSurface(modelPath, manifest)) {
|
|
311672
|
+
warnings.push(finding);
|
|
311673
|
+
}
|
|
311674
|
+
}
|
|
311331
311675
|
}
|
|
311332
311676
|
const drillFacts = Array.from(allFacts.values());
|
|
311333
311677
|
for (const finding of withheldDrills.values()) {
|
|
@@ -311576,6 +311920,15 @@ function getPackageAdmissionRejectionsCounter() {
|
|
|
311576
311920
|
});
|
|
311577
311921
|
return packageAdmissionRejectionsCounter;
|
|
311578
311922
|
}
|
|
311923
|
+
var compileRefusalsCounter = null;
|
|
311924
|
+
function getCompileRefusalsCounter() {
|
|
311925
|
+
if (compileRefusalsCounter)
|
|
311926
|
+
return compileRefusalsCounter;
|
|
311927
|
+
compileRefusalsCounter = publisherMeter().createCounter("publisher_compile_refusals_total", {
|
|
311928
|
+
description: "Compiles refused at append scope, labelled by reason and environment"
|
|
311929
|
+
});
|
|
311930
|
+
return compileRefusalsCounter;
|
|
311931
|
+
}
|
|
311579
311932
|
var COMPILE_SCOPES = ["append", "file", "package"];
|
|
311580
311933
|
async function denyHiddenAsNotQueryable(convert, gate) {
|
|
311581
311934
|
try {
|
|
@@ -311804,7 +312157,7 @@ ${source}` : source ?? "";
|
|
|
311804
312157
|
}, () => hasExactGateModel ? gateModel.assertAuthorizedForRunnable(finalQuery, givens ?? {}) : gateModel.assertAuthorizedFromCompiledRunnable(finalQuery, givens ?? {}));
|
|
311805
312158
|
}
|
|
311806
312159
|
} catch (error) {
|
|
311807
|
-
if (!(error instanceof
|
|
312160
|
+
if (!(error instanceof MalloyError6))
|
|
311808
312161
|
throw error;
|
|
311809
312162
|
}
|
|
311810
312163
|
}
|
|
@@ -311864,6 +312217,36 @@ ${source}` : source ?? "";
|
|
|
311864
312217
|
}
|
|
311865
312218
|
return { problems };
|
|
311866
312219
|
}
|
|
312220
|
+
if (scope === "append") {
|
|
312221
|
+
let baseModel;
|
|
312222
|
+
try {
|
|
312223
|
+
baseModel = await runtime.loadModel(pathToFileURL2(modelPath)).getModel();
|
|
312224
|
+
} catch (error) {
|
|
312225
|
+
logger.warn("Compile gate could not load the base model", {
|
|
312226
|
+
modelPath,
|
|
312227
|
+
error
|
|
312228
|
+
});
|
|
312229
|
+
getCompileRefusalsCounter().add(1, {
|
|
312230
|
+
environment: this.environmentName,
|
|
312231
|
+
reason: "base_model_load_failed"
|
|
312232
|
+
});
|
|
312233
|
+
if (error instanceof MalloyError6) {
|
|
312234
|
+
throw new CompileRefusedError(`Cannot validate the submitted source: the model ` + `"${modelName}" does not compile, so there is nothing ` + `to check the submitted source against. Problems: ` + error.problems.map((p) => p.message).join("; "));
|
|
312235
|
+
}
|
|
312236
|
+
throw new CompileRefusedError(`Cannot validate the submitted source: the model ` + `"${modelName}" could not be loaded to check it against.`);
|
|
312237
|
+
}
|
|
312238
|
+
try {
|
|
312239
|
+
await assertNoRestrictedConstructs(runtime, baseModel, source ?? "");
|
|
312240
|
+
} catch (error) {
|
|
312241
|
+
if (error instanceof CompileRefusedError) {
|
|
312242
|
+
getCompileRefusalsCounter().add(1, {
|
|
312243
|
+
environment: this.environmentName,
|
|
312244
|
+
reason: "restricted_construct"
|
|
312245
|
+
});
|
|
312246
|
+
}
|
|
312247
|
+
throw error;
|
|
312248
|
+
}
|
|
312249
|
+
}
|
|
311867
312250
|
try {
|
|
311868
312251
|
const modelMaterializer = runtime.loadModel(virtualUrl);
|
|
311869
312252
|
const model = await modelMaterializer.getModel();
|
|
@@ -311889,14 +312272,14 @@ ${source}` : source ?? "";
|
|
|
311889
312272
|
if (typeof givenCode === "string" && givenCode.startsWith("runtime-given-")) {
|
|
311890
312273
|
throw new BadRequestError(error instanceof Error ? error.message : String(error));
|
|
311891
312274
|
}
|
|
311892
|
-
if (error instanceof
|
|
312275
|
+
if (error instanceof MalloyError6) {
|
|
311893
312276
|
throw error;
|
|
311894
312277
|
}
|
|
311895
312278
|
}
|
|
311896
312279
|
}
|
|
311897
312280
|
return { problems: tagProblems(model.problems), sql };
|
|
311898
312281
|
} catch (error) {
|
|
311899
|
-
if (error instanceof
|
|
312282
|
+
if (error instanceof MalloyError6) {
|
|
311900
312283
|
return { problems: tagProblems(error.problems) };
|
|
311901
312284
|
}
|
|
311902
312285
|
throw error;
|
|
@@ -312202,6 +312585,7 @@ ${source}` : source ?? "";
|
|
|
312202
312585
|
await this.rebindServeBindingsFromLocalStore(_package);
|
|
312203
312586
|
if (existingPackage !== undefined && reload) {
|
|
312204
312587
|
this.retireConnectionGeneration(`package ${packageName}`, () => existingPackage.getMalloyConfig().shutdown("close"));
|
|
312588
|
+
_package.noteSurfaceChangeFrom(existingPackage.getPackageMetadata().explores);
|
|
312205
312589
|
}
|
|
312206
312590
|
this.packages.set(packageName, _package);
|
|
312207
312591
|
this.setPackageStatus(packageName, "serving" /* SERVING */);
|
|
@@ -312505,11 +312889,12 @@ ${source}` : source ?? "";
|
|
|
312505
312889
|
const materializationBase = metadata.materialization !== undefined ? { ...metadata.materialization, ...preservedQueryMetadata } : onDiskMaterialization !== undefined ? { ...onDiskMaterialization } : undefined;
|
|
312506
312890
|
const materializationBlock = resolvedScope !== undefined ? { ...materializationBase ?? {}, scope: resolvedScope } : materializationBase;
|
|
312507
312891
|
const resolvedQueryMetadata = metadata.queryMetadata ?? materializationBlock?.queryMetadata ?? existingManifest.queryMetadata;
|
|
312892
|
+
const echoesDerivedSurface = existingManifest.explores === undefined && Array.isArray(metadata.explores) && metadata.explores.length === 1 && metadata.explores[0] === INDEX_MODEL_NAME;
|
|
312508
312893
|
const updatedManifest = {
|
|
312509
312894
|
...existingManifest,
|
|
312510
312895
|
name: metadata.name,
|
|
312511
312896
|
description: metadata.description,
|
|
312512
|
-
...metadata.explores !== undefined ? { explores: metadata.explores } : {},
|
|
312897
|
+
...metadata.explores !== undefined && !echoesDerivedSurface ? { explores: metadata.explores } : {},
|
|
312513
312898
|
...metadata.queryableSources !== undefined ? { queryableSources: metadata.queryableSources } : {},
|
|
312514
312899
|
...metadata.manifestLocation !== undefined ? { manifestLocation: metadata.manifestLocation } : {},
|
|
312515
312900
|
...resolvedScope !== undefined ? { scope: resolvedScope } : {},
|
|
@@ -317113,7 +317498,7 @@ var compileShape = {
|
|
|
317113
317498
|
var COMPILE_DESCRIPTION = `Compile-check Malloy without running a query. Use this while authoring instead of a throwaway execute_query.
|
|
317114
317499
|
|
|
317115
317500
|
## Scopes (the scope parameter)
|
|
317116
|
-
- "append" (default): append source to modelPath. Use for NEW definitions; existing definitions report "Cannot redefine". Positions refer to the concatenated file.
|
|
317501
|
+
- "append" (default): append source to modelPath. Use for NEW definitions; existing definitions report "Cannot redefine". Positions refer to the concatenated file. Refused here, with a 400: import, connection.table(...), connection.sql(...), raw-SQL functions, given: declarations (reading the model's givens as $NAME is fine), ##! flags, and text that does not stand alone as top-level Malloy — a bare view:/dimension:/measure:, or anything parsing only as a continuation of the model's last statement. Wrap a view body in a top-level query:, a field in a throwaway source: check is <source> extend { … }; use "file" or "package" for a model that declares data roots.
|
|
317117
317502
|
- "file": compile source AS modelPath. Use to validate an EDIT before saving; positions match the submitted file.
|
|
317118
317503
|
- "package": run reload's worker compiler over all .malloy/.malloynb files without changing the served package. Optional source replaces modelPath so importers see the edit. Diagnostics may name files hidden from discovery; no rows or SQL are returned, and authorize gates still apply to caller text. A missing exact path is warned and treated as a new file. Save and call reload_package to serve a clean edit.
|
|
317119
317504
|
|
|
@@ -318985,7 +319370,7 @@ a permanent parallel mechanism.`
|
|
|
318985
319370
|
{
|
|
318986
319371
|
name: "eval-answer/ledger-schema",
|
|
318987
319372
|
description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
|
|
318988
|
-
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| `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."
|
|
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."
|
|
318989
319374
|
},
|
|
318990
319375
|
{
|
|
318991
319376
|
name: "eval-diagnose",
|
|
@@ -319207,6 +319592,21 @@ If the agent could not reasonably have known to ask, that is a model gap.
|
|
|
319207
319592
|
| \`GUIDANCE-NOT-RETRIEVED\` | entities came back, governing guidance did not | model: put guidance on the entities agents search for |
|
|
319208
319593
|
| \`GUIDANCE-DECLINED\` | guidance was retrieved and judged inapplicable | model: state the business default, not a caveat |
|
|
319209
319594
|
|
|
319595
|
+
**Before diagnosing a \`NOT-RETURNED\`, check the call actually returned
|
|
319596
|
+
nothing.** The \`tool_call\` event carries \`retrieval_mode\` and \`rankedSummary\`.
|
|
319597
|
+
A \`rankedSummary\` of \`null\` means the response could not be read, not that it
|
|
319598
|
+
was empty -- a body too large for the model's context is written to a file, and
|
|
319599
|
+
the run records no summary for it. There is nothing to diagnose there: say the
|
|
319600
|
+
call was not measured.
|
|
319601
|
+
|
|
319602
|
+
And **never attribute a miss to the embedding index unless \`retrieval_mode\`
|
|
319603
|
+
says \`lexical\`.** The mode is recorded per call precisely so this is checkable.
|
|
319604
|
+
A diagnosis once explained a phantom empty result with "the index was not ready
|
|
319605
|
+
yet" on a run whose index was ready before the first question and whose
|
|
319606
|
+
response did hold results; the empty list was a parsing bug in the harness. An
|
|
319607
|
+
unverifiable cause that sounds right is worse than \`needs_human\`, because it
|
|
319608
|
+
closes the finding.
|
|
319609
|
+
|
|
319210
319610
|
A missing join is coverage, not an agent-call miss. The model has to volunteer
|
|
319211
319611
|
relationships. A declared join is not a retrieval entity; do not look for it in
|
|
319212
319612
|
\`get_context\` results.
|
|
@@ -319880,7 +320280,145 @@ Available: case-format.`
|
|
|
319880
320280
|
{
|
|
319881
320281
|
name: "eval-import/case-format",
|
|
319882
320282
|
description: "Reading an arriving question list. Reference detail for the eval-import skill.",
|
|
319883
|
-
body:
|
|
320283
|
+
body: `<!-- What an arriving item maps to, and how to read the four formats questions actually arrive in. The file shapes themselves are in eval-answer/reference/ledger-schema.md; this is only what is specific to an import. -->
|
|
320284
|
+
|
|
320285
|
+
# Reading an arriving question list
|
|
320286
|
+
|
|
320287
|
+
\`reference/ledger-schema.md\` in \`skill:eval-answer\` defines \`set.json\`,
|
|
320288
|
+
\`cases.jsonl\` and every golden field. This covers two things it does not: what
|
|
320289
|
+
an arriving item maps onto, and how the four formats break.
|
|
320290
|
+
|
|
320291
|
+
## What an import fills in
|
|
320292
|
+
|
|
320293
|
+
On the case:
|
|
320294
|
+
|
|
320295
|
+
| field | from the source | when it is missing |
|
|
320296
|
+
|---|---|---|
|
|
320297
|
+
| \`qid\` | their id, if they gave one, prefixed with the set name | derive from the question: a slug of its first words plus a counter. Stable, because scores are keyed on it |
|
|
320298
|
+
| \`question\` | the question text, byte for byte. One exception: a source hard-wrapped at a column width cannot be one line byte for byte, so a line break inside a question becomes one space, the seal is on the unwrapped text, and \`sourceNote\` says so | it is not a case. Do not reconstruct a question from a criterion |
|
|
320299
|
+
| \`questionSha\` | never from the source | stamped at conversion by \`import_cases.py --stamp\` |
|
|
320300
|
+
| \`split\` | rarely present | you choose, and freeze it at import |
|
|
320301
|
+
| \`source\` | how it arrived: the filename, or the log query | say \`unknown\` rather than guessing a provenance |
|
|
320302
|
+
| \`golden\` | see the classification table in \`SKILL.md\` | absent. A case with no golden is legitimate |
|
|
320303
|
+
| \`expectedEntities\` | almost never present | leave it out. Guessing which fields an answer needs invents a retrieval expectation nobody stated, and it scores as a retrieval miss forever. The one exception is a key you derived yourself (\`SKILL.md\`, Deriving a key yourself): then you are not guessing, you are recording what you used |
|
|
320304
|
+
|
|
320305
|
+
\`expectedEntities\` is the one most worth leaving empty. A wrong \`required\` id
|
|
320306
|
+
cannot be delivered by any run, so it reads as a model failure on every case
|
|
320307
|
+
it touches: five copied ids cost a real set two days.
|
|
320308
|
+
|
|
320309
|
+
On \`set.json\`: \`name\`, \`description\`, \`datasetVersion: 1\`,
|
|
320310
|
+
\`targetModelPath\`, \`targetPackage\` (the package under test -- it arms the
|
|
320311
|
+
guard that refuses a "truth" server also serving it), and a \`sourceNote\` saying
|
|
320312
|
+
where the questions came from and what shape they arrived in. \`truthPackage\` is
|
|
320313
|
+
usually absent at import, and until it exists no golden can reach \`verified\`;
|
|
320314
|
+
\`verify_goldens.py --promote\` is what takes it there afterwards. \`init_truth_package.py\` in
|
|
320315
|
+
\`skill:eval-answer\` scaffolds one.
|
|
320316
|
+
|
|
320317
|
+
## Their field names will not be your field names
|
|
320318
|
+
|
|
320319
|
+
An arriving JSONL is somebody else's schema. Map it explicitly rather than
|
|
320320
|
+
trusting a name that looks familiar, and write down the mapping you used in
|
|
320321
|
+
\`sourceNote\`. Names seen in real sets:
|
|
320322
|
+
|
|
320323
|
+
| theirs | usually means | trap |
|
|
320324
|
+
|---|---|---|
|
|
320325
|
+
| \`prompt\`, \`query\`, \`input\`, \`nl_question\` | the question | \`query\` is the trap: in half of these it is the SQL or Malloy they ran, not the question |
|
|
320326
|
+
| \`answer\`, \`expected\`, \`output\`, \`ground_truth\` | a golden value, prose, or both mixed in one string | a paragraph under \`answer\` is criteria, not a value. Classify it, do not store it as a number |
|
|
320327
|
+
| \`sql\`, \`malloy\`, \`gold_query\` | their derivation | run it (\`SKILL.md\` step 3). This is the field that upgrades a typed number into something checkable |
|
|
320328
|
+
| \`criteria\`, \`rubric\`, \`assertions\`, \`must_include\` | prose criteria | apply the query test in \`SKILL.md\` step 4 to EACH one, not to the field |
|
|
320329
|
+
| \`id\`, \`case_id\`, \`uuid\` | their id | keep it, prefixed. Two sets from one customer collide otherwise |
|
|
320330
|
+
| \`difficulty\`, \`category\`, \`tags\` | their own labels | keep as-is on the case for slicing. Never let one become \`split\` |
|
|
320331
|
+
|
|
320332
|
+
A field you cannot map is a question for its author. Keep it verbatim under a
|
|
320333
|
+
\`sourceFields\` object on the case rather than discarding it or forcing it into
|
|
320334
|
+
a field it does not mean.
|
|
320335
|
+
|
|
320336
|
+
## The four formats
|
|
320337
|
+
|
|
320338
|
+
**JSONL.** One object per line, and the easy one. The failures are all in the
|
|
320339
|
+
mapping above, plus two mechanical ones: a trailing incomplete line, and a
|
|
320340
|
+
\`\\n\` inside a question that a naive line split breaks. Parse it as JSON per
|
|
320341
|
+
line, and count the lines you produced against the lines they sent. Report the
|
|
320342
|
+
count in the import summary: an import that silently dropped 3 of 50 is a
|
|
320343
|
+
measurement on 47 cases claiming to be one on 50.
|
|
320344
|
+
|
|
320345
|
+
**CSV or a spreadsheet export.** Questions contain commas, quotes and newlines,
|
|
320346
|
+
so use a real CSV reader, never a split on \`,\`. Watch for a header row that is
|
|
320347
|
+
not row one (an exported sheet often has a title and a blank line above it), for
|
|
320348
|
+
merged cells arriving as empty strings that belong to the row above, and for a
|
|
320349
|
+
number that arrived as text with a thousands separator or a currency symbol. A
|
|
320350
|
+
number you cannot parse cleanly is a string; keep it as their text and say so,
|
|
320351
|
+
rather than reading \`1,234\` as \`1.234\` or as \`1234\` without checking.
|
|
320352
|
+
|
|
320353
|
+
**A markdown doc or an email thread.** The questions are prose among other
|
|
320354
|
+
prose, so the hard part is deciding what IS a question. Rules that hold up:
|
|
320355
|
+
a heading or a numbered list item is usually a case boundary; a sentence
|
|
320356
|
+
ending in a question mark inside a paragraph about something else usually is
|
|
320357
|
+
not. Criteria attached to a question tend to follow it as a bullet list or a
|
|
320358
|
+
"should" sentence. Quote a boundary you are unsure of and ask, rather than
|
|
320359
|
+
splitting on your best guess: a question invented by a bad split is
|
|
320360
|
+
indistinguishable, later, from one a human asked. Nothing in this format is
|
|
320361
|
+
byte-clean, which makes the verbatim copy in \`as-received/\` load-bearing.
|
|
320362
|
+
|
|
320363
|
+
**A pull from production logs.** The best source, because real traffic asks
|
|
320364
|
+
what people actually ask, and the messiest. Three things to do:
|
|
320365
|
+
de-duplicate near-identical prompts before you import 40 of one question;
|
|
320366
|
+
strip the parts that are not the question (a system preamble, a UI-injected
|
|
320367
|
+
scope line, a pasted table); and keep the trace or request id on the case as
|
|
320368
|
+
\`source\`, so a case can be taken back to the session it came from. Almost
|
|
320369
|
+
none of these arrive with a golden, which is fine: bare questions are a
|
|
320370
|
+
coverage measurement. Where the log also holds the query the agent ran, that
|
|
320371
|
+
is NOT a golden. It is what an unevaluated agent did, and importing it as a
|
|
320372
|
+
key scores the model against its own past behavior.
|
|
320373
|
+
|
|
320374
|
+
## A worked line
|
|
320375
|
+
|
|
320376
|
+
Theirs:
|
|
320377
|
+
|
|
320378
|
+
\`\`\`json
|
|
320379
|
+
{"case_id": "q-014", "prompt": "How did denim sell last quarter versus the one before?",
|
|
320380
|
+
"sql": "SELECT ...", "answer": "Up 12.4% to 1.83M. Should break out by channel.",
|
|
320381
|
+
"difficulty": "hard"}
|
|
320382
|
+
\`\`\`
|
|
320383
|
+
|
|
320384
|
+
Yours, after applying step 3 and step 4:
|
|
320385
|
+
|
|
320386
|
+
\`\`\`json
|
|
320387
|
+
{"qid": "ecom-q-014", "question": "How did denim sell last quarter versus the one before?",
|
|
320388
|
+
"questionSha": "<stamped>", "split": "dev", "source": "questions.jsonl",
|
|
320389
|
+
"golden": {"status": "provisional", "kind": "scalar", "value": 1830000,
|
|
320390
|
+
"canonicalQuery": "SELECT ...", "verifiedBy": "authored_query",
|
|
320391
|
+
"rubric": "Reports the quarter-over-quarter change for denim, and breaks the figure out by channel."},
|
|
320392
|
+
"sourceFields": {"difficulty": "hard"}}
|
|
320393
|
+
\`\`\`
|
|
320394
|
+
|
|
320395
|
+
And one that arrived as criteria alone, with no number anywhere:
|
|
320396
|
+
|
|
320397
|
+
\`\`\`json
|
|
320398
|
+
{"qid": "ecom-q-021", "question": "Which channels are underperforming?",
|
|
320399
|
+
"questionSha": "<stamped>", "split": "dev", "source": "questions.jsonl",
|
|
320400
|
+
"golden": {"status": "verified", "kind": "criteria",
|
|
320401
|
+
"verifiedBy": "authored_criteria",
|
|
320402
|
+
"rubric": "Names the channels and says what it measured underperformance against. Does not use list price.",
|
|
320403
|
+
"mustNotUse": ["retail_price"]}}
|
|
320404
|
+
\`\`\`
|
|
320405
|
+
|
|
320406
|
+
No query settles "underperforming", so there is nothing to derive and nothing
|
|
320407
|
+
that can be numerically wrong: the clauses are the whole key, and this case
|
|
320408
|
+
scores on the first run.
|
|
320409
|
+
|
|
320410
|
+
\`criteria\` and \`unanswerable\` are the two kinds an import may mark \`verified\`,
|
|
320411
|
+
and they arrive looking alike. A criterion reading "PASS: a refusal that names
|
|
320412
|
+
the missing data" belongs to the second: the key is that the data is absent,
|
|
320413
|
+
so \`kind: unanswerable\`, and filing it as \`criteria\` beside a value would make
|
|
320414
|
+
a correct refusal fail.
|
|
320415
|
+
|
|
320416
|
+
What happened to that one \`answer\` string: the number became a provisional
|
|
320417
|
+
value, checkable because they sent the query that produced it; "should break
|
|
320418
|
+
out by channel" is a shape nothing can settle with a query, so it became
|
|
320419
|
+
rubric prose and scores on day one; and "Up 12.4%" is a second figure the
|
|
320420
|
+
rubric now has to state consistently with the value, which
|
|
320421
|
+
\`verify_goldens.py\` reads the accepting clause for.`
|
|
319884
320422
|
},
|
|
319885
320423
|
{
|
|
319886
320424
|
name: "eval-improve",
|
|
@@ -320139,7 +320677,7 @@ reply, ONLY this JSON object:
|
|
|
320139
320677
|
description: "Decide whether ONE answer matches its golden, and say whether you believe the golden. Read this before emitting any verdict. Covers containment, column pairing, near_match, refusals, and the gold_status judgement. Use when scoring an attempt in an evaluation run; never to conduct a run (eval-loop), diagnose a failure (eval-diagnose) or edit a model (eval-improve).",
|
|
320140
320678
|
body: `# The judge
|
|
320141
320679
|
|
|
320142
|
-
JUDGE_VERSION:
|
|
320680
|
+
JUDGE_VERSION: 6
|
|
320143
320681
|
|
|
320144
320682
|
This skill IS the judge. One fresh judge subagent is spawned per attempt, with
|
|
320145
320683
|
this skill installed in its workspace and the case materials in its prompt. It
|
|
@@ -320252,6 +320790,27 @@ Output, exactly this shape:
|
|
|
320252
320790
|
reading that produced it, that is \`no_match\`. Use \`near_match\` only when you
|
|
320253
320791
|
can name the rubric clause that makes the difference defensible.
|
|
320254
320792
|
|
|
320793
|
+
**A rubric clause cannot make a wrong VALUE defensible, and a clause that
|
|
320794
|
+
tries is a defect in the rubric rather than a licence to you.** \`near_match\`
|
|
320795
|
+
turns on the answer being right under a reading the QUESTION allows -- a tie
|
|
320796
|
+
broken the other way, a grain the question left open, a basis the question
|
|
320797
|
+
never fixed. It does not turn on the answer being transparent about how it
|
|
320798
|
+
got a figure the question did not ask for. Those two look alike in a rubric
|
|
320799
|
+
and are opposites in a report: one is a number a reader can act on, the
|
|
320800
|
+
other is a number a reader would act on wrongly. Naming the method makes a
|
|
320801
|
+
wrong figure DIAGNOSABLE, which is worth having, and it is not partial
|
|
320802
|
+
credit.
|
|
320803
|
+
|
|
320804
|
+
The test, before you write \`near_match\` on a case with a value: would a
|
|
320805
|
+
reader who acted on this figure be wrong? If yes, it is \`no_match\` however
|
|
320806
|
+
plainly the answer explained itself, and however the rubric is worded. Say
|
|
320807
|
+
in \`why\` that you are overriding a rubric clause, so the clause gets fixed.
|
|
320808
|
+
This rule exists because a set shipped one: a question asked for sales over
|
|
320809
|
+
the company's season, the answer gave the meteorological window 25% lower
|
|
320810
|
+
and said which window it used, and a clause granting \`near_match\` for a
|
|
320811
|
+
stated window kept a materially wrong answer out of the pass rate
|
|
320812
|
+
entirely -- the arm reported 100%.
|
|
320813
|
+
|
|
320255
320814
|
It is a third outcome because as a pass it was a large share of the measured
|
|
320256
320815
|
noise: the same unchanged answer reads \`match\` in one run and \`near_match\`
|
|
320257
320816
|
in the next, and the pass rate moves although nothing did. A verdict whose
|
|
@@ -320298,6 +320857,29 @@ Output, exactly this shape:
|
|
|
320298
320857
|
total; the question had never asked for one. A rubric that means "campaign
|
|
320299
320858
|
total only" must say so as \`REQUIRED\`, and the question should say so too.
|
|
320300
320859
|
|
|
320860
|
+
### Rubric markers
|
|
320861
|
+
|
|
320862
|
+
A case rubric marks its alternate readings and disclosures with the words
|
|
320863
|
+
below, and each word fixes the verdict. Apply them as written; do not re-weigh
|
|
320864
|
+
a reading the rubric has already classified. (\`reference/writing-rubrics.md\`
|
|
320865
|
+
is where authors are told to use them; this table is the judge's half.)
|
|
320866
|
+
|
|
320867
|
+
| Marker | Verdict | Meaning |
|
|
320868
|
+
|---|---|---|
|
|
320869
|
+
| \`PREFERRED\` | \`match\` | The reading the golden encodes. |
|
|
320870
|
+
| \`ACCEPT\` | \`match\` | Equally right: a different but faithful route to the same claim. Check the figure against the golden the way the clause says to. |
|
|
320871
|
+
| \`DIVERGENT\` | \`near_match\` | Defensible and not what was asked for. Never \`no_match\`, however clearly the answer committed to it. |
|
|
320872
|
+
| \`WRONG\` | \`no_match\` | Plausible and incorrect; the clause usually names the trap. |
|
|
320873
|
+
| \`REQUIRED\` | omitted: \`no_match\` | A disclosure without which the number misleads. |
|
|
320874
|
+
| \`CREDITED\` | omitted: \`match\` | Context a good analyst adds; its absence costs nothing. |
|
|
320875
|
+
|
|
320876
|
+
Measured on an unchanged answer, rubric and golden: an answer whose
|
|
320877
|
+
recommended figure the rubric marked \`DIVERGENT\` scored \`near_match\` under
|
|
320878
|
+
one judge and \`no_match\` under the next, because the judge had never been
|
|
320879
|
+
told what the word meant and weighed the commitment instead. An unmarked
|
|
320880
|
+
clause is \`CREDITED\` (the author's bug, not yours to repair by inventing a
|
|
320881
|
+
requirement).
|
|
320882
|
+
|
|
320301
320883
|
### Anchors
|
|
320302
320884
|
|
|
320303
320885
|
- **match**: question "total sales by category"; golden 8 rows
|
|
@@ -320543,6 +321125,14 @@ Rules that follow from this:
|
|
|
320543
321125
|
must say "for the campaign as a whole". If it does not, the rubric must accept
|
|
320544
321126
|
a correct figure at any stated grain (judge rule 11). A rubric that quietly
|
|
320545
321127
|
assumes the golden's grain fails correct answers.
|
|
321128
|
+
- **Fix the cardinality in the question, or accept any prefix in the rubric.**
|
|
321129
|
+
"The most", "the top ones", "most often" name no N; the truth query needs
|
|
321130
|
+
one, so the golden fixes 10 or 20 or 25 and an answer that shows five
|
|
321131
|
+
correct rows reads as missing fifteen. Measured over one 24-case set, that
|
|
321132
|
+
class alone produced a \`no_match\`, a flip and two \`near_match\` verdicts on
|
|
321133
|
+
answers whose rows were a correct prefix of the golden's. Either write the N
|
|
321134
|
+
into the question, or mark the rubric \`ACCEPT the first N of the golden's
|
|
321135
|
+
order for any N of at least five\`, and say which order the golden is in.
|
|
320546
321136
|
- **A right value plus a missing \`CREDITED\` disclosure is a \`match\`.** Not a
|
|
320547
321137
|
near match. Do not deduct for it.
|
|
320548
321138
|
- **\`DIVERGENT\` is about definitions, not arithmetic.** A clause permitting a
|
|
@@ -320915,11 +321505,19 @@ the run measure something other than what it names:
|
|
|
320915
321505
|
first time somebody tidies up; the set, the run directory and the write-up
|
|
320916
321506
|
belong in git beside the model.
|
|
320917
321507
|
|
|
320918
|
-
2. The server must be up
|
|
320919
|
-
|
|
320920
|
-
|
|
320921
|
-
|
|
320922
|
-
|
|
321508
|
+
2. The server must be up. Where your host offers retrieval tracing, turn it on
|
|
321509
|
+
and confirm a trace lookup answers, so a call's ranked results can be
|
|
321510
|
+
recovered afterwards.
|
|
321511
|
+
|
|
321512
|
+
**Open-source Publisher has none.** \`PUBLISHER_MCP_TRACE=retrieval\` is set by
|
|
321513
|
+
\`serve.py\` and read by nothing: the trace store was written and never merged,
|
|
321514
|
+
so there is no trace tool and \`traceId\` is null on every local attempt. This
|
|
321515
|
+
does not block a scored run, because attribution never depended on it:
|
|
321516
|
+
\`rankedSummary\` is copied onto the \`tool_call\` event at capture, precisely so
|
|
321517
|
+
the evidence survives without a store. So do not refuse a local run for want
|
|
321518
|
+
of tracing -- an earlier version of this rule did, and it refused every local
|
|
321519
|
+
run there has ever been. Refuse one whose \`tool_call\` events carry no
|
|
321520
|
+
\`rankedSummary\`, which is what attribution actually reads.
|
|
320923
321521
|
|
|
320924
321522
|
3. Health-check: your host's status check until it reports serving, and inspect
|
|
320925
321523
|
\`loadErrors\`. A dead database that still answers HTTP is an environment
|
|
@@ -321087,6 +321685,13 @@ conductor. Do not:
|
|
|
321087
321685
|
|
|
321088
321686
|
- The model is the only thing improve edits. No question text, qids, or
|
|
321089
321687
|
expected values in any name, doc, or comment.
|
|
321688
|
+
- **You are measuring a model, not reviewing this harness.** Read a script when
|
|
321689
|
+
a number you have to report cannot be explained otherwise, and stop there. Do
|
|
321690
|
+
not audit the scripts, propose fixes to them, or hand back tooling critique
|
|
321691
|
+
in place of a result -- a run that ends in harness feedback has not answered
|
|
321692
|
+
the question it was asked. A defect that changed a number gets one sentence
|
|
321693
|
+
in the report; a defect that changed nothing gets one line in chat, once.
|
|
321694
|
+
Fixing it is a different task, and the user starts it.
|
|
321090
321695
|
- When the environment misbehaves, stop. Never diagnose a sick system. The
|
|
321091
321696
|
harness is part of the environment: a known-broken measurement does not
|
|
321092
321697
|
become quotable by being finished. Measured against this directive, an arm
|
|
@@ -321879,6 +322484,10 @@ check, a judge, and a conformant \`events.jsonl\`.
|
|
|
321879
322484
|
# if it does not reach \`ready\`: the sync is lazy, so without it the first
|
|
321880
322485
|
# cases are answered LEXICALLY and the run reports that as the model's
|
|
321881
322486
|
# number.
|
|
322487
|
+
# Semantic retrieval needs EMBEDDING_API_KEY in the server's environment
|
|
322488
|
+
# (the Publisher does not read OPENAI_API_KEY); without it every ranking is
|
|
322489
|
+
# lexical, the warm-up reports \`cooldown\`, and the provider's own error is
|
|
322490
|
+
# only in publisher.log (grep Embedding).
|
|
321882
322491
|
python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
|
|
321883
322492
|
--server-root <root> --port 4811 --mcp-port 4040 --trace-retrieval \\
|
|
321884
322493
|
--warm-retrieval --environment <env> --package <pkg> \\
|
|
@@ -322047,6 +322656,30 @@ one exception is anything that qualifies THIS run's number -- a truncated
|
|
|
322047
322656
|
attempt, a contaminated one, an unestablished answer key -- which the "Eval
|
|
322048
322657
|
failures" section exists for.
|
|
322049
322658
|
|
|
322659
|
+
**Do not audit the harness. You were asked to measure a model.** This is the
|
|
322660
|
+
most common way this job goes wrong, and it does not look like going wrong: a
|
|
322661
|
+
run turns up something odd in a script, the odd thing is genuinely a bug, and
|
|
322662
|
+
the reply comes back as a critique of the tooling with the model's score
|
|
322663
|
+
somewhere underneath. The reader asked what their model scored. Answer that.
|
|
322664
|
+
|
|
322665
|
+
So, unless the user asked you to work on the harness:
|
|
322666
|
+
|
|
322667
|
+
- Do not read harness source to satisfy your own curiosity about a number.
|
|
322668
|
+
Read it when a number you must report cannot be explained any other way, and
|
|
322669
|
+
stop when it can.
|
|
322670
|
+
- Do not propose harness fixes, refactors, flags or "while I was in there"
|
|
322671
|
+
improvements. Not in the report, not in the chat reply.
|
|
322672
|
+
- When a harness defect DID change this run's number, the report gets one
|
|
322673
|
+
sentence: what the number should be and why. Not the mechanism, not the
|
|
322674
|
+
file, not the fix.
|
|
322675
|
+
- Keep a defect that changed nothing out of the report entirely. Mention it
|
|
322676
|
+
once in chat, in a line, and let the user decide whether they want it
|
|
322677
|
+
chased.
|
|
322678
|
+
|
|
322679
|
+
A harness bug you found and did not chase is not a loose end. It is the job
|
|
322680
|
+
being done. If the user wants it fixed they will say so, and then it is a
|
|
322681
|
+
different task with its own turn.
|
|
322682
|
+
|
|
322050
322683
|
## Step 1: build the artifacts, before writing a word
|
|
322051
322684
|
|
|
322052
322685
|
\`\`\`bash
|
|
@@ -322085,6 +322718,29 @@ not in \`~/Downloads\`, both of which have lost a findings document before.
|
|
|
322085
322718
|
from the console.** A figure retyped from scrollback is a figure nobody can
|
|
322086
322719
|
check, and the console rounds.
|
|
322087
322720
|
|
|
322721
|
+
### Keep it under a page and a half
|
|
322722
|
+
|
|
322723
|
+
**Budget: about 80 lines, and 120 is the ceiling for a run with several
|
|
322724
|
+
distinct failures.** Reports have shipped at 200 and the length is not
|
|
322725
|
+
thoroughness -- it is the artifact links restated as prose, the cascade
|
|
322726
|
+
explained twice, and a paragraph apologising for a figure nobody disputed.
|
|
322727
|
+
A reader who wants the detail opens the case matrix, which is why step 1 builds
|
|
322728
|
+
it. What cannot be recovered from the artifacts is your judgement: what broke,
|
|
322729
|
+
why, and what to do. Spend the lines there.
|
|
322730
|
+
|
|
322731
|
+
What earns its place: the headline, one row per case, one short entry per
|
|
322732
|
+
failure, the retrieval numbers, and the next steps. What does not: restating a
|
|
322733
|
+
number you already gave, explaining what a cascade is before showing it,
|
|
322734
|
+
defending a decision nobody questioned, or any section whose content is that
|
|
322735
|
+
nothing happened -- except "Eval failures", where "None." is the point.
|
|
322736
|
+
|
|
322737
|
+
**Being brief is not being terse with the vocabulary.** The reader does not
|
|
322738
|
+
know what \`near_match\`, a cluster, recall or a holdout is, so the first time
|
|
322739
|
+
one appears, say what it means in the same sentence -- "\`near_match\`, which is
|
|
322740
|
+
excluded from the pass rate" -- and then use it. Cutting the explanations is
|
|
322741
|
+
how a short report becomes an unreadable one; cutting the restatements is how
|
|
322742
|
+
it becomes a good one.
|
|
322743
|
+
|
|
322088
322744
|
### The template
|
|
322089
322745
|
|
|
322090
322746
|
\`\`\`markdown
|
|
@@ -322098,7 +322754,7 @@ check, and the console rounds.
|
|
|
322098
322754
|
- Target \`<env>/<package>\`, model \`<modelPath>\`, pinned \`<modelSha or targetVersion>\`
|
|
322099
322755
|
- Answerer \`<model>\`, judge \`<model>\`, cap \`<maxTurns>\` turns
|
|
322100
322756
|
- Steps run: scrape/run, eval<, diagnose><, improve>
|
|
322101
|
-
- Cost $X answerer + $Y judge
|
|
322757
|
+
- Cost $X answerer + $Y judge, N turns median / N p90, N entities delivered per answer
|
|
322102
322758
|
|
|
322103
322759
|
## The result
|
|
322104
322760
|
|
|
@@ -322109,9 +322765,15 @@ check, and the console rounds.
|
|
|
322109
322765
|
|
|
322110
322766
|
[case matrix](<url>) - [aggregate tables](<url>)
|
|
322111
322767
|
|
|
322112
|
-
##
|
|
322768
|
+
## Coverage, retrieval, accuracy, cost
|
|
322113
322769
|
|
|
322114
|
-
|
|
322770
|
+
**Coverage N of M** -- how many questions the model can express an answer to at
|
|
322771
|
+
all. **Entity recall N%** -- one number, first, before the cascade. It is the share
|
|
322772
|
+
of the entities an answer needed that retrieval actually handed the agent, and
|
|
322773
|
+
it is the headline of this section for the same reason the pass rate is the
|
|
322774
|
+
headline of the last one. Then the cascade: Covered? -> Retrieved? -> Correct?,
|
|
322775
|
+
with the per-arm numbers under each. Say in one clause what recall counts, then
|
|
322776
|
+
give the number.
|
|
322115
322777
|
|
|
322116
322778
|
## Model failures
|
|
322117
322779
|
|
|
@@ -322140,10 +322802,10 @@ these in order and put every one that fires into the list, with its command:
|
|
|
322140
322802
|
|
|
322141
322803
|
| If the run shows | Then the next step is |
|
|
322142
322804
|
|---|---|
|
|
322143
|
-
| \`coverage: unmeasured\` | run
|
|
322805
|
+
| \`coverage: unmeasured\` | the run was given \`--no-coverage\`, or the measurement failed and said so. It is measured by default, so say in "Eval failures" that this score cannot tell a model gap from a bad answer, and re-run without the flag |
|
|
322144
322806
|
| \`goldenCheck: skipped\` or the set names no \`truthPackage\` | build one with \`init_truth_package.py\`; until then the goldens were derived through the model under test and certify themselves |
|
|
322145
322807
|
| any golden still \`provisional\` | re-derive and \`verify_goldens.py --promote\` |
|
|
322146
|
-
| a stale entity name warning | fix \`expectedEntities\`; it scores as a retrieval miss on every run until you do |
|
|
322808
|
+
| a stale entity name warning | fix \`expectedEntities\`; it scores as a retrieval miss on every run until you do. A next step, not a section: it goes in this list and nowhere else in the report |
|
|
322147
322809
|
| a passing case with recall below 1.0 | check whether \`required\` over-specifies one path |
|
|
322148
322810
|
| \`truncated\` non-empty | re-run those cases at a higher cap with \`--from\` |
|
|
322149
322811
|
| diagnose did not run | run it, or say the failures have no owner yet |
|
|
@@ -322186,8 +322848,23 @@ wrong answer. One entry each: what the answer said, what was right, and the
|
|
|
322186
322848
|
mechanism in one sentence. Do not write a cluster id here; write what it got
|
|
322187
322849
|
wrong.
|
|
322188
322850
|
|
|
322189
|
-
**Eval failures** are
|
|
322190
|
-
|
|
322851
|
+
**Eval failures** are the things that stopped the run measuring the model, and
|
|
322852
|
+
**only** those. The test is one question: *did this cost a verdict, or make one
|
|
322853
|
+
untrustworthy?* If no, it does not appear in the report at any length.
|
|
322854
|
+
|
|
322855
|
+
That rules out most of what is tempting to put here, and all of it has been put
|
|
322856
|
+
here on a real run: a \`-dirty\` model pin, a stale-entity-name warning that cost
|
|
322857
|
+
no case, a defect you hit in the harness and worked around, a setup step that
|
|
322858
|
+
took two tries, anything you would open with "worth knowing, though it changes
|
|
322859
|
+
no verdict". A reader wants to know what their model scored. Harness defects are
|
|
322860
|
+
real and belong in a harness issue, filed against the harness -- that is the
|
|
322861
|
+
skill's opening rule, and this section is where it gets broken.
|
|
322862
|
+
|
|
322863
|
+
The section is usually two words. "**None.**" is a complete and good answer, and
|
|
322864
|
+
a reader who sees it learns exactly what they need to. Do not pad it into a
|
|
322865
|
+
paragraph explaining the absence.
|
|
322866
|
+
|
|
322867
|
+
Report every one that DID occur, with its count and its qids, and say plainly
|
|
322191
322868
|
that these are NOT evidence about the model:
|
|
322192
322869
|
|
|
322193
322870
|
| What happened | How it reads in the ledger | Who fixes it |
|
|
@@ -322206,11 +322883,32 @@ harness prints \`INCOMPLETE\` and withholds the percentage; the report does the
|
|
|
322206
322883
|
same. Give the counts and the re-run command instead of a number with a caveat,
|
|
322207
322884
|
because the number is what gets repeated and the caveat is what gets dropped.
|
|
322208
322885
|
|
|
322209
|
-
##
|
|
322886
|
+
## The four measurements, and why a report carries all of them
|
|
322887
|
+
|
|
322888
|
+
A pass rate says an answer was wrong. It does not say where, and on its own it
|
|
322889
|
+
makes every failure look like the model's. A run measures four things, in this
|
|
322890
|
+
order, and each one tells you whether the next is even a fair question:
|
|
322891
|
+
|
|
322892
|
+
| | Measures | A failure here means |
|
|
322893
|
+
|---|---|---|
|
|
322894
|
+
| **a. Coverage** | can the data and the model express an answer at all | nothing downstream was winnable. Fix the model |
|
|
322895
|
+
| **b. Retrieval** | did \`get_context\` hand the agent the entities it needed | the model has it and the agent never saw it: the docs, or the search wording |
|
|
322896
|
+
| **c. Accuracy** | did the agent get the answer right | it had what it needed and still missed: the agent, or a doc that misleads |
|
|
322897
|
+
| **d. Cost** | dollars, turns, wall-clock, entities per answer | it works and cannot be afforded, which is its own kind of not working |
|
|
322898
|
+
|
|
322899
|
+
**Coverage first, and a report without it is incomplete.** A model that cannot
|
|
322900
|
+
express an answer can never succeed at that question, so a pass rate quoted
|
|
322901
|
+
without coverage cannot distinguish a bad model from an unanswerable set.
|
|
322902
|
+
\`run_baseline.py\` measures it by default; if a run skipped it, say so in
|
|
322903
|
+
"Eval failures" and treat the score as provisional.
|
|
322904
|
+
|
|
322905
|
+
**Cost is a result, not an aside.** Report dollars, the turn and call counts,
|
|
322906
|
+
and the entity payload per answer. An agent that answers correctly in 40 turns
|
|
322907
|
+
and 75 delivered entities per question is a different product from one that
|
|
322908
|
+
does it in 8 and 5, and only the report says which you have.
|
|
322210
322909
|
|
|
322211
|
-
|
|
322212
|
-
|
|
322213
|
-
because a report that omits them makes every failure look like the model's:
|
|
322910
|
+
Report them all, because a report that omits them makes every failure look
|
|
322911
|
+
like the model's:
|
|
322214
322912
|
|
|
322215
322913
|
**Copy the cascade the run printed. Do not re-derive it.** \`cascade_lines()\`
|
|
322216
322914
|
in \`run_baseline.py\` already renders every rung, and it carries one field a
|
|
@@ -322426,6 +323124,7 @@ This table is a catalogue of what exists, not of what is loaded. A host that ins
|
|
|
322426
323124
|
| \`skill:malloy-notebooks\` | Building Malloy notebooks (.malloynb) |
|
|
322427
323125
|
| \`skill:malloy-analysis-report\` | Combining validated queries into a notebook report or dashboard |
|
|
322428
323126
|
| \`skill:malloy-analysis-pitfalls\` | Checking a query and its results before presenting an answer |
|
|
323127
|
+
| \`skill:malloy-source-unreachable\` | A source is missing from discovery, or a query was refused with a 404 or 403 |
|
|
322429
323128
|
| \`malloy-notebook-chat\` | The chat is bound to a notebook or saved report; answer from its cells. Ships in \`analysis\`. |
|
|
322430
323129
|
| \`skill:malloy-phrase-detection\` | Turning a plain-English question into search targets for the context tool |
|
|
322431
323130
|
|
|
@@ -322482,7 +323181,7 @@ Call \`search_malloy_docs\` with your question. Use \`skill:malloy-patterns\` to
|
|
|
322482
323181
|
},
|
|
322483
323182
|
{
|
|
322484
323183
|
name: "malloy-analysis",
|
|
322485
|
-
description: "
|
|
323184
|
+
description: "Read before your first get_context call on any business or data question. The required workflow, not background: which source, which rows, how to verify. Skipping it causes confident wrong answers.",
|
|
322486
323185
|
body: `# Malloy analysis workflow
|
|
322487
323186
|
|
|
322488
323187
|
> **Tool names** are written bare here - \`get_context\`, \`execute_query\`, \`search_malloy_docs\`. The exact prefixed name depends on the host surface; match each against the tools you actually have.
|
|
@@ -322891,6 +323590,13 @@ Scanned at a glance is a dashboard; read top to bottom is a notebook.
|
|
|
322891
323590
|
|
|
322892
323591
|
## Build sequence
|
|
322893
323592
|
|
|
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.
|
|
322894
323600
|
1. **READ THE MODEL FIRST.** Get the real source, view, dimension, and given names from the package:
|
|
322895
323601
|
\`get_context\` if you have it, otherwise the REST model endpoint or the \`.malloy\` files.
|
|
322896
323602
|
Never guess a name. A guessed field in a query fails the whole package load, not just that one
|
|
@@ -322925,8 +323631,11 @@ Scanned at a glance is a dashboard; read top to bottom is a notebook.
|
|
|
322925
323631
|
include wants \`"scope": "package"\`**, which recompiles every file as saved: \`file\` only checks the
|
|
322926
323632
|
one you are editing, so renaming a source in \`_shared.malloy\` passes it while breaking every
|
|
322927
323633
|
dashboard that imports it. A clean compile is not a working dashboard: some tag mistakes surface
|
|
322928
|
-
at step 6, and some only when you look at the page in step 7.
|
|
322929
|
-
|
|
323634
|
+
at step 6, and some only when you look at the page in step 7. **A not-yet-saved dashboard wants
|
|
323635
|
+
\`"scope": "file"\` too**, not the default: a dashboard file opens with an \`import\`, and the
|
|
323636
|
+
default \`append\` scope refuses one, so the new-file case fails on the import and again on the
|
|
323637
|
+
path that does not exist yet. \`append\` is for a fragment checked against a model that is
|
|
323638
|
+
already on disk, which a dashboard file is not.
|
|
322930
323639
|
6. **SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS.** \`reload_package\`, or
|
|
322931
323640
|
\`GET …/packages/<pkg>?reload=true\`. Check the status the reload returns as well as the warnings:
|
|
322932
323641
|
a 424 means the package did not load and your edit is not live. **The \`warnings\` key is absent
|
|
@@ -323301,8 +324010,8 @@ Package warnings after a reload are the dashboard's test suite. Fix all of them:
|
|
|
323301
324010
|
Findings carry a \`severity\`, but \`warn\` is the ordinary default and tells you nothing about how bad
|
|
323302
324011
|
one is. Read the text, not the severity and not the count. One message is worth recognising because
|
|
323303
324012
|
it changes what the rest of the list means: **"Dashboard lint stopped early, so this list is
|
|
323304
|
-
incomplete"**. A dashboard withheld from
|
|
323305
|
-
withheld file is not a clean bill of health.
|
|
324013
|
+
incomplete"**. A dashboard withheld from the package's surface also loses its own findings, so a
|
|
324014
|
+
short list for a withheld file is not a clean bill of health.
|
|
323306
324015
|
|
|
323307
324016
|
**Read the status the reload itself returns, not the listing.** One dashboard that fails to compile
|
|
323308
324017
|
fails the whole package load, and the reload answers **424** with the compile error. A package that
|
|
@@ -323317,11 +324026,22 @@ at all appears there too, without \`stale\`, and is absent from the listing enti
|
|
|
323317
324026
|
|
|
323318
324027
|
If the reload is 200 and the others are listed but yours is not, discovery skipped the file instead,
|
|
323319
324028
|
usually a missing or misspelled \`# artifact\` tag, which is the same mechanism that deliberately skips
|
|
323320
|
-
an untagged shared include. There is a second cause
|
|
323321
|
-
|
|
323322
|
-
|
|
323323
|
-
|
|
323324
|
-
|
|
324029
|
+
an untagged shared include. There is a second cause whenever the package has a curated surface: a
|
|
324030
|
+
dashboard whose file is off it is withheld rather than served, and the warning says so and names the
|
|
324031
|
+
fix. A package has a surface in two ways, and the second is easy to miss because no manifest field
|
|
324032
|
+
records it:
|
|
324033
|
+
|
|
324034
|
+
- the package's \`publisher.json\` carries an \`explores\` list, and the dashboard's file is not on it;
|
|
324035
|
+
- the package root holds an **\`index.malloy\`**, which IS the surface. This bites in both
|
|
324036
|
+
directions, and a dashboard's file is not something an \`index.malloy\` exports either way: adding
|
|
324037
|
+
that file to a package that has dashboards withholds every one of them, and adding a dashboard to
|
|
324038
|
+
a package that already has one (every scaffolded package does) means it is never served. The fix
|
|
324039
|
+
is the same in both directions, and it is not an edit to \`index.malloy\`: declare an \`explores\` in
|
|
324040
|
+
\`publisher.json\` naming \`index.malloy\` and every dashboard file.
|
|
324041
|
+
|
|
324042
|
+
Either way the surface is what matters, not the \`queryableSources\` setting, which is \`declared\` by
|
|
324043
|
+
default; a package with neither withholds nothing. Where there is a surface, a \`suggest\` source has
|
|
324044
|
+
to be queryable as well as resolvable, so it needs to be reachable through it too.
|
|
323325
324045
|
|
|
323326
324046
|
**A clean reload is not proof the tags are right.** The checks above read names and resolve them; the
|
|
323327
324047
|
separate warning for a tag that does not _parse_ is syntax only: it carries no
|
|
@@ -323818,7 +324538,42 @@ view: overview is {
|
|
|
323818
324538
|
{
|
|
323819
324539
|
name: "malloy-html-data-app-embedding",
|
|
323820
324540
|
description: "Embed an in-package HTML data app into a host page or another application, including auto-sizing and auth. Read when embedding a Publisher page via Publisher.embed.",
|
|
323821
|
-
body:
|
|
324541
|
+
body: `# Embedding an HTML Data App
|
|
324542
|
+
|
|
324543
|
+
> \`Publisher.embed(selector, { src })\` drops a package page into a host page as a sandboxed, auto-resizing iframe. Same-origin embeds authenticate with the browser's cookies; cross-origin embeds need a signed token.
|
|
324544
|
+
|
|
324545
|
+
> **A cross-origin embed also needs the server to permit it.** Publisher sends \`Content-Security-Policy: frame-ancestors 'self'\` by default, so a page embedded from another origin is refused by the browser and renders blank -- with nothing logged server-side, which makes it look like a broken page rather than a policy. The deployment must set \`PUBLISHER_FRAME_ANCESTORS\` to the host page's origin (space-separated for several, or \`*\` to allow any). Check this first when an embed is blank: the browser console names \`frame-ancestors\`.
|
|
324546
|
+
|
|
324547
|
+
## The host-page pattern
|
|
324548
|
+
|
|
324549
|
+
\`\`\`html
|
|
324550
|
+
<script src="https://your-publisher/sdk/publisher.js"></script>
|
|
324551
|
+
<div id="dashboard"></div>
|
|
324552
|
+
<script>
|
|
324553
|
+
const handle = Publisher.embed("#dashboard", {
|
|
324554
|
+
src: "https://your-publisher/environments/demo/packages/sales/index.html",
|
|
324555
|
+
});
|
|
324556
|
+
// handle.destroy() removes the iframe and detaches its listeners.
|
|
324557
|
+
</script>
|
|
324558
|
+
\`\`\`
|
|
324559
|
+
|
|
324560
|
+
\`embed(selector, options)\` returns \`{ iframe, destroy() }\`. Options: \`src\` (required), \`token\` (a signed token for cross-origin auth, appended as \`embed_token\`), \`height\` (omit to auto-size; a number is treated as pixels), and \`allow\` (the iframe permissions policy).
|
|
324561
|
+
|
|
324562
|
+
## Sizing and the resize contract
|
|
324563
|
+
|
|
324564
|
+
Omit \`height\` and the frame auto-sizes. The embedded page measures its real content height and posts a \`publisher:resize\` message to the host, which resizes the iframe and accepts that message only from the iframe it created. You write none of this; it ships in \`/sdk/publisher.js\`, so the embedded page only has to load that script.
|
|
324565
|
+
|
|
324566
|
+
Do not rely on \`body { min-height: 100vh }\` to drive the frame height. The runtime deliberately measures the content's bottom edge, not the viewport, to avoid a grow-forever loop.
|
|
324567
|
+
|
|
324568
|
+
## Auth
|
|
324569
|
+
|
|
324570
|
+
- Same-origin or same-tenant: pass no token. The browser's cookies authenticate the iframe.
|
|
324571
|
+
- Cross-origin: mint a short-lived signed token server-side and pass it as \`options.token\`. The runtime appends it to the iframe URL as \`embed_token\`; the embedded page must read it (from \`location.search\`) and call \`Publisher.setToken(token)\`. Because it rides in the URL, it can land in browser history, Referer headers, and server logs, so keep it short-lived and scoped to that one embed, and never put a long-lived or admin token in client HTML.
|
|
324572
|
+
|
|
324573
|
+
## Guardrails (v1)
|
|
324574
|
+
|
|
324575
|
+
- The iframe is sandboxed (\`allow-scripts allow-same-origin allow-forms\`). Design for that: no top-level navigation, no popups.
|
|
324576
|
+
- Embedded author JavaScript runs with the viewing user's data authority, so treat everything under \`public/\` as strictly first-party code: do not load untrusted third-party scripts, and do not move query results off to other hosts. Tighter per-embed isolation is planned.`
|
|
323822
324577
|
},
|
|
323823
324578
|
{
|
|
323824
324579
|
name: "malloy-html-data-app-runtime",
|
|
@@ -324695,12 +325450,12 @@ Present coverage in this order:
|
|
|
324695
325450
|
{
|
|
324696
325451
|
name: "malloy-materialization",
|
|
324697
325452
|
description: "Add and debug Malloy Persistence materializations in a package - persist an expensive source so queries read a pre-built table. Read this whenever the user wants to materialize a source, add a persist annotation, speed up a slow source, or asks why a persist source isn't building.",
|
|
324698
|
-
body: "# Materialization (Malloy Persistence)\n\nMaterialize an expensive source once so queries read a **pre-built warehouse table** instead of recomputing it every time. You tag a source `#@ persist`, a materialization run builds it into a physical table, and queries against it are rewritten to read that table.\n\n> **The #1 gotcha, up front:** if a persist source isn't materializing, it is almost always one of two things - a `.malloy` file in the package missing the `##! experimental.persistence` flag (which aborts the *whole* package's build plan), or no build ever ran (a standalone Publisher does not build on publish - see **Building and refreshing**). Jump to **Debugging a no-op build**.\n\n## The recipe (get this right and it just works)\n\n1. **`##! experimental.persistence` on EVERY `.malloy` file in the package** - not only the file that declares the persist source. Either form enables it:\n - `##! experimental.persistence`, or\n - `##! experimental { access_modifiers, sql_functions, persistence }` (add `persistence` to the existing list).\n\n **Why every file:** the build plan is computed by asking *every* `.malloy` file in the package for its persist sources, and that call **throws on any file whose model lacks the flag** (`Model must have ##! experimental.persistence`). One unflagged helper or import file, even one with no persist source of its own, aborts the whole package's build plan, so *every* persist source in the package drops out. This is the most common cause of a no-op build.\n\n2. **`#@ persist name=\"...\"` on a query-based source, with the name quoted:**\n ```malloy\n #@ persist name=\"my_dataset.my_table\"\n source: my_rollup is some_source -> { group_by: ...; aggregate: ... }\n ```\n - **Only `query_source` and `sql_select` sources are persistable** - a source whose definition has a `-> { ... }` pipeline or a `conn.sql(\"...\")`. This **includes** one refined by a trailing `extend { ... }`. What is **not** persistable is a *plain* `extend` over a bare `conn.table(...)`; a `#@ persist` on such a source is **silently ignored** (its annotation is never read) - that one source just won't materialize, and the rest of the package still builds.\n - **Quote the name.** `name=\"my_table\"` (or a path `name=\"dataset.table\"` / `name=\"project.dataset.table\"`) is required. A **bare** `name=my_table` **always fails the build/publish** with `persist annotation name must be quoted` (a raw-source scan that hard-stops); it never silently no-ops.\n - `name=` is the target table name. In a standalone Publisher this **is** the physical table (rebuilt in place); a hosted (control-plane) deployment builds it under a content-addressed generation name. In both, the source's identity for reuse is a content address of its connection and canonical SQL (its `sourceEntityId`), so **republishing unchanged persist logic reuses the existing table** and changing the logic builds fresh.\n\n3. **Package persistence policy in `publisher.json`** (all optional):\n ```jsonc\n {\n \"name\": \"my-package\",\n \"materialization\": {\n \"scope\": \"package\", // default; \"version\" = each published version owns its own tables\n \"freshness\": { \"window\": \"24h\", \"fallback\": \"live\" },\n \"queryMetadata\": { \"team\": \"finance\" } // tags the build's backend statements\n }\n }\n ```\n Enforced at publish (strict), on edits (strict), at load (warn, still serves), and by the scheduler (an offending package is skipped):\n - **`scope`**: `package` (default; artifacts reused across published versions) or `version` (each artifact owned by one version). Package-level only; there is no per-source scope. A root-level `scope` is the deprecated home and still works, with a warning; declaring both homes with different values is rejected.\n - **`materialization.freshness`** (`window` + `fallback` of `live`/`stale_ok`/`fail`) is the objective a **hosted control plane** enforces by refreshing the table to meet it (`fallback: \"live\"` serves live compute while stale/absent). A **standalone** Publisher does **not** act on `freshness` for refresh - see **Building and refreshing**.\n - **`materialization.queryMetadata`** is a bag of string properties attached to every statement the build issues, for the backend's own cost attribution (Snowflake `QUERY_TAG`, BigQuery job labels, a leading SQL comment elsewhere). Overridable per source with `#@ persist queryMetadata.<name>=\"<value>\"`. Observability only: it never changes what gets built. See `docs/query-metadata.md`.\n - **`materialization.schedule`** is a 5-field UTC cron (`min hour dom mon dow`; `L`/`W`/`#`/`?` rejected). It **requires `scope: \"version\"`** and is **mutually exclusive with `freshness`**. This is how a standalone Publisher refreshes on a cadence.\n\n4. **Reads vs writes.** The persist source can *read* any dataset the connection can read; the persist *target* (`name=`'s dataset) must be a dataset the connection can **write** (typically a scratch dataset).\n\n## Building and refreshing (standalone vs. hosted)\n\nA `#@ persist` tag declares *what* to materialize; it does not by itself build anything.\n\n- **Standalone Publisher:** publishing or loading a package only computes its build plan - **no table is built until a materialization run executes.** Trigger one explicitly (`malloy-pub materialize --package <pkg> --wait`, or the materialization API), or turn on the opt-in local scheduler (off unless `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` is set) to fire the package's `schedule` cron. Refresh is a re-run or that cron; `freshness` is not a refresh trigger here, so a freshness-only standalone package builds once and is not auto-refreshed.\n- **Hosted (control-plane) deployment:** the build runs automatically on publish, best-effort - a build failure does **not** fail the publish (which is why a broken persist can look like a silent no-op), and the control plane drives refresh to meet the `freshness` objective.\n\nEither way, a successful publish alone does not prove a table exists - confirm the build separately.\n\n## Serve-time routing is `query_source`-only (today)\n\nBoth persistable types *build* a table, but only a **`query_source`** (a `-> { ... }` pipeline) is rewritten to *read* it at query time. A raw **`sql_select`** (`conn.sql(\"...\")`, including `conn.sql(\"...\") extend { ... }`) builds its table and then the query path re-inlines its SQL, so the table is built and never read, and queries are no faster. If you have raw SQL you want served from a table, wrap it in a thin `query_source` and persist that:\n\n```malloy\nsource: x_raw is my_conn.sql(\"select ...\")\n#@ persist name=\"scratch_dataset.x\"\nsource: x is x_raw -> { select: * }\n```\n\n## Confirming it worked\n\nAfter a build runs, re-run one of the source's queries - a persisted `query_source` should return quickly, reading the pre-built table instead of recomputing the upstream. Your host also reports each persisted source as **ready** with its physical table name (a materialization run detail, CLI listing, or materialization view, depending on the host); if nothing is listed, either no build ran (standalone) or the build plan was empty - see **Debugging a no-op build**.\n\n## Debugging a no-op build\n\nSymptom: no table was built and the source still recomputes on every query. Check, in order:\n\n0. **Did a build actually run?** On a standalone Publisher, publish/load does **not** build - run `malloy-pub materialize` (or enable the scheduler). \"Publishes fine, no table\" is the *expected* standalone state, not a model bug. On a hosted deployment the build is automatic but best-effort, so a failure is silent - look for a `FAILED` run.\n1. **A `.malloy` file missing the persistence flag** (the most common real bug). Every model file's `##!` line needs `persistence`, including pure helper/import files with no persist source - one unflagged file aborts the whole package's build plan.\n2. **An unquoted persist name** - a bare `name=foo` **always** hard-stops the build/publish with `persist annotation name must be quoted`; use `name=\"foo\"`. (If you got *no* error at all, it isn't this.)\n3. **A `#@ persist` on a non-persistable source** - a bare `extend` over `conn.table(...)` is silently ignored, so *that* source won't materialize (the rest of the package is unaffected). Tag a `query_source` / `sql_select` instead.\n4. **A persisted raw `sql_select` that builds but is never read** - if the table exists yet queries are no faster, it's the serve-routing gap above; wrap the `sql_select` in a `query_source`.\n\n**Isolation test** - add a trivial, self-contained persist source in its own file and rebuild:\n```malloy\n##! experimental.persistence\nsource: smoke_raw is my_conn.table('some_dataset.some_table')\n#@ persist name=\"scratch_dataset.persist_smoke_test\"\nsource: persist_smoke is smoke_raw -> { aggregate: n is count() }\n```\n- If **even this** doesn't build (after a real materialization run), the whole package's plan is aborting - a sibling `.malloy` file is missing the flag. Fix rule 1 across the package.\n- If the smoke source **does** build but your real one doesn't, your real source is the problem - a non-persistable type (a bare `extend`), or its own file's flag.\n\nDelete the smoke file and drop its table afterward.\n\n## Persisting a `#(access_filter)`-gated source\n\nA gated source **can** be persisted, but only on one tier and only in one shape, and the thing to be\ncareful about is not refused by anything - you have to decide it.\n\n- **`storage=` and `#@ preaggregate` always refuse a gated source**, with a 422 at build time naming the\n source. (`#@ persist storage=<name>` is the tier that materializes into a separate registered storage\n destination and serves from there, rather than building in the source's own connection; `#@ preaggregate`\n stores a rollup Publisher derives from a measure you annotated with a grain, rather than a source you\n wrote.) A rollup also groups *across* the gated column, so it could not be row-filtered afterwards even\n in principle.\n- **A colocated `#@ persist` (no `storage=`) is admitted** when the gate is provably the entry point's\n **own row filter**. It is refused when the gate is reached only through a join, inherited from a base\n the compiler cannot attribute cleanly, or does not classify as a row filter at all. The gate is found\n through the import -> rename -> `query_source` chain, so a gate the persisted source did not declare\n itself still counts.\n\n**What to be wary of.** Persisting does not weaken the gate: it changes only where rows are read FROM, and\nthe gate still runs live on every query as that query's own `WHERE`, so filtered rows come back filtered.\nWhat freezes is the **column the gate filters on**. A row whose access decision changes - it changes\nowner, say - keeps being served under its OLD decision until the next rebuild. That is a stale *access\ndecision*, not merely stale data, and nothing raises an error.\n\n**None of this is needed for the gate to work.** It is enforced live on every query either way; what\nneeds a bound is how long a *stale* decision can survive. Of the three controls that look like that\nbound, only the first is:\n\n- **`materialization.freshness` `{ \"window\": \"24h\", \"fallback\": \"live\" }` is the bound.** The serve path\n re-checks freshness per query, so once the artifact ages past the window it drops out of the serving set\n and the query recomputes live, correctly filtered - whether or not a rebuild ever lands. Three details\n decide whether you actually get that. **`fallback` must be `live`**: under `stale_ok` a stale artifact\n keeps being served, which voids the bound, and window and fallback resolve *independently* per layer,\n so a package-level `stale_ok` silently defeats a window you set on the source. That is a statement about\n **layers**, which do not combine - not about siblings, below. Prefer the\n **per-source** spelling `#@ persist name=\"...\" freshness.window=\"24h\" freshness.fallback=\"live\"` over\n the package-wide `materialization.freshness` key: the gated source is what needs the bound, and setting\n it package-wide forces every other persisted source to recompute once stale too. And **a\n content-identical sibling shares the artifact, so it shares the window**: reuse is keyed on the\n content-addressed `sourceEntityId`, which folds the connection and the SQL but *not* the source name, so\n two persist sources whose bodies compute the same SQL resolve to one table carrying one freshness\n policy. The tightest window any of them declares governs all of them - a sibling declaring nothing\n cannot loosen yours, and yours pulls that sibling's reads off the table once it lapses. A sibling's\n `stale_ok` cannot void your bound either: the fold keeps whichever fallback bounds staleness, so the\n layer rule above does not carry over here. If two sources need genuinely different windows, give them\n genuinely different SQL.\n\n Both of those are properties of the **host** that assembles the manifest, not of the annotation. Where\n the host does not fold, which sibling's policy reaches the wire is unspecified; and a host that folds at\n manifest-assembly time typically applies it when a version's manifest is next published rather than\n retroactively to manifests already distributed - so you can declare the window correctly and not have it\n in force yet.\n- **A cron alone is not a bound.** A failed build or a stopped scheduler leaves the source serving its old\n decisions indefinitely. `freshness` and `schedule` are mutually exclusive; for a gated source, take the\n window.\n- **`refresh=\"incremental\"` does not bound revocation.** The delta only re-reads rows in\n `[covered_through, frontier)`, so a row that changes owner *without its watermark advancing* is never\n re-read - while the entry still reports an advancing `coveredThrough` and reads as healthy. Only a full\n rebuild recomputes the gating column.\n\n**And the window only binds where the serving manifest carries it.** Freshness is enforced from fields a\ncontrol plane stamps onto the manifest it distributes; a Publisher that serves what it just built binds the\ntable with no `dataAsOf` and no window, and an entry carrying no window never ages out. So on a standalone\ndeployment the declared window is inert and the artifact serves until the next full rebuild - which leaves a\nrebuild cadence you actually verify as the only bound, and makes leaving a revocation-sensitive source\nunpersisted the safer call.\n\nWhen recommending `#@ persist` on a gated source, pair it with a freshness window and say out loud what\nstaleness the author is accepting. A gated source with neither a window nor a full-rebuild cadence has no\nbound on how long a revoked row keeps being served.\n\n## Gotchas\n\n- **Every `.malloy` file needs the persistence flag** - one unflagged file aborts the whole package's build plan. (A `#@ persist` on a *non*-persistable source, by contrast, is silently ignored and does not affect other sources.)\n- **A tag doesn't build** - a standalone Publisher materializes only on an explicit run or its scheduler; only a hosted control plane builds on publish.\n- **Serve-time routing is `query_source`-only** - a raw `sql_select` builds a table the query path doesn't read; wrap it in a `query_source`.\n- **Quote the name** - a bare `name=` always hard-stops the build.\n- **Republishing unchanged persist logic reuses the table** - reuse is keyed on the content-addressed `sourceEntityId`, not the `name=`.\n- **Removing a persist source (or a smoke test) does not drop its table** - physical-table cleanup is the caller's responsibility; drop it yourself.\n- **A `#(access_filter)`-gated source freezes its gating column when persisted** - the gate still runs live, but a revoked row keeps being served under its old access decision until the next rebuild. `storage=` and `#@ preaggregate` refuse a gated source outright. See **Persisting a `#(access_filter)`-gated source**."
|
|
325453
|
+
body: "# Materialization (Malloy Persistence)\n\nMaterialize an expensive source once so queries read a **pre-built warehouse table** instead of recomputing it every time. You tag a source `#@ persist`, a materialization run builds it into a physical table, and queries against it are rewritten to read that table.\n\n> **The #1 gotcha, up front:** if a persist source isn't materializing, it is almost always one of two things - a `.malloy` file in the package missing the `##! experimental.persistence` flag (which aborts the *whole* package's build plan), or no build ever ran (a standalone Publisher does not build on publish - see **Building and refreshing**). Jump to **Debugging a no-op build**.\n\n## The recipe (get this right and it just works)\n\n1. **`##! experimental.persistence` on EVERY `.malloy` file in the package** - not only the file that declares the persist source. Either form enables it:\n - `##! experimental.persistence`, or\n - `##! experimental { access_modifiers, sql_functions, persistence }` (add `persistence` to the existing list).\n\n **Why every file:** the build plan is computed by asking *every* `.malloy` file in the package for its persist sources, and that call **throws on any file whose model lacks the flag** (`Model must have ##! experimental.persistence`). One unflagged helper or import file, even one with no persist source of its own, aborts the whole package's build plan, so *every* persist source in the package drops out. This is the most common cause of a no-op build.\n\n2. **`#@ persist name=\"...\"` on a query-based source, with the name quoted:**\n ```malloy\n #@ persist name=\"my_dataset.my_table\"\n source: my_rollup is some_source -> { group_by: ...; aggregate: ... }\n ```\n - **Only `query_source` and `sql_select` sources are persistable** - a source whose definition has a `-> { ... }` pipeline or a `conn.sql(\"...\")`. This **includes** one refined by a trailing `extend { ... }`. What is **not** persistable is a *plain* `extend` over a bare `conn.table(...)`; a `#@ persist` on such a source is **silently ignored** (its annotation is never read) - that one source just won't materialize, and the rest of the package still builds.\n - **Quote the name.** `name=\"my_table\"` (or a path `name=\"dataset.table\"` / `name=\"project.dataset.table\"`) is required. A **bare** `name=my_table` **always fails the build/publish** with `persist annotation name must be quoted` (a raw-source scan that hard-stops); it never silently no-ops.\n - `name=` is the target table name. In a standalone Publisher this **is** the physical table (rebuilt in place); a hosted (control-plane) deployment builds it under a content-addressed generation name. In both, the source's identity for reuse is a content address of its connection and canonical SQL (its `sourceEntityId`), so **republishing unchanged persist logic reuses the existing table** and changing the logic builds fresh.\n\n3. **Package persistence policy in `publisher.json`** (all optional):\n ```jsonc\n {\n \"name\": \"my-package\",\n \"materialization\": {\n \"scope\": \"package\", // default; \"version\" = each published version owns its own tables\n \"freshness\": { \"window\": \"24h\", \"fallback\": \"live\" },\n \"queryMetadata\": { \"team\": \"finance\" } // tags the build's backend statements\n }\n }\n ```\n Enforced at publish (strict), on edits (strict), at load (warn, still serves), and by the scheduler (an offending package is skipped):\n - **`scope`**: `package` (default; artifacts reused across published versions) or `version` (each artifact owned by one version). Package-level only; there is no per-source scope. A root-level `scope` is the deprecated home and still works, with a warning; declaring both homes with different values is rejected.\n - **`materialization.freshness`** (`window` + `fallback` of `live`/`stale_ok`/`fail`) is the objective a **hosted control plane** enforces by refreshing the table to meet it (`fallback: \"live\"` serves live compute while stale/absent). A **standalone** Publisher does **not** act on `freshness` for refresh - see **Building and refreshing**.\n - **`materialization.queryMetadata`** is a bag of string properties attached to every statement the build issues, for the backend's own cost attribution (Snowflake `QUERY_TAG`, BigQuery job labels, a leading SQL comment elsewhere). Overridable per source with `#@ persist queryMetadata.<name>=\"<value>\"`. Observability only: it never changes what gets built. See `docs/query-metadata.md`.\n - **`materialization.schedule`** is a 5-field UTC cron (`min hour dom mon dow`; `L`/`W`/`#`/`?` rejected). It **requires `scope: \"version\"`** and is **mutually exclusive with `freshness`**. This is how a standalone Publisher refreshes on a cadence.\n\n4. **Reads vs writes.** The persist source can *read* any dataset the connection can read; the persist *target* (`name=`'s dataset) must be a dataset the connection can **write** (typically a scratch dataset).\n\n## Building and refreshing (standalone vs. hosted)\n\nA `#@ persist` tag declares *what* to materialize; it does not by itself build anything.\n\n- **Standalone Publisher:** publishing or loading a package only computes its build plan - **no table is built until a materialization run executes.** Trigger one explicitly (`malloy-pub materialize --package <pkg> --wait`, or the materialization API), or turn on the opt-in local scheduler (off unless `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` is set) to fire the package's `schedule` cron. Refresh is a re-run or that cron; `freshness` is not a refresh trigger here, so a freshness-only standalone package builds once and is not auto-refreshed.\n- **Hosted (control-plane) deployment:** the build runs automatically on publish, best-effort - a build failure does **not** fail the publish (which is why a broken persist can look like a silent no-op), and the control plane drives refresh to meet the `freshness` objective.\n\nEither way, a successful publish alone does not prove a table exists - confirm the build separately.\n\n## Serve-time routing is `query_source`-only (today)\n\nBoth persistable types *build* a table, but only a **`query_source`** (a `-> { ... }` pipeline) is rewritten to *read* it at query time. A raw **`sql_select`** (`conn.sql(\"...\")`, including `conn.sql(\"...\") extend { ... }`) builds its table and then the query path re-inlines its SQL, so the table is built and never read, and queries are no faster. If you have raw SQL you want served from a table, wrap it in a thin `query_source` and persist that:\n\n```malloy\nsource: x_raw is my_conn.sql(\"select ...\")\n#@ persist name=\"scratch_dataset.x\"\nsource: x is x_raw -> { select: * }\n```\n\n## Confirming it worked\n\nAfter a build runs, re-run one of the source's queries - a persisted `query_source` should return quickly, reading the pre-built table instead of recomputing the upstream. Your host also reports each persisted source as **ready** with its physical table name (a materialization run detail, CLI listing, or materialization view, depending on the host); if nothing is listed, either no build ran (standalone) or the build plan was empty - see **Debugging a no-op build**.\n\n## Debugging a no-op build\n\nSymptom: no table was built and the source still recomputes on every query. Check, in order:\n\n0. **Did a build actually run?** On a standalone Publisher, publish/load does **not** build - run `malloy-pub materialize` (or enable the scheduler). \"Publishes fine, no table\" is the *expected* standalone state, not a model bug. On a hosted deployment the build is automatic but best-effort, so a failure is silent - look for a `FAILED` run.\n1. **A `.malloy` file missing the persistence flag** (the most common real bug). Every model file's `##!` line needs `persistence`, including pure helper/import files with no persist source - one unflagged file aborts the whole package's build plan.\n2. **An unquoted persist name** - a bare `name=foo` **always** hard-stops the build/publish with `persist annotation name must be quoted`; use `name=\"foo\"`. (If you got *no* error at all, it isn't this.)\n3. **A `#@ persist` on a non-persistable source** - a bare `extend` over `conn.table(...)` is silently ignored, so *that* source won't materialize (the rest of the package is unaffected). Tag a `query_source` / `sql_select` instead.\n4. **A persisted raw `sql_select` that builds but is never read** - if the table exists yet queries are no faster, it's the serve-routing gap above; wrap the `sql_select` in a `query_source`.\n\n**Isolation test** - add a trivial, self-contained persist source in its own file and rebuild:\n```malloy\n##! experimental.persistence\nsource: smoke_raw is my_conn.table('some_dataset.some_table')\n#@ persist name=\"scratch_dataset.persist_smoke_test\"\nsource: persist_smoke is smoke_raw -> { aggregate: n is count() }\n```\n- If **even this** doesn't build (after a real materialization run), the whole package's plan is aborting - a sibling `.malloy` file is missing the flag. Fix rule 1 across the package.\n- If the smoke source **does** build but your real one doesn't, your real source is the problem - a non-persistable type (a bare `extend`), or its own file's flag.\n\nDelete the smoke file and drop its table afterward.\n\n## Persisting a `#(access_filter)`-gated source\n\nA gated source **can** be persisted, but only on one tier and only in one shape, and the thing to be\ncareful about is not refused by anything - you have to decide it.\n\n- **`storage=` and `#@ preaggregate` always refuse a gated source**, naming it. The build skips a refused\n source, records it on the run (`metadata.refusedSources`), and builds the rest of the package. A run fails\n on a refusal only when every authored source it targeted was refused, or when `sourceNames` named this one;\n a refused rollup never fails it. (`#@ persist storage=<name>` is the tier that materializes into a separate\n registered storage destination and serves from there, rather than building in the source's own connection;\n `#@ preaggregate` stores a rollup Publisher derives from a measure you annotated with a grain, rather than a\n source you wrote.) A rollup also groups *across* the gated column, so it could not be row-filtered afterwards\n even in principle.\n- **A colocated `#@ persist` (no `storage=`) is admitted** when the gate is provably the entry point's\n **own row filter**. It is refused when the gate is reached only through a join, inherited from a base\n the compiler cannot attribute cleanly, or does not classify as a row filter at all. The gate is found\n through the import -> rename -> `query_source` chain, so a gate the persisted source did not declare\n itself still counts.\n\n**What to be wary of.** Persisting does not weaken the gate: it changes only where rows are read FROM, and\nthe gate still runs live on every query as that query's own `WHERE`, so filtered rows come back filtered.\nWhat freezes is the **column the gate filters on**. A row whose access decision changes - it changes\nowner, say - keeps being served under its OLD decision until the next rebuild. That is a stale *access\ndecision*, not merely stale data, and nothing raises an error.\n\n**None of this is needed for the gate to work.** It is enforced live on every query either way; what\nneeds a bound is how long a *stale* decision can survive. Of the three controls that look like that\nbound, only the first is:\n\n- **`materialization.freshness` `{ \"window\": \"24h\", \"fallback\": \"live\" }` is the bound.** The serve path\n re-checks freshness per query, so once the artifact ages past the window it drops out of the serving set\n and the query recomputes live, correctly filtered - whether or not a rebuild ever lands. Three details\n decide whether you actually get that. **`fallback` must be `live`**: under `stale_ok` a stale artifact\n keeps being served, which voids the bound, and window and fallback resolve *independently* per layer,\n so a package-level `stale_ok` silently defeats a window you set on the source. That is a statement about\n **layers**, which do not combine - not about siblings, below. Prefer the\n **per-source** spelling `#@ persist name=\"...\" freshness.window=\"24h\" freshness.fallback=\"live\"` over\n the package-wide `materialization.freshness` key: the gated source is what needs the bound, and setting\n it package-wide forces every other persisted source to recompute once stale too. And **a\n content-identical sibling shares the artifact, so it shares the window**: reuse is keyed on the\n content-addressed `sourceEntityId`, which folds the connection and the SQL but *not* the source name, so\n two persist sources whose bodies compute the same SQL resolve to one table carrying one freshness\n policy. The tightest window any of them declares governs all of them - a sibling declaring nothing\n cannot loosen yours, and yours pulls that sibling's reads off the table once it lapses. A sibling's\n `stale_ok` cannot void your bound either: the fold keeps whichever fallback bounds staleness, so the\n layer rule above does not carry over here. If two sources need genuinely different windows, give them\n genuinely different SQL.\n\n Both of those are properties of the **host** that assembles the manifest, not of the annotation. Where\n the host does not fold, which sibling's policy reaches the wire is unspecified; and a host that folds at\n manifest-assembly time typically applies it when a version's manifest is next published rather than\n retroactively to manifests already distributed - so you can declare the window correctly and not have it\n in force yet.\n- **A cron alone is not a bound.** A failed build or a stopped scheduler leaves the source serving its old\n decisions indefinitely. `freshness` and `schedule` are mutually exclusive; for a gated source, take the\n window.\n- **`refresh=\"incremental\"` does not bound revocation.** The delta only re-reads rows in\n `[covered_through, frontier)`, so a row that changes owner *without its watermark advancing* is never\n re-read - while the entry still reports an advancing `coveredThrough` and reads as healthy. Only a full\n rebuild recomputes the gating column.\n\n**And the window only binds where the serving manifest carries it.** Freshness is enforced from fields a\ncontrol plane stamps onto the manifest it distributes; a Publisher that serves what it just built binds the\ntable with no `dataAsOf` and no window, and an entry carrying no window never ages out. So on a standalone\ndeployment the declared window is inert and the artifact serves until the next full rebuild - which leaves a\nrebuild cadence you actually verify as the only bound, and makes leaving a revocation-sensitive source\nunpersisted the safer call.\n\nWhen recommending `#@ persist` on a gated source, pair it with a freshness window and say out loud what\nstaleness the author is accepting. A gated source with neither a window nor a full-rebuild cadence has no\nbound on how long a revoked row keeps being served.\n\n## Gotchas\n\n- **Every `.malloy` file needs the persistence flag** - one unflagged file aborts the whole package's build plan. (A `#@ persist` on a *non*-persistable source, by contrast, is silently ignored and does not affect other sources.)\n- **A tag doesn't build** - a standalone Publisher materializes only on an explicit run or its scheduler; only a hosted control plane builds on publish.\n- **Serve-time routing is `query_source`-only** - a raw `sql_select` builds a table the query path doesn't read; wrap it in a `query_source`.\n- **Quote the name** - a bare `name=` always hard-stops the build.\n- **Republishing unchanged persist logic reuses the table** - reuse is keyed on the content-addressed `sourceEntityId`, not the `name=`.\n- **Removing a persist source (or a smoke test) does not drop its table** - physical-table cleanup is the caller's responsibility; drop it yourself.\n- **A `#(access_filter)`-gated source freezes its gating column when persisted** - the gate still runs live, but a revoked row keeps being served under its old access decision until the next rebuild. `storage=` and `#@ preaggregate` refuse a gated source outright. See **Persisting a `#(access_filter)`-gated source**."
|
|
324699
325454
|
},
|
|
324700
325455
|
{
|
|
324701
325456
|
name: "malloy-materialization-tuning",
|
|
324702
325457
|
description: "Optimize a package's Malloy Persistence materializations for cost and performance using the malloy-pub CLI and the materialization history. Recommend what to persist, what to stop persisting, and how to schedule/scope it. Use when the user asks to make a package cheaper or faster, tune persistence, decide what to materialize, or review persist/schedule choices.",
|
|
324703
|
-
body: "# Tuning materializations for cost and performance\n\nThis skill turns the signals the open-source Publisher already records (the materialization history, per-run timings, and which sources were built vs reused) into concrete, **recommendations-only** advice: which sources to persist, which to stop persisting, and how to schedule and scope them. It is the local counterpart to the platform's usage-driven optimization: the Publisher has the raw signals, and you read them with the `malloy-pub` CLI.\n\n> **Recommendations only. Never change a model, schedule, or scope without the user's explicit go-ahead.** Present the findings and the proposed edits, then apply them only when asked. Persisting the wrong source wastes storage and rebuild time; unpersisting a hot one makes queries slow. Let the user decide.\n\nAssumes the `malloy-pub` CLI is on PATH and points at the server (`--url` or `MALLOY_PUBLISHER_URL`, default `http://localhost:4000`). Substitute the real environment and package for `<env>` / `<pkg>`.\n\n## Step 1: Take inventory\n\nEstablish what the package persists today and how it is governed.\n\n- **Persist sources:** the sources annotated `#@ persist name=\"…\"` in the package's `.malloy` files. Read the models (or `get_context` the package) to list them.\n- **Schedule + scope:**\n\n ```bash\n malloy-pub schedule view --environment <env> --package <pkg>\n ```\n\n This prints the cron (or `none`, meaning publish / on-demand only), the persist **scope** (`package` = artifacts reused across versions; `version` = per published version), and whether a freshness policy is set. A control-plane-managed package (manifestLocation set) is refreshed by the control plane, not the standalone scheduler, so leave its cadence alone.\n\n## Step 2: Read the materialization history\n\nThe history is where cost lives. Each run records its trigger, timing, and how many sources were built vs reused.\n\n- **For one package** (newest first). A materialization is a run of one package's persist sources, so this is the listing; to review a whole environment, read each package in turn (`malloy-pub list package --environment <env>` names them):\n\n ```bash\n malloy-pub list materialization --environment <env> --package <pkg>\n ```\n\n Columns: ID, Status, **Trigger** (`SCHEDULER` vs `ON_DEMAND`), Started, Completed, Error.\n\n- **A single run's detail** (the cost signals):\n\n ```bash\n malloy-pub get materialization <id> --environment <env> --package <pkg>\n ```\n\n In the JSON, read:\n - `metadata.durationMs`: how long the build took.\n - `metadata.sourcesBuilt` vs `metadata.sourcesReused`: how much work each run actually did. A run that is nearly all _reused_ is cheap; one that is nearly all _built_ every time is where cost accumulates.\n - `metadata.trigger`: `SCHEDULER` (a cron fired it) or `ON_DEMAND`.\n - `manifest.entries[*]`: the persisted sources: `sourceName`, `physicalTableName`, `realization`.\n\nLook across several runs, not one: the pattern over the recent history (how often it rebuilds, how much it reuses, how long it takes) is the signal.\n\n## Step 3: Analyze and recommend\n\nWeigh rebuild cost against query benefit. Common findings:\n\n- **Persist candidate:** an expensive, frequently-queried source that is _not_ persisted (recomputed on every query). Recommend adding `#@ persist name=\"…\"`. Strongest when the source is a heavy aggregate/join reused by many queries and its inputs change slowly. If the source carries a `#(access_filter)` gate, check which case it is first. A gate reached only through a `join_*`, or inherited from a base it cannot be attributed to,
|
|
325458
|
+
body: "# Tuning materializations for cost and performance\n\nThis skill turns the signals the open-source Publisher already records (the materialization history, per-run timings, and which sources were built vs reused) into concrete, **recommendations-only** advice: which sources to persist, which to stop persisting, and how to schedule and scope them. It is the local counterpart to the platform's usage-driven optimization: the Publisher has the raw signals, and you read them with the `malloy-pub` CLI.\n\n> **Recommendations only. Never change a model, schedule, or scope without the user's explicit go-ahead.** Present the findings and the proposed edits, then apply them only when asked. Persisting the wrong source wastes storage and rebuild time; unpersisting a hot one makes queries slow. Let the user decide.\n\nAssumes the `malloy-pub` CLI is on PATH and points at the server (`--url` or `MALLOY_PUBLISHER_URL`, default `http://localhost:4000`). Substitute the real environment and package for `<env>` / `<pkg>`.\n\n## Step 1: Take inventory\n\nEstablish what the package persists today and how it is governed.\n\n- **Persist sources:** the sources annotated `#@ persist name=\"…\"` in the package's `.malloy` files. Read the models (or `get_context` the package) to list them.\n- **Schedule + scope:**\n\n ```bash\n malloy-pub schedule view --environment <env> --package <pkg>\n ```\n\n This prints the cron (or `none`, meaning publish / on-demand only), the persist **scope** (`package` = artifacts reused across versions; `version` = per published version), and whether a freshness policy is set. A control-plane-managed package (manifestLocation set) is refreshed by the control plane, not the standalone scheduler, so leave its cadence alone.\n\n## Step 2: Read the materialization history\n\nThe history is where cost lives. Each run records its trigger, timing, and how many sources were built vs reused.\n\n- **For one package** (newest first). A materialization is a run of one package's persist sources, so this is the listing; to review a whole environment, read each package in turn (`malloy-pub list package --environment <env>` names them):\n\n ```bash\n malloy-pub list materialization --environment <env> --package <pkg>\n ```\n\n Columns: ID, Status, **Trigger** (`SCHEDULER` vs `ON_DEMAND`), Started, Completed, Error.\n\n- **A single run's detail** (the cost signals):\n\n ```bash\n malloy-pub get materialization <id> --environment <env> --package <pkg>\n ```\n\n In the JSON, read:\n - `metadata.durationMs`: how long the build took.\n - `metadata.sourcesBuilt` vs `metadata.sourcesReused`: how much work each run actually did. A run that is nearly all _reused_ is cheap; one that is nearly all _built_ every time is where cost accumulates.\n - `metadata.sourcesRefused` and `metadata.refusedSources`: sources the eligibility gate skipped. Each one serves live on every query, so a refused source the user expected to be persisted is a finding in its own right; its entry's `message` says what to change.\n - `metadata.trigger`: `SCHEDULER` (a cron fired it) or `ON_DEMAND`.\n - `manifest.entries[*]`: the persisted sources: `sourceName`, `physicalTableName`, `realization`.\n\nLook across several runs, not one: the pattern over the recent history (how often it rebuilds, how much it reuses, how long it takes) is the signal.\n\n## Step 3: Analyze and recommend\n\nWeigh rebuild cost against query benefit. Common findings:\n\n- **Persist candidate:** an expensive, frequently-queried source that is _not_ persisted (recomputed on every query). Recommend adding `#@ persist name=\"…\"`. Strongest when the source is a heavy aggregate/join reused by many queries and its inputs change slowly. If the source carries a `#(access_filter)` gate, check which case it is first. A gate reached only through a `join_*`, or inherited from a base it cannot be attributed to, means the colocated persist is **refused** - the build skips it and it serves live - so do not recommend persisting it at all. Where the gate is provably the source's own row filter the persist is admitted, and then only recommend it alongside a freshness window (`freshness.fallback=\"live\"`): the gating column freezes at build time, so without one a revoked row can be served under its old access decision indefinitely (see `skill:malloy-materialization`, which also covers where that window does and does not bind).\n- **Removal candidate:** a persisted source that is cheap to compute, rarely queried, or rebuilt far more often than it is read. Recommend dropping the `#@ persist` annotation (and its table): the storage + rebuild cost is not buying anything.\n- **Cadence mismatch:** a `SCHEDULER` cadence out of step with how fast the data changes or how long a build takes. If most scheduled runs are all-reused (nothing changed), the cron is too frequent, so loosen it. If queries routinely read stale data, tighten it. If the build's `durationMs` approaches the interval, the cadence is too aggressive.\n- **Scope mismatch:** `scope: version` re-materializes per published version (right when versions must be isolated, e.g. a schedule); `scope: package` reuses one lineage across versions (cheaper when versions can share). A schedule _requires_ `version`. If a package carries a schedule it does not need, clearing the schedule frees it to use the cheaper package scope.\n\nFrame each recommendation with the evidence from Step 2 (the run IDs, timings, built/reused counts) so the user can judge it.\n\n## Step 4: Apply (only once approved)\n\n- **Add a persist source:** add the `#@ persist` annotation in the `.malloy` file (use the modeling workflow to validate and reload), then rebuild so it is materialized:\n\n ```bash\n malloy-pub materialize --environment <env> --package <pkg> --wait\n ```\n\n- **Remove a persist source:** delete the `#@ persist` annotation, then drop the old run's tables **before** rebuilding what remains:\n\n ```bash\n malloy-pub delete materialization <id> --environment <env> --package <pkg> --drop-tables\n malloy-pub materialize --environment <env> --package <pkg> --wait\n ```\n\n > `--drop-tables` drops **every** physical table in that run's manifest, not just the removed source's: auto-run assigns stable table names and carries unchanged sources forward, so an old run's manifest names tables a newer manifest still serves. Do the drop **first, then rebuild**: `materialize --wait` re-creates every source that is still persisted, ending in the desired state. Dropping a run whose tables the current serving manifest depends on, without an immediate rebuild, breaks queries (`Table ... does not exist`).\n\n- **Change the schedule / cadence:**\n\n ```bash\n malloy-pub schedule set \"0 6 * * *\" --environment <env> --package <pkg> # 5-field UTC cron\n malloy-pub schedule clear --environment <env> --package <pkg>\n ```\n\n `set` also sets `scope: version` (a schedule requires it); the server rejects an invalid cron or an illegal scope/freshness combination, so a rejection means the change was unsafe.\n\n- **Change the scope:** `scope` is declared in the package's `publisher.json` under `materialization` (`\"scope\": \"package\"` or `\"version\"`; the root-level form is deprecated but still read). Edit it there, then reload/republish the package. Remember a schedule pins scope to `version`, so clear the schedule first if moving to `package`.\n\nAfter applying, re-run Step 2 on the next few builds to confirm the change did what you predicted (more reuse, shorter builds, or a table that is actually read).\n\n## What this skill does not do\n\n- It does not decide for the user or apply changes silently; every edit needs an explicit go-ahead.\n- It cannot see per-query read counts (the open-source Publisher records build history, not query-level table usage), so \"rarely queried\" is a judgment from the model and the user's knowledge, not a measured metric. Say so when it matters."
|
|
324704
325459
|
},
|
|
324705
325460
|
{
|
|
324706
325461
|
name: "malloy-model",
|
|
@@ -325687,7 +326442,7 @@ source: first_touch is conn.table('events') -> {
|
|
|
325687
326442
|
{
|
|
325688
326443
|
name: "malloy-modeling",
|
|
325689
326444
|
description: "Build semantic models with Malloy for the Malloy Publisher. Read this skill whenever the user asks about modeling data or specifically mentions Malloy.",
|
|
325690
|
-
body: "# STOP - READ BEFORE WRITING ANY MALLOY CODE\n\n> **AI AGENTS: You MUST review this file before writing Malloy code.** Cross-skill references below use logical `skill:` names; load the referenced skill before acting. Before writing code, also read the gotcha skills: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering`.\n\n## Pre-Flight Checklist\n\n1. **Discover first**: ground yourself before writing ANY code, with the tool that matches what you are modelling.\n - Modelling data **already in a package**: `get_context` returns that package's sources, views, and fields (with their docs).\n - Modelling **a database with no package yet**: `get_context` has nothing to return, so use `search_database_schema` instead. It walks the connection's schemas and tables, ranks them against a plain-English description, and gives you each table's columns plus the `source:` line to start from. Take those names verbatim into step 5.\n Never guess field names either way.\n2. **Search docs proactively**: call `search_malloy_docs` BEFORE writing unfamiliar patterns (window functions, query-based sources, pipelines). Don't guess. Malloy syntax is specific and SQL intuition is often wrong.\n3. **Use `skill:malloy-patterns`** to discover available doc topics (YoY, cohorts, rendering, window functions).\n4. **Check diagnostics** after writing: fix the FIRST error first, errors cascade.\n5. **Read the gotcha skills**: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering` prevent the most common mistakes.\n\n**Quick syntax reminders:**\n1. **Backtick reserved words:** `` `Date` ``, `` `Hour` ``, `` `Timestamp` ``, `` `Type` ``, `` `number` ``, `` `source` ``\n2. **Use `having:` for aggregate filters**: not `where:` on measures\n3. **Alias joined fields in `group_by`** if using them in `order_by`\n4. **`count()` counts rows; `count(x)` counts distinct values of `x`**: `count(distinct x)` is deprecated, write `count(x)`\n5. **One tag per line**: `# label=\"Revenue\"` and `# currency` on separate lines\n6. **No fixed scale on measures**: use `# currency` not `# currency=usd0m`\n7. **Cast strings for aggregates:** `avg(score::number)` not `avg(score)`\n8. **Boolean columns:** use `= true` not `= 'true'` (no quotes!)\n9. **Read data files in place:** `.csv`, `.parquet`, `.json`, `.ndjson`, and `.xlsx` all work as-is through `duckdb.table('data/file.ext')`. Never convert a file to another format first, and never read one with python or jq to \"have a look\" first: query it. For `.xlsx`, check the row count before trusting it: a workbook with a title row or a blank spacer reads short and reports no error. (Per-format quirks: `skill:malloy-gotchas-modeling`)\n\n## Planning and `modeling-notes.md`\n\nIf the IDE has a native plan mode, use it for the high-level approach: do data exploration during planning, then present a concrete plan for user approval before writing any files.\n\n`modeling-notes.md` is an expected output of the workflow, not an optional extra. Start it at step 2 (Propose Scope) and grow it as you work: it persists alongside the model, and its value is as the thing the user argues with at step 3, before source files exist; written after the build it can only document decisions already baked in. Record findings and problems as they are found during discovery (`skill:malloy-discover`), and every unconfirmed decision as an open item. Only when there is no writable workspace do the notes live in the conversation instead.\n\nKeep it compact, with these sections:\n\n```markdown\n# Modeling notes - <package>\n## Scope what was confirmed, what the model is FOR, skip list with reasons\n## Grain and keys proven by query, not by column name\n## Coverage coverage cliffs; columns excluded for nullity\n## Decisions each with its evidence\n## Open decisions ASSUMPTIONS, NOT CONFIRMED: every threshold or definition the user\n has not settled, one entry each, mirrored by a hedge in its #(doc)\n## Validation reconciliation checks performed, and their results\n```\n\n## 8-Step Modeling Workflow\n\nThe agent orchestrates all steps. Steps marked **(user)** pause for input. Each step has a dedicated skill with full instructions. Read each step's skill **before starting that step**, including the decision skills for steps 1–4 (`skill:malloy-discover`, `skill:malloy-scope`, `skill:malloy-define`). They govern what the model says; skipping them to reach the build skills is how unreviewed business logic ships.\n\n**A field is not complete until it has its definition, `#(doc)` tag, and rendering tags, and any threshold or business convention in it is user-confirmed, distribution-derived, or explicitly flagged in its `#(doc)`** (see `skill:malloy-document` § Mark conventions as conventions). Documentation is part of defining a field, not a separate activity. Read `skill:malloy-document` for full documentation standards (doc string writing, tag ordering).\n\n```\nDISCOVER → SCOPE → SOURCES → DEFINITIONS → BUILD BASE → BUILD JOINED → REVIEW → CURATE\n (silent) (user) (user) (user) (agent) (agent) (user) (user)\n```\n\n| Step | Skill | What Happens |\n|------|-------|-------------|\n| 1. Discover | `skill:malloy-discover` | Read the model and data; scan sources, fields, distributions; detect prior art. With no package yet, start from `search_database_schema` to find the tables in the connection |\n| 2. Propose Scope | `skill:malloy-scope` | Present findings, user selects focus |\n| 3. Propose Sources | `skill:malloy-define` | Propose source plan, user confirms architecture |\n| 4. Propose Definitions | `skill:malloy-define` | Propose fields per base source, user confirms logic |\n| 5. Build Base Sources | `skill:malloy-model` | Write fully documented base source files (one per table), check diagnostics. Read `skill:malloy-document` for doc standards. |\n| 6. Build Joined Sources | `skill:malloy-model` | Write fully documented joined source files, validate. Read `skill:malloy-document` for doc standards. |\n| 7. Review | (none) | Present the review checklist below; user confirms or corrects |\n| 8. Curate | `skill:malloy-model` | Propose access controls (`explores`, `queryableSources`, access modifiers); always propose, the user decides whether to apply |\n\n### The pauses are the point\n\nThese are governed semantic models: the business decisions in them must be confirmed by a human subject-matter expert, and the **(user)** steps exist to collect that confirmation. They are real stops, not progress reports. A model can be complete, compiling, and fully documented and still be wrong everywhere it guessed; a capable agent can build the whole thing without pausing once, which is exactly the failure mode this workflow exists to prevent.\n\nWhen a decision goes unanswered (the user explicitly declines to decide, or nobody is there to ask), do not silently proceed as if it were settled. Take your best-supported position, label it an assumption in the field's own `#(doc)` (see `skill:malloy-document` § Mark conventions as conventions), record it under \"Open decisions\" in `modeling-notes.md`, and raise it again at Review. An unlabeled assumption is indistinguishable from a confirmed fact, and misleads everyone downstream.\n\n### Step 7 Review is a checklist, not a summary\n\nPresent these to the user, with answers:\n\n- **Which definitions did the user actually confirm?** List them; everything else is an assumption.\n- **Which thresholds and bucket boundaries did you choose?** For each: the evidence (distribution query, metadata, prior art) and the `#(doc)` hedge that marks it.\n- **Which questions were left unanswered?** Each must already carry a labeled assumption and an \"Open decisions\" entry.\n- **Does the headline metric have more than one defensible definition?** If yes, that is a blocking question: put the candidate definitions to the user with their counts side by side, not in a footnote.\n\nThe user confirming this checklist is what makes the model governed. A summary of what you built is not a checkpoint.\n\nPublishing is out of scope for open-source v1. Self-hosters move a finished model into a served package via git and the host's publish path; see `skill:malloy-publish` for the local-to-served handoff.\n\n**Two paths to a model: both produce the same fully documented result:**\n- **Schema-first:** \"Model my data\" → 8-step workflow above using the relevant skills\n- **Analysis-first:** a data question arrives before any model exists → `skill:malloy-model-as-you-go`. It answers the question with `skill:malloy-analysis`, then codifies what the answer assumed into the model, one question at a time, confirming binding decisions first. The model exists by the end; there is no separate formalize step.\n- **Open-ended exploration** with no intent to keep anything: `skill:malloy-analyze`. If it turns into something worth keeping, formalize via `skill:malloy-model` (`reference/analysis-to-model.md`).\n\n## Agent Behavior\n\n**Research before asking.** Present proposals with evidence. Never ask open-ended questions: propose with data and let the user confirm.\n\n**Use business language.** Say \"I simplified the column name\" not \"reserved word replaced.\" Don't expose Malloy internals unless the user asks.\n\n**Describe what you're doing, not which step you're on.** The user doesn't have the skill files open. Say \"I'll propose which tables to include and how they relate\" not \"Steps 3 and 4.\" Say \"Now I'll write the source files\" not \"Moving to Step 5.\" Explain the purpose of each phase in plain language before doing it.\n\n**Present choices as A/B/C.** When asking the user to choose, use lettered options with one-line descriptions. Mark your recommendation.\n\n**Complete all workflow steps.** Once modeling begins, complete through Review and propose Curate. A field without documentation is not finished. If you lose track, re-read the model and your notes. Suggest notebooks at the end.\n\n## Route by Intent\n\n| User says... | Route to |\n|-------------|----------|\n| \"Model my data\", \"create a model\" | 8-step workflow (`skill:malloy-discover`) |\n| \"Model from LookML\" | 8-step with prior art via `skill:malloy-lookml-review` |\n| \"Explore this data\", \"what's interesting?\", \"show me the top X\" | `skill:malloy-analyze` (EDA) |\n| \"Build a dashboard\", \"create views\" on existing model | `skill:malloy-analyze` (views), plus `skill:malloy-charts` or `skill:malloy-notebooks` as needed |\n| \"Build a model but not sure what metrics\" | `skill:malloy-model-as-you-go`: answer their first real question, codify what it assumed, repeat |\n\n**If the user's first message is a data question** (not \"build me a model\"), route to `skill:malloy-model-as-you-go`. It answers with `skill:malloy-analysis` and grows the model from what each answer assumed, so there is nothing to formalize afterwards.\n\n## Additional Support Skills\n\nThese supplemental skills may also be loaded as needed:\n\n- **`skill:malloy`**: Index of Malloy skills and routing guide\n- **`skill:malloy-debug`**: Fix compile errors and interpret diagnostics\n\n## Publisher MCP Tools\n\nEnsure the Publisher MCP tools are configured before modeling. No server yet? `skill:malloy-getting-started` covers setup, including the one-command scaffolder (`npm create @malloy-publisher/malloy-package@latest <name>`) and why local authoring needs `--watch-env <env>`: start the server without it and your saved edits are never read.\n\n| Tool | Purpose |\n|------|---------|\n| `get_context` | Ground yourself in a package: its sources, views, and fields |\n| `execute_query` | Run ad-hoc queries for validation |\n| `compile_model` | Compile-check a change and get diagnostics back without running a query |\n| `reload_package` | Recompile a package from disk so a saved edit becomes queryable by name |\n| `search_malloy_docs` | Search Malloy docs (call BEFORE unfamiliar patterns) |\n| `search_database_schema` | Find the tables in a database connection by plain-English description, when modelling data that is not in a package yet. Returns each table's columns and the `source:` line to start from. Names and types only: no row value is returned |\n\nNever guess field names. Ground yourself with `get_context` to see the sources and fields a package defines.\n\n### The edit-and-run loop\n\nPublisher compiles each configured package at boot and serves that cached model, so a source or view you add afterwards is not queryable by name until you reload the package. The loop is:\n\n1. **Validate** the change with `compile_model`, picking the scope that matches what you are doing:\n - Adding a new definition or query: the default (`scope: \"append\"`) compiles your text in the model's namespace. Note its diagnostic positions land in the model-plus-your-text concatenation.\n - **Editing an existing definition: `scope: \"file\"`**, with the whole edited file as `source`. It compiles your text AS the file (append would collide with \"Cannot redefine\"), and diagnostics land at the true line numbers of your text.\n - Before saving a change other files import: `scope: \"package\"` with the edited file as `source` runs reload's worker compiler over every `.malloy` and `.malloynb` file against your edit, so a rename that breaks an importer surfaces now instead of at reload. Each diagnostic carries `model`, the file it points at; files hidden from discovery can appear. If `modelPath` does not exactly match an existing file, a warning says the source was treated as new.\n2. **Save** it to the package's model file.\n3. **Reload** with `reload_package`.\n4. **Run** the new view with `execute_query`.\n\nA reload that fails to compile is safe: your files are left alone and the previously compiled model keeps serving, with the compile errors returned to you. Compile first anyway for faster feedback, and a `scope: \"package\"` dry-run with no `source` uses reload's compiler and file selection (imports across files, every `.malloy` and `.malloynb` file as saved) without touching the served model. Keep the source of truth outside `publisher_data/`, which is not version-controlled and is wiped by a `--init` restart. If these tools are missing, the Publisher you are connected to predates them; fall back to validating with a throwaway `execute_query`. An older Publisher that has `compile_model` but rejects `scope` supports only the append behavior.\n\n## SQL-to-Malloy Quick Reference\n\n| SQL | Malloy |\n|-----|--------|\n| `COUNT(*)` | `count()` |\n| `COUNT(DISTINCT x)` | `count(x)` |\n| `NOW()` | `now` |\n| `CASE WHEN...END` | `pick...when...else` |\n| `col IN ('a','b')` | `col ? 'a' \\| 'b'` |\n| `COALESCE(a,b)` | `a ?? b` |\n| `CAST(x AS type)` | `x::type` |\n| `DATEDIFF(day, a, b)` | `days(a to b)` |\n| `CONCAT(a, b)` or `a \\|\\| b` | `concat(a, b)` |\n| `TIMESTAMP_DIFF(a, b, SECOND)` | `seconds(b to a)` |\n\n## Critical Rules\n\n1. **All keywords require colons**: `source:`, `dimension:`, `measure:`, `view:`\n2. **Use `is` not `as`**: `dimension: name is expression`\n3. **Arrow operator required**: `run: source -> { operations }`\n4. **Specify join type**: `join_one:`, `join_many:`, `join_cross:`\n5. **Safe division**: `revenue / nullif(count, 0)`\n6. **Group definitions under one keyword**: `measure:` then indent fields beneath\n\n## Common Anti-Patterns\n\n```\nWRONG: source flights is ... RIGHT: source: flights is ...\nWRONG: dimension: x as y RIGHT: dimension: y is x\nWRONG: count(*) RIGHT: count()\nWRONG: count(distinct x) RIGHT: count(x)\nWRONG: revenue / order_count RIGHT: revenue / nullif(order_count, 0)\nWRONG: run: src { ... } RIGHT: run: src -> { ... }\n```\n\n## Reserved Words: Scan Schema First\n\n**Malloy has many reserved words. When in doubt, backtick it.** Most likely to appear as column names:\n\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n- `string`, `boolean`, `true`, `false`: backtick any column with these exact names\n\n## Gotcha Skills: Read Before Writing Code\n\nThe following skills contain detailed WRONG/RIGHT patterns that prevent the most common Malloy errors. **Read them before writing code:**\n\n- **`skill:malloy-gotchas-modeling`**: Reserved words, NULL checks, date functions, type casts, rename pitfalls, query-based source gotchas, `conn.sql()` anti-pattern\n- **`skill:malloy-gotchas-queries`**: Chart constraints, aggregate filters, joined field aliasing, time truncation vs extraction\n- **`skill:malloy-gotchas-rendering`**: Tag syntax, scale rules, sparkline setup, big_value patterns"
|
|
326445
|
+
body: "# STOP - READ BEFORE WRITING ANY MALLOY CODE\n\n> **AI AGENTS: You MUST review this file before writing Malloy code.** Cross-skill references below use logical `skill:` names; load the referenced skill before acting. Before writing code, also read the gotcha skills: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering`.\n\n## Pre-Flight Checklist\n\n1. **Discover first**: ground yourself before writing ANY code, with the tool that matches what you are modelling.\n - Modelling data **already in a package**: `get_context` returns that package's sources, views, and fields (with their docs).\n - Modelling **a database with no package yet**: `get_context` has nothing to return, so use `search_database_schema` instead. It walks the connection's schemas and tables, ranks them against a plain-English description, and gives you each table's columns plus the `source:` line to start from. Take those names verbatim into step 5.\n Never guess field names either way.\n2. **Search docs proactively**: call `search_malloy_docs` BEFORE writing unfamiliar patterns (window functions, query-based sources, pipelines). Don't guess. Malloy syntax is specific and SQL intuition is often wrong.\n3. **Use `skill:malloy-patterns`** to discover available doc topics (YoY, cohorts, rendering, window functions).\n4. **Check diagnostics** after writing: fix the FIRST error first, errors cascade.\n5. **Read the gotcha skills**: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering` prevent the most common mistakes.\n\n**Quick syntax reminders:**\n1. **Backtick reserved words:** `` `Date` ``, `` `Hour` ``, `` `Timestamp` ``, `` `Type` ``, `` `number` ``, `` `source` ``\n2. **Use `having:` for aggregate filters**: not `where:` on measures\n3. **Alias joined fields in `group_by`** if using them in `order_by`\n4. **`count()` counts rows; `count(x)` counts distinct values of `x`**: `count(distinct x)` is deprecated, write `count(x)`\n5. **One tag per line**: `# label=\"Revenue\"` and `# currency` on separate lines\n6. **No fixed scale on measures**: use `# currency` not `# currency=usd0m`\n7. **Cast strings for aggregates:** `avg(score::number)` not `avg(score)`\n8. **Boolean columns:** use `= true` not `= 'true'` (no quotes!)\n9. **Read data files in place:** `.csv`, `.parquet`, `.json`, `.ndjson`, and `.xlsx` all work as-is through `duckdb.table('data/file.ext')`. Never convert a file to another format first, and never read one with python or jq to \"have a look\" first: query it. For `.xlsx`, check the row count before trusting it: a workbook with a title row or a blank spacer reads short and reports no error. (Per-format quirks: `skill:malloy-gotchas-modeling`)\n\n## Planning and `modeling-notes.md`\n\nIf the IDE has a native plan mode, use it for the high-level approach: do data exploration during planning, then present a concrete plan for user approval before writing any files.\n\n`modeling-notes.md` is an expected output of the workflow, not an optional extra. Start it at step 2 (Propose Scope) and grow it as you work: it persists alongside the model, and its value is as the thing the user argues with at step 3, before source files exist; written after the build it can only document decisions already baked in. Record findings and problems as they are found during discovery (`skill:malloy-discover`), and every unconfirmed decision as an open item. Only when there is no writable workspace do the notes live in the conversation instead.\n\nKeep it compact, with these sections:\n\n```markdown\n# Modeling notes - <package>\n## Scope what was confirmed, what the model is FOR, skip list with reasons\n## Grain and keys proven by query, not by column name\n## Coverage coverage cliffs; columns excluded for nullity\n## Decisions each with its evidence\n## Open decisions ASSUMPTIONS, NOT CONFIRMED: every threshold or definition the user\n has not settled, one entry each, mirrored by a hedge in its #(doc)\n## Validation reconciliation checks performed, and their results\n```\n\n## 8-Step Modeling Workflow\n\nThe agent orchestrates all steps. Steps marked **(user)** pause for input. Each step has a dedicated skill with full instructions. Read each step's skill **before starting that step**, including the decision skills for steps 1–4 (`skill:malloy-discover`, `skill:malloy-scope`, `skill:malloy-define`). They govern what the model says; skipping them to reach the build skills is how unreviewed business logic ships.\n\n**A field is not complete until it has its definition, `#(doc)` tag, and rendering tags, and any threshold or business convention in it is user-confirmed, distribution-derived, or explicitly flagged in its `#(doc)`** (see `skill:malloy-document` § Mark conventions as conventions). Documentation is part of defining a field, not a separate activity. Read `skill:malloy-document` for full documentation standards (doc string writing, tag ordering).\n\n```\nDISCOVER → SCOPE → SOURCES → DEFINITIONS → BUILD BASE → BUILD JOINED → REVIEW → CURATE\n (silent) (user) (user) (user) (agent) (agent) (user) (user)\n```\n\n| Step | Skill | What Happens |\n|------|-------|-------------|\n| 1. Discover | `skill:malloy-discover` | Read the model and data; scan sources, fields, distributions; detect prior art. With no package yet, start from `search_database_schema` to find the tables in the connection |\n| 2. Propose Scope | `skill:malloy-scope` | Present findings, user selects focus |\n| 3. Propose Sources | `skill:malloy-define` | Propose source plan, user confirms architecture |\n| 4. Propose Definitions | `skill:malloy-define` | Propose fields per base source, user confirms logic |\n| 5. Build Base Sources | `skill:malloy-model` | Write fully documented base source files (one per table), check diagnostics. Read `skill:malloy-document` for doc standards. |\n| 6. Build Joined Sources | `skill:malloy-model` | Write fully documented joined source files, validate. Read `skill:malloy-document` for doc standards. |\n| 7. Review | (none) | Present the review checklist below; user confirms or corrects |\n| 8. Curate | `skill:malloy-model` | Propose the published surface (an `index.malloy` with `export { ... }`) and access controls (access modifiers, gates); always propose, the user decides whether to apply |\n\n### The pauses are the point\n\nThese are governed semantic models: the business decisions in them must be confirmed by a human subject-matter expert, and the **(user)** steps exist to collect that confirmation. They are real stops, not progress reports. A model can be complete, compiling, and fully documented and still be wrong everywhere it guessed; a capable agent can build the whole thing without pausing once, which is exactly the failure mode this workflow exists to prevent.\n\nWhen a decision goes unanswered (the user explicitly declines to decide, or nobody is there to ask), do not silently proceed as if it were settled. Take your best-supported position, label it an assumption in the field's own `#(doc)` (see `skill:malloy-document` § Mark conventions as conventions), record it under \"Open decisions\" in `modeling-notes.md`, and raise it again at Review. An unlabeled assumption is indistinguishable from a confirmed fact, and misleads everyone downstream.\n\n### Step 7 Review is a checklist, not a summary\n\nPresent these to the user, with answers:\n\n- **Which definitions did the user actually confirm?** List them; everything else is an assumption.\n- **Which thresholds and bucket boundaries did you choose?** For each: the evidence (distribution query, metadata, prior art) and the `#(doc)` hedge that marks it.\n- **Which questions were left unanswered?** Each must already carry a labeled assumption and an \"Open decisions\" entry.\n- **Does the headline metric have more than one defensible definition?** If yes, that is a blocking question: put the candidate definitions to the user with their counts side by side, not in a footnote.\n\nThe user confirming this checklist is what makes the model governed. A summary of what you built is not a checkpoint.\n\nPublishing is out of scope for open-source v1. Self-hosters move a finished model into a served package via git and the host's publish path; see `skill:malloy-publish` for the local-to-served handoff.\n\n**Two paths to a model: both produce the same fully documented result:**\n- **Schema-first:** \"Model my data\" → 8-step workflow above using the relevant skills\n- **Analysis-first:** a data question arrives before any model exists → `skill:malloy-model-as-you-go`. It answers the question with `skill:malloy-analysis`, then codifies what the answer assumed into the model, one question at a time, confirming binding decisions first. The model exists by the end; there is no separate formalize step.\n- **Open-ended exploration** with no intent to keep anything: `skill:malloy-analyze`. If it turns into something worth keeping, formalize via `skill:malloy-model` (`reference/analysis-to-model.md`).\n\n## Agent Behavior\n\n**Research before asking.** Present proposals with evidence. Never ask open-ended questions: propose with data and let the user confirm.\n\n**Use business language.** Say \"I simplified the column name\" not \"reserved word replaced.\" Don't expose Malloy internals unless the user asks.\n\n**Describe what you're doing, not which step you're on.** The user doesn't have the skill files open. Say \"I'll propose which tables to include and how they relate\" not \"Steps 3 and 4.\" Say \"Now I'll write the source files\" not \"Moving to Step 5.\" Explain the purpose of each phase in plain language before doing it.\n\n**Present choices as A/B/C.** When asking the user to choose, use lettered options with one-line descriptions. Mark your recommendation.\n\n**Complete all workflow steps.** Once modeling begins, complete through Review and propose Curate. A field without documentation is not finished. If you lose track, re-read the model and your notes. Suggest notebooks at the end.\n\n## Route by Intent\n\n| User says... | Route to |\n|-------------|----------|\n| \"Model my data\", \"create a model\" | 8-step workflow (`skill:malloy-discover`) |\n| \"Model from LookML\" | 8-step with prior art via `skill:malloy-lookml-review` |\n| \"Explore this data\", \"what's interesting?\", \"show me the top X\" | `skill:malloy-analyze` (EDA) |\n| \"Build a dashboard\", \"create views\" on existing model | `skill:malloy-analyze` (views), plus `skill:malloy-charts` or `skill:malloy-notebooks` as needed |\n| \"Build a model but not sure what metrics\" | `skill:malloy-model-as-you-go`: answer their first real question, codify what it assumed, repeat |\n\n**If the user's first message is a data question** (not \"build me a model\"), route to `skill:malloy-model-as-you-go`. It answers with `skill:malloy-analysis` and grows the model from what each answer assumed, so there is nothing to formalize afterwards.\n\n## Additional Support Skills\n\nThese supplemental skills may also be loaded as needed:\n\n- **`skill:malloy`**: Index of Malloy skills and routing guide\n- **`skill:malloy-debug`**: Fix compile errors and interpret diagnostics\n\n## Publisher MCP Tools\n\nEnsure the Publisher MCP tools are configured before modeling. No server yet? `skill:malloy-getting-started` covers setup, including the one-command scaffolder (`npm create @malloy-publisher/malloy-package@latest <name>`) and why local authoring needs `--watch-env <env>`: start the server without it and your saved edits are never read.\n\n| Tool | Purpose |\n|------|---------|\n| `get_context` | Ground yourself in a package: its sources, views, and fields |\n| `execute_query` | Run ad-hoc queries for validation |\n| `compile_model` | Compile-check a change and get diagnostics back without running a query |\n| `reload_package` | Recompile a package from disk so a saved edit becomes queryable by name |\n| `search_malloy_docs` | Search Malloy docs (call BEFORE unfamiliar patterns) |\n| `search_database_schema` | Find the tables in a database connection by plain-English description, when modelling data that is not in a package yet. Returns each table's columns and the `source:` line to start from. Names and types only: no row value is returned |\n\nNever guess field names. Ground yourself with `get_context` to see the sources and fields a package defines.\n\n### The edit-and-run loop\n\nPublisher compiles each configured package at boot and serves that cached model, so a source or view you add afterwards is not queryable by name until you reload the package. The loop is:\n\n1. **Validate** the change with `compile_model`, picking the scope that matches what you are doing:\n - Adding a new definition or query: the default (`scope: \"append\"`) compiles your text in the model's namespace. Note its diagnostic positions land in the model-plus-your-text concatenation. Append checks your text against what the model already publishes, so it refuses text that declares its own data root -- `import`, `connection.table(...)` and `connection.sql(...)` are rejected. A `source:` line handed to you by `search_database_schema` is exactly that shape, so validate it at `file` or `package` scope instead.\n - **Editing an existing definition: `scope: \"file\"`**, with the whole edited file as `source`. It compiles your text AS the file (append would collide with \"Cannot redefine\"), and diagnostics land at the true line numbers of your text.\n - Before saving a change other files import: `scope: \"package\"` with the edited file as `source` runs reload's worker compiler over every `.malloy` and `.malloynb` file against your edit, so a rename that breaks an importer surfaces now instead of at reload. Each diagnostic carries `model`, the file it points at; files hidden from discovery can appear. If `modelPath` does not exactly match an existing file, a warning says the source was treated as new.\n2. **Save** it to the package's model file.\n3. **Reload** with `reload_package`.\n4. **Run** the new view with `execute_query`.\n\nA reload that fails to compile is safe: your files are left alone and the previously compiled model keeps serving, with the compile errors returned to you. Compile first anyway for faster feedback, and a `scope: \"package\"` dry-run with no `source` uses reload's compiler and file selection (imports across files, every `.malloy` and `.malloynb` file as saved) without touching the served model. Keep the source of truth outside `publisher_data/`, which is not version-controlled and is wiped by a `--init` restart. If these tools are missing, the Publisher you are connected to predates them; fall back to validating with a throwaway `execute_query`. An older Publisher that has `compile_model` but rejects `scope` supports only the append behavior.\n\n## SQL-to-Malloy Quick Reference\n\n| SQL | Malloy |\n|-----|--------|\n| `COUNT(*)` | `count()` |\n| `COUNT(DISTINCT x)` | `count(x)` |\n| `NOW()` | `now` |\n| `CASE WHEN...END` | `pick...when...else` |\n| `col IN ('a','b')` | `col ? 'a' \\| 'b'` |\n| `COALESCE(a,b)` | `a ?? b` |\n| `CAST(x AS type)` | `x::type` |\n| `DATEDIFF(day, a, b)` | `days(a to b)` |\n| `CONCAT(a, b)` or `a \\|\\| b` | `concat(a, b)` |\n| `TIMESTAMP_DIFF(a, b, SECOND)` | `seconds(b to a)` |\n\n## Critical Rules\n\n1. **All keywords require colons**: `source:`, `dimension:`, `measure:`, `view:`\n2. **Use `is` not `as`**: `dimension: name is expression`\n3. **Arrow operator required**: `run: source -> { operations }`\n4. **Specify join type**: `join_one:`, `join_many:`, `join_cross:`\n5. **Safe division**: `revenue / nullif(count, 0)`\n6. **Group definitions under one keyword**: `measure:` then indent fields beneath\n\n## Common Anti-Patterns\n\n```\nWRONG: source flights is ... RIGHT: source: flights is ...\nWRONG: dimension: x as y RIGHT: dimension: y is x\nWRONG: count(*) RIGHT: count()\nWRONG: count(distinct x) RIGHT: count(x)\nWRONG: revenue / order_count RIGHT: revenue / nullif(order_count, 0)\nWRONG: run: src { ... } RIGHT: run: src -> { ... }\n```\n\n## Reserved Words: Scan Schema First\n\n**Malloy has many reserved words. When in doubt, backtick it.** Most likely to appear as column names:\n\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n- `string`, `boolean`, `true`, `false`: backtick any column with these exact names\n\n## Gotcha Skills: Read Before Writing Code\n\nThe following skills contain detailed WRONG/RIGHT patterns that prevent the most common Malloy errors. **Read them before writing code:**\n\n- **`skill:malloy-gotchas-modeling`**: Reserved words, NULL checks, date functions, type casts, rename pitfalls, query-based source gotchas, `conn.sql()` anti-pattern\n- **`skill:malloy-gotchas-queries`**: Chart constraints, aggregate filters, joined field aliasing, time truncation vs extraction\n- **`skill:malloy-gotchas-rendering`**: Tag syntax, scale rules, sparkline setup, big_value patterns"
|
|
325691
326446
|
},
|
|
325692
326447
|
{
|
|
325693
326448
|
name: "malloy-notebook-chat",
|
|
@@ -325721,7 +326476,7 @@ Steps to follow when the user asks a question:
|
|
|
325721
326476
|
{
|
|
325722
326477
|
name: "malloy-phrase-detection",
|
|
325723
326478
|
description: "How to phrase search_text on a get_context call so retrieval returns the fields you need instead of a truncated catalog. Covers target-type classification and decomposition patterns.",
|
|
325724
|
-
body: "# Search Target Construction for `get_context`\n\nThe `get_context` tool description defines each field and what a call returns. This skill focuses on the parts you won't get right by default: classifying concepts into target types and splitting ambiguous phrases.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n**Scope of this skill:** the patterns below build `dimension` / `measure` / `view` targets. Phrasing for `source` targets is covered at the end.\n\n**A note on matching:** `get_context` searches over the model (sources, fields, views, and their descriptions), not the distinct categorical *values* stored in the data. To find which literal values a categorical dimension holds, target the dimension, then query its distinct values with `execute_query` (see the patterns below).\n\n## Always send `search_text`\n\n**Do not enumerate.** Omitting `search_text` lists a catalog rather than searching it. Knowing the package narrows *where* to look; it does not substitute for saying *what* you need: if you know the package, that is a reason to scope, not a reason to skip `search_text`. Enumerated listings are capped per source and per entity type, and with no relevance signal the cap drops the fields your question is about while keeping join-path noise.\n\nA bare listing has two legitimate uses. The first is answering \"what data is here?\" when the user has named no subject at all. The second is reading a specific entity you already have the exact name of: scope to its source, set `entity_name`, and pass `search_text: null`, which returns that entity's docstring and Malloy code without spending a search. Every other call carries `search_text` on every target.\n\n## Authoring `search_text` for entity targets\n\nWrite `search_text` as a brief semantic **description** of what you're looking for, not an echo of the user's word. This applies even when you already know the entity name from a prior result: still describe it, don't just repeat the name. That is a rule about how to *phrase* a search, and it does not conflict with the exact-entity lookup above: if you want that one entity's code and docstring rather than a ranked set, pin it with `entity_name` and skip the search entirely. Sending its name back as `search_text` is the move this rule forbids, because it searches for a name instead of either describing a concept or asking for the entity.\n\nOne target per concept is enough: the tool handles phrasing variants internally. Don't pile up dimension targets that point at the same field. Use multiple targets only when they describe genuinely distinct concepts (see \"Non-obvious decomposition patterns\" below).\n\n## Target-type decision guide\n\n- **`dimension`**: categorical attribute to group, filter, or join on. Also used for time and numeric fields.\n - \"region\" becomes `\"the geographic region\"`\n- **`measure`**: aggregation metric (count, sum, average, rate).\n - \"total revenue\" becomes `\"the total revenue or sales amount\"`\n- **`view`**: pre-built analysis. Include one whenever the question sounds like a canned report (summary, breakdown, top-N, trend).\n - \"sales summary\" becomes `\"a summary of sales metrics\"`\n- **`source`**: data domain, for a question that names a subject area rather than fields (phrasing below).\n\n**Resolving categorical values (no value-search target in v1).** When the user names a literal value like \"premium\" or \"New York City\", target the *dimension* it lives on (`\"the subscription tier\"`, `\"the city where the subscriber lives\"`). Then confirm the exact stored string by querying that dimension's distinct values with `execute_query` before you filter on it. The data may store `\"Premium\"`, `\"PREMIUM\"`, `\"NYC\"`, or `\"New York\"`, and only the data tells you which.\n\n## Non-obvious decomposition patterns\n\nThese are the rules you won't apply correctly by default:\n\n1. **Adjective + noun, split.** \"active users\" becomes two dimension targets: one for the attribute (`\"the status of the user account\"`) and one for the noun (`\"the user or account holder\"`). Resolve the modifier (\"active\") to the exact stored value by querying the status dimension's distinct values with `execute_query`.\n2. **Ambiguous concept, cover both types.** \"rating\", \"duration\", and the like could be either a dimension or a measure: create one target of each type.\n3. **Time references are dimensions.** \"last year\" becomes a dimension target for the relevant date field (`\"the date the event occurred\"`).\n4. **Numeric ranges are dimensions.** \"aged 50\", \"revenue over $1M\" become dimension targets; the comparison is applied in the query, not matched as text.\n5. **Categorical strings that look numeric are still dimensions.** \"18-30\", \"<5 days\", \"tier 2\" are stored as literal strings on a dimension. Target that dimension, then confirm the exact string with `execute_query`.\n6. **\"Top N\" without a named measure, add a ranking measure.** \"top 6 products\" becomes a measure for the ranking concept (`\"the performance metric for a product\"`) plus a dimension for the entity. If the measure is explicit (\"top products by total sales\"), use it directly and skip the generic ranking measure.\n7. **Multiple values for one concept, one dimension target.** Several values (\"premium and basic\") still map to a single dimension target for the parent field; enumerate the exact stored values with `execute_query`.\n8. **The quantity asked for is a measure, even when nothing names it.** \"How many titles were released in 2019?\" is asking for a count, but no noun in it is the count: the visible phrases are the subject (\"titles\") and the filter (\"2019\"), and mapping only those yields a `source` and a `dimension` target with no way to answer. Always add a measure target for the quantity itself (`\"the number of titles\"`). This holds for every interrogative that IS the aggregation -- \"how many\", \"how much\", \"how often\" -- and it is the pattern most easily lost when a filter or grouping is the loud part of the sentence. Measured: an answerer got this right on \"How many titles are in the dataset?\" and dropped it on \"How many titles were released in 2019?\", where the year took the attention.\n9. **A population qualifier is a target, and so is the one the question omits.** Words like \"real\", \"actual\", \"genuine\", \"live\" or \"production\" are not filler: they name rows the model marks for exclusion. Target the flag itself (`\"the flag marking synthetic, test or monitoring traffic\"`), not just the noun they modify. Add one such target even when the question carries no qualifier at all, because a table of events, requests, sessions or logs usually holds test, internal or cancelled rows and nothing in the wording will say so. Resolve it to the model's own flag rather than inventing a filter on an id or a name; a hand-rolled exclusion and the documented one rarely select the same rows.\n\n## Worked example\n\n**User:** \"Customer churn in NYC over the last year for premium and basic subscribers\"\n\nThe targets for this question:\n\n| target_type | search_text |\n|---|---|\n| `measure` | `\"the rate at which customers leave the service\"` |\n| `dimension` | `\"the city where the subscriber lives\"` |\n| `dimension` | `\"the date the subscription was canceled\"` |\n| `dimension` | `\"the tier of the subscription\"` |\n| `view` | `\"subscriber churn or retention analysis\"` |\n\nKey moves: time (\"last year\") becomes a dimension on the cancellation date; \"NYC\" and \"premium/basic subscribers\" do not get their own value targets, they resolve to the city and tier dimensions. One `view` target is included to surface any canned churn analysis.\n\nThe response returns the source these fields live on (here `subscriptions`) with the matched fields on its card. Then run `execute_query` to read the city and tier dimensions' distinct values and confirm the exact strings to filter on (\"New York City\" vs \"NYC\", \"premium\" vs \"Premium\").\n\n## Authoring `search_text` for `source` targets\n\nAim for **3-8 words that name the entity and its business process**. Don't include filter values, time ranges, or aggregations: those belong in entity targets.\n\n| Too vague | Over-specific (entity-shaped) | Good |\n|---|---|---|\n| `\"orders\"` | `\"total order revenue by customer last year\"` | `\"customer order history and line items\"` |\n| `\"customer data\"` | `\"premium subscribers who churned in NYC\"` | `\"subscriber accounts and churn\"` |\n| `\"metrics\"` | `\"monthly revenue variance by account\"` | `\"sales pipeline and revenue forecasts\"` |\n\nHeuristics:\n\n1. **Translate, don't echo.** \"How did sales go last month?\" becomes `\"sales order revenue\"`, not `\"sales last month\"`.\n2. **Differentiate by data shape or business process, not by the user's industry, brand, or product category.** Prefer `\"order fulfillment and shipping\"` over `\"ecommerce data\"` when multiple commerce-ish packages exist. Do NOT add words like `\"eyewear\"`, `\"subscription box\"`, `\"Acme Corp\"`, or the user's specific vertical/brand. Source summaries describe data structure, not the customer's vertical, so those words add noise and can hurt matching.\n3. **Retry with alternative phrasings** if the right source isn't in the results before concluding it's missing."
|
|
326479
|
+
body: "# Search Target Construction for `get_context`\n\nThe `get_context` tool description defines each field and what a call returns. This skill focuses on the parts you won't get right by default: classifying concepts into target types and splitting ambiguous phrases.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n**Scope of this skill:** the patterns below build `dimension` / `measure` / `view` / `dimensional_value` targets. Phrasing for `source` targets is covered at the end.\n\n**A note on matching:** `get_context` searches over the model (sources, fields, views, and their descriptions), and `dimensional_value` targets search the categorical *values* stored in the data. Some servers don't index values; check the tool's description. On one that doesn't, find a literal value by targeting its dimension, then querying its distinct values with `execute_query` (see \"Where value search isn't available\" below).\n\n## Always send `search_text`\n\n**Do not enumerate.** Omitting `search_text` lists a catalog rather than searching it. Knowing the package narrows *where* to look; it does not substitute for saying *what* you need: if you know the package, that is a reason to scope, not a reason to skip `search_text`. Enumerated listings are capped per source and per entity type, and with no relevance signal the cap drops the fields your question is about while keeping join-path noise.\n\nA bare listing has two legitimate uses. The first is answering \"what data is here?\" when the user has named no subject at all. The second is reading a specific entity you already have the exact name of: scope to its source, set `entity_name`, and pass `search_text: null`, which returns that entity's docstring and Malloy code without spending a search. Every other call carries `search_text` on every target.\n\n## Authoring `search_text` for entity targets\n\nWrite `search_text` as a brief semantic **description** of what you're looking for, not an echo of the user's word. This applies even when you already know the entity name from a prior result: still describe it, don't just repeat the name. That is a rule about how to *phrase* a search, and it does not conflict with the exact-entity lookup above: if you want that one entity's code and docstring rather than a ranked set, pin it with `entity_name` and skip the search entirely. Sending its name back as `search_text` is the move this rule forbids, because it searches for a name instead of either describing a concept or asking for the entity.\n\nOne target per concept is enough: the tool handles phrasing variants internally. Don't pile up dimension targets that point at the same field. Use multiple targets only when they describe genuinely distinct concepts (see \"Non-obvious decomposition patterns\" below).\n\n## Target-type decision guide\n\n- **`dimension`**: categorical attribute to group, filter, or join on. Also used for time and numeric fields.\n - \"region\" becomes `\"the geographic region\"`\n- **`measure`**: aggregation metric (count, sum, average, rate).\n - \"total revenue\" becomes `\"the total revenue or sales amount\"`\n- **`view`**: pre-built analysis. Include one whenever the question sounds like a canned report (summary, breakdown, top-N, trend).\n - \"sales summary\" becomes `\"a summary of sales metrics\"`\n- **`dimensional_value`**: a literal value the user named, stored in some dimension. `search_text` is the value itself, the one target where echoing the user's word is right.\n - \"CyberArk\" stays `\"CyberArk\"`\n - Best practice is to scope the call to the source that holds the value. An unscoped value search covers every indexed dimension in scope, so it is slow on a large package. If you truly don't know the source, you can leave the scope off, but expect a slow call.\n- **`source`**: data domain, for a question that names a subject area rather than fields (phrasing below).\n\n**Resolving categorical values.** When the user names a literal value like \"premium\" or \"New York City\", send a `dimensional_value` target for it, scoped to its source once you know the source. Filter on the exact string it returns: the data may store `\"Premium\"`, `\"PREMIUM\"`, `\"NYC\"`, or `\"New York\"`, and only the data tells you which.\n\n**Where value search isn't available.** Some servers have no value index and return nothing for a `dimensional_value` target, and on others a particular dimension's values may not be indexed. Then target the *dimension* the value lives on (`\"the subscription tier\"`, `\"the city where the subscriber lives\"`), and confirm the exact stored string by querying that dimension's distinct values with `execute_query` before you filter on it.\n\n## Non-obvious decomposition patterns\n\nThese are the rules you won't apply correctly by default:\n\n1. **Adjective + noun, split.** \"active users\" becomes two dimension targets: one for the attribute (`\"the status of the user account\"`) and one for the noun (`\"the user or account holder\"`). Resolve the modifier (\"active\") to the exact stored value with a `dimensional_value` target, or the status dimension's distinct values where values aren't indexed.\n2. **Ambiguous concept, cover both types.** \"rating\", \"duration\", and the like could be either a dimension or a measure: create one target of each type.\n3. **Time references are dimensions.** \"last year\" becomes a dimension target for the relevant date field (`\"the date the event occurred\"`).\n4. **Numeric ranges are dimensions.** \"aged 50\", \"revenue over $1M\" become dimension targets; the comparison is applied in the query, not matched as text.\n5. **Categorical strings that look numeric are still dimensions.** \"18-30\", \"<5 days\", \"tier 2\" are stored as literal strings on a dimension. Target that dimension, then confirm the exact string with a `dimensional_value` target, or `execute_query` where values aren't indexed.\n6. **\"Top N\" without a named measure, add a ranking measure.** \"top 6 products\" becomes a measure for the ranking concept (`\"the performance metric for a product\"`) plus a dimension for the entity. If the measure is explicit (\"top products by total sales\"), use it directly and skip the generic ranking measure.\n7. **Multiple values for one concept, one dimension target.** Several values (\"premium and basic\") still map to a single dimension target for the parent field; confirm the exact stored values with `dimensional_value` targets, or `execute_query` where values aren't indexed.\n8. **The quantity asked for is a measure, even when nothing names it.** \"How many titles were released in 2019?\" is asking for a count, but no noun in it is the count: the visible phrases are the subject (\"titles\") and the filter (\"2019\"), and mapping only those yields a `source` and a `dimension` target with no way to answer. Always add a measure target for the quantity itself (`\"the number of titles\"`). This holds for every interrogative that IS the aggregation -- \"how many\", \"how much\", \"how often\" -- and it is the pattern most easily lost when a filter or grouping is the loud part of the sentence. Measured: an answerer got this right on \"How many titles are in the dataset?\" and dropped it on \"How many titles were released in 2019?\", where the year took the attention.\n9. **A population qualifier is a target, and so is the one the question omits.** Words like \"real\", \"actual\", \"genuine\", \"live\" or \"production\" are not filler: they name rows the model marks for exclusion. Target the flag itself (`\"the flag marking synthetic, test or monitoring traffic\"`), not just the noun they modify. Add one such target even when the question carries no qualifier at all, because a table of events, requests, sessions or logs usually holds test, internal or cancelled rows and nothing in the wording will say so. Resolve it to the model's own flag rather than inventing a filter on an id or a name; a hand-rolled exclusion and the documented one rarely select the same rows.\n\n## Worked example\n\n**User:** \"Customer churn in NYC over the last year for premium and basic subscribers\"\n\nThe targets for this question:\n\n| target_type | search_text |\n|---|---|\n| `measure` | `\"the rate at which customers leave the service\"` |\n| `dimension` | `\"the city where the subscriber lives\"` |\n| `dimension` | `\"the date the subscription was canceled\"` |\n| `dimension` | `\"the tier of the subscription\"` |\n| `view` | `\"subscriber churn or retention analysis\"` |\n\nKey moves: time (\"last year\") becomes a dimension on the cancellation date; \"NYC\" and \"premium/basic subscribers\" resolve to the city and tier dimensions in this first call, because the source is not known yet and an unscoped value search is slow. One `view` target is included to surface any canned churn analysis.\n\nThe response returns the source these fields live on (here `subscriptions`) with the matched fields on its card. Then send a second call scoped to `subscriptions`, with `dimensional_value` targets for `\"NYC\"`, `\"premium\"` and `\"basic\"`, to get the exact strings to filter on (\"New York City\" vs \"NYC\", \"premium\" vs \"Premium\"). Where values aren't indexed, run `execute_query` on the city and tier dimensions' distinct values instead.\n\n## Authoring `search_text` for `source` targets\n\nAim for **3-8 words that name the entity and its business process**. Don't include filter values, time ranges, or aggregations: those belong in entity targets.\n\n| Too vague | Over-specific (entity-shaped) | Good |\n|---|---|---|\n| `\"orders\"` | `\"total order revenue by customer last year\"` | `\"customer order history and line items\"` |\n| `\"customer data\"` | `\"premium subscribers who churned in NYC\"` | `\"subscriber accounts and churn\"` |\n| `\"metrics\"` | `\"monthly revenue variance by account\"` | `\"sales pipeline and revenue forecasts\"` |\n\nHeuristics:\n\n1. **Translate, don't echo.** \"How did sales go last month?\" becomes `\"sales order revenue\"`, not `\"sales last month\"`.\n2. **Differentiate by data shape or business process, not by the user's industry, brand, or product category.** Prefer `\"order fulfillment and shipping\"` over `\"ecommerce data\"` when multiple commerce-ish packages exist. Do NOT add words like `\"eyewear\"`, `\"subscription box\"`, `\"Acme Corp\"`, or the user's specific vertical/brand. Source summaries describe data structure, not the customer's vertical, so those words add noise and can hurt matching.\n3. **Retry with alternative phrasings** if the right source isn't in the results before concluding it's missing."
|
|
325725
326480
|
},
|
|
325726
326481
|
{
|
|
325727
326482
|
name: "malloy-publish",
|
|
@@ -325774,28 +326529,47 @@ If it doesn't exist, create one. Suggest a package name based on the model conte
|
|
|
325774
326529
|
|
|
325775
326530
|
### Curating discovery & the query boundary (optional)
|
|
325776
326531
|
|
|
325777
|
-
|
|
326532
|
+
A package with no \`index.malloy\` and no \`explores\` exposes **everything**: every model is listed and every source is directly queryable.
|
|
326533
|
+
|
|
326534
|
+
To curate, add an **\`index.malloy\`** at the package root. Publisher reads it as the package's published surface, so no manifest field is involved:
|
|
326535
|
+
|
|
326536
|
+
\`\`\`malloy
|
|
326537
|
+
// index.malloy
|
|
326538
|
+
import "order_analysis.malloy"
|
|
326539
|
+
import "staging.malloy"
|
|
326540
|
+
|
|
326541
|
+
export { orders, customers }
|
|
326542
|
+
\`\`\`
|
|
326543
|
+
|
|
326544
|
+
What it exports is what agents discover **and** what may be queried. Everything else still compiles, and other models can import, join and extend it, but a direct query against it is refused with a 404 - indistinguishable from a source that does not exist. Reach for this when you have raw/staging/scaffolding sources that exist to build a curated entry point and you don't want agents landing on, or querying, them directly.
|
|
326545
|
+
|
|
326546
|
+
**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
|
+
|
|
326548
|
+
**A surface can be layered.** An \`index.malloy\` may front a file that fronts another. A source re-exported through a chain of files stays queryable through the surface at any depth, because admission follows the declaration rather than the path taken to it.
|
|
325778
326549
|
|
|
325779
|
-
|
|
326550
|
+
**Curation hides a landing point, not a column.** A published source may \`join\` an unpublished one, and a query grouping by a joined field returns that field's values normally. If a column must not be readable, do not join it into something you publish; gate it with \`#(authorize)\` instead.
|
|
326551
|
+
|
|
326552
|
+
**Leaving a source out does not put it out of reach, but there is a condition.** \`export { ... }\` also decides what an *importing* file may see, which is Malloy's rule rather than Publisher's. A file that declares no \`export\` hands an importer everything it declares, so an unpublished source stays importable and joinable from the file that declares it. A file that *does* declare one hands over exactly that list: importing a file whose \`export\` omits a source and then naming it fails to compile with \`Reference to undefined object\`. Put an \`export\` on a mid-layer file only when you mean to narrow what its importers can build on, not just what Publisher lists.
|
|
326553
|
+
|
|
326554
|
+
**About \`export { … }\`:** the surface filters which *files* are listed; \`export { … }\` (a Malloy statement) filters which *sources within a file* are exposed, and the two compose. You usually don't write it in a leaf model: a file with **no** \`export\` exposes all of its own top-level sources. It must appear after the definitions it names. See [Malloy: Imports & Exports](https://docs.malloydata.dev/documentation/language/imports).
|
|
326555
|
+
|
|
326556
|
+
### The older manifest fields
|
|
326557
|
+
|
|
326558
|
+
Both still work and are not going away in this release. Both are deprecated where \`index.malloy\` replaces them, and a package using them that way gets a load-time warning naming the replacement. The two uses it cannot replace, an \`explores\` naming several files and \`queryableSources: "all"\`, stay supported without a warning. Do not add either key to a new package that does not need one of those two.
|
|
325780
326559
|
|
|
325781
326560
|
\`\`\`json
|
|
325782
326561
|
{
|
|
325783
326562
|
"name": "ecommerce",
|
|
325784
|
-
"version": "0.0.1",
|
|
325785
|
-
"description": "Orders, customers, and revenue analysis",
|
|
325786
326563
|
"explores": ["order_analysis.malloy", "customer_health.malloy"],
|
|
325787
|
-
"queryableSources": "
|
|
326564
|
+
"queryableSources": "all"
|
|
325788
326565
|
}
|
|
325789
326566
|
\`\`\`
|
|
325790
326567
|
|
|
325791
|
-
- **\`explores\`** (\`string[]\`) -
|
|
325792
|
-
- **\`queryableSources\`** (\`"declared"\` | \`"all"\`, default \`"declared"\`) - the query boundary.
|
|
325793
|
-
|
|
325794
|
-
**About \`export { … }\`:** \`explores\` filters which *files* are listed; \`export { … }\` (a Malloy statement) filters which *sources within a file* are exposed, the two compose. You usually don't write it: a file with **no** \`export\` exposes all of its own top-level sources. Add \`export { orders, customers }\` to a file to expose only those and keep imported/scaffolding helpers out of discovery (it must appear after the definitions it names). See [Malloy: Imports & Exports](https://docs.malloydata.dev/documentation/language/imports).
|
|
326568
|
+
- **\`explores\`** (\`string[]\`) - model file paths relative to the package root, naming the surface. Reach for it for the one thing \`index.malloy\` cannot express: a surface spanning **several** files. An explicit \`explores\` always wins over the convention, and a package with both an \`index.malloy\` and an \`explores\` that omits it carries a warning saying so. An entry that doesn't resolve to a real \`.malloy\` file surfaces in \`exploresWarnings\`; publishing a package that has any is rejected, so fix the path before publishing. An explicit \`"explores": []\` means "do not curate" and suppresses the convention.
|
|
326569
|
+
- **\`queryableSources\`** (\`"declared"\` | \`"all"\`, default \`"declared"\`) - the query boundary. \`"declared"\` is already the default, so the key changes nothing. **\`"all"\` is the exception, and \`index.malloy\` does not replace it:** it curates listings while leaving every source queryable by name, and a surface derived from an \`index.malloy\` always enforces the boundary. If you want listings-only curation, keep both keys.
|
|
325795
326570
|
|
|
325796
|
-
**Why curate here:** declaring \`explores\` routes agents to the well-documented curated sources instead of raw tables, and \`queryableSources: "declared"\` keeps them from reaching the hidden sources by name. The two axes compose: list a file in \`explores\` for its models to be discoverable, and \`export\` a source within that file for it to be a landing point.
|
|
325797
326571
|
|
|
325798
|
-
> **Not access control.**
|
|
326572
|
+
> **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.
|
|
325799
326573
|
|
|
325800
326574
|
The manifest also carries a \`scope\` field (\`"package"\` | \`"version"\`, default \`"package"\`) controlling whether persisted/materialized artifacts are shared across published versions or owned by a single version, and a \`materialization\` field configuring that persistence policy (a cron \`schedule\` or a \`freshness\` window). Both are unrelated to discovery curation; there is no per-source \`sharing\` or \`schedule\` field, that was retired in favor of the single package-level \`scope\` and \`materialization\`.
|
|
325801
326575
|
|
|
@@ -326798,6 +327572,74 @@ Then hand off to modeling: use your modeling workflow to turn the confirmed scop
|
|
|
326798
327572
|
## Done
|
|
326799
327573
|
|
|
326800
327574
|
Scope confirmed, in the conversation and in \`modeling-notes.md\`: tables in scope, analytical focus, model kind and what it rules out, and what's deferred. Continue with your modeling workflow.`
|
|
327575
|
+
},
|
|
327576
|
+
{
|
|
327577
|
+
name: "malloy-source-unreachable",
|
|
327578
|
+
description: `Work out why a source is missing from get_context, or why a query against it was refused with a 404 or a 403, and what to do next. Use when a source you expect is absent from discovery, when execute_query answers "No queryable source", when a query that used to work stops working, or when deciding whether a package's curation is hiding something you need.`,
|
|
327579
|
+
body: `# A source you cannot reach
|
|
327580
|
+
|
|
327581
|
+
> **Tool names** are written bare here - \`get_context\`, \`execute_query\`, \`get_status\`, \`compile_model\`. The exact prefixed name depends on the host surface; match each against the tools you actually have.
|
|
327582
|
+
|
|
327583
|
+
Most packages publish a curated surface, so a source being absent or refused is usually the package working as its author intended, not a fault. Read the refusal before working around it, because the three things that can refuse you need three different responses and they are told apart by the status code.
|
|
327584
|
+
|
|
327585
|
+
## The three answers
|
|
327586
|
+
|
|
327587
|
+
| what you got | what it means | what to do |
|
|
327588
|
+
| --- | --- | --- |
|
|
327589
|
+
| **404**, "No queryable source", "No queryable model" or "Query target is not queryable" | the source is not on the package's published surface, or does not exist. The two are deliberately indistinguishable so that a refusal cannot be used to probe for hidden names | query something on the surface, or ask the package's author to publish it |
|
|
327590
|
+
| **403**, "Access denied" | the source exists and is on the surface, but an \`#(authorize)\` gate did not admit you | supply the givens the gate reads, or accept that this caller may not read it |
|
|
327591
|
+
| **200** with zero rows | you were admitted, and an \`#(access_filter)\` narrowed the rows to none of them | this is a real answer. Report it as "no matching rows", never as an error |
|
|
327592
|
+
|
|
327593
|
+
A 403 names a source, so it tells you the source exists. A 404 tells you nothing at all. That asymmetry is deliberate: a source that is both hidden AND gated answers **404**, so you can never use a 403 to discover that a hidden name is real.
|
|
327594
|
+
|
|
327595
|
+
## Addressing the surface, which is the most common 404
|
|
327596
|
+
|
|
327597
|
+
A query names a model file. When a package curates its surface, only files on that surface are valid entry points, **even for a source that file declares itself**:
|
|
327598
|
+
|
|
327599
|
+
\`\`\`
|
|
327600
|
+
POST .../packages/sales/models/index.malloy/query {"sourceName": "orders"} -> 200
|
|
327601
|
+
POST .../packages/sales/models/orders.malloy/query {"sourceName": "orders"} -> 404
|
|
327602
|
+
\`\`\`
|
|
327603
|
+
|
|
327604
|
+
Same source, same package. \`orders.malloy\` is off the surface, so it is not an entry point.
|
|
327605
|
+
|
|
327606
|
+
**Use the \`model_path\` that \`get_context\` gave you, verbatim.** Its \`source_info.resource_id\` holds \`environment\`, \`package\`, \`model_path\` and \`source\`, and those are exactly \`execute_query\`'s \`environmentName\`, \`packageName\`, \`modelPath\` and \`sourceName\`. On a curated package the \`model_path\` is the surface file, not the file that declares the source. Substituting the declaring file because it looks more correct is how this 404 happens.
|
|
327607
|
+
|
|
327608
|
+
## When the source is not in \`get_context\` at all
|
|
327609
|
+
|
|
327610
|
+
Work down this list. The first three are far more common than the last.
|
|
327611
|
+
|
|
327612
|
+
1. **It is off the surface.** The package publishes a curated set and this source is not in it. Nothing you can do from the query side; it is a package edit.
|
|
327613
|
+
2. **The package failed to load, or is serving a stale model.** Call \`get_status\`. A package that never loaded is absent from listings entirely, which reads exactly like "does not exist". A package whose last reload failed to compile is listed and answering, from the model it compiled *before* that save, and carries \`stale: true\`. Neither is visible from a listing alone.
|
|
327614
|
+
3. **You are talking to a different server than you think.** Call \`list_packages\` and check the environment and package names are the ones you expect. A stale \`.mcp.json\` outlives the server that wrote it, and another Publisher may hold that port.
|
|
327615
|
+
4. **Your search phrasing missed it.** Retry with a bare target (no \`search_text\`) to enumerate rather than rank. If it appears there, the source exists and was a ranking miss.
|
|
327616
|
+
|
|
327617
|
+
## What curation does not do
|
|
327618
|
+
|
|
327619
|
+
- **It does not hide fields.** A published source may \`join\` an unpublished one, and a query grouping by a joined field returns that field's values normally. Hiding a source removes it as a landing point; it does not redact columns a published source pulls in.
|
|
327620
|
+
- **It does not gate \`/compile\`.** \`compile_model\` is exempt, because compile is the authoring loop. A hidden source can still be compile-checked, and that is intended. The exception is a hidden source that is also gated, which answers 404 at compile too.
|
|
327621
|
+
- **It is not access control.** Curation answers "what is queryable by name", not "who may query it". Identity is \`#(authorize)\` and \`#(access_filter)\`. A source is not protected by being hidden.
|
|
327622
|
+
|
|
327623
|
+
## Reaching an unpublished source from your own model
|
|
327624
|
+
|
|
327625
|
+
If you are writing Malloy rather than just querying, an unpublished source is still usable, with one condition that trips people.
|
|
327626
|
+
|
|
327627
|
+
\`export { ... }\` decides what an **importing file** can see, which is Malloy's rule and not Publisher's:
|
|
327628
|
+
|
|
327629
|
+
- a file that declares **no** \`export\` hands an importer everything it declares, so import the file that declares the source and join it normally;
|
|
327630
|
+
- a file that **does** declare one hands over exactly that list. Importing a file whose \`export\` omits the source and then naming it fails to compile with \`Reference to undefined object\`.
|
|
327631
|
+
|
|
327632
|
+
So an unpublished source is reachable through a file that exports it, or that exports nothing, and not through a file whose \`export\` leaves it out. Re-export chains are fine at any depth: a source re-exported through several files stays queryable through the surface, because admission follows the declaration rather than the path taken to it.
|
|
327633
|
+
|
|
327634
|
+
## Before you report a source as missing
|
|
327635
|
+
|
|
327636
|
+
Confirm all three, in this order. Each one has been mistaken for "the data is not there".
|
|
327637
|
+
|
|
327638
|
+
1. \`get_status\` shows the package loaded and not \`stale\`.
|
|
327639
|
+
2. \`list_packages\` names the environment and package you meant.
|
|
327640
|
+
3. An enumerating \`get_context\` call (a target with no \`search_text\`) does not list it.
|
|
327641
|
+
|
|
327642
|
+
Only then is it genuinely not published, and the answer to the user is that the package does not expose it, not that the data does not exist.`
|
|
326801
327643
|
}
|
|
326802
327644
|
]
|
|
326803
327645
|
};
|
|
@@ -328767,6 +329609,25 @@ function tallySources(entries, failures, carried) {
|
|
|
328767
329609
|
sourcesReused: Object.keys(carried).filter((id) => !failures[id]).length
|
|
328768
329610
|
};
|
|
328769
329611
|
}
|
|
329612
|
+
function refusalRecord(persistSource, err, tier, modelPath) {
|
|
329613
|
+
return {
|
|
329614
|
+
name: persistSource.name,
|
|
329615
|
+
sourceID: persistSource.sourceID,
|
|
329616
|
+
...modelPath ? { modelPath } : {},
|
|
329617
|
+
tier,
|
|
329618
|
+
reason: err.reason || "authorize",
|
|
329619
|
+
message: errMessage(err)
|
|
329620
|
+
};
|
|
329621
|
+
}
|
|
329622
|
+
function allSourcesRefusedError(errors2) {
|
|
329623
|
+
if (errors2.length === 1)
|
|
329624
|
+
return errors2[0];
|
|
329625
|
+
const reason = errors2.every((e) => e.reason === errors2[0].reason) ? errors2[0].reason : undefined;
|
|
329626
|
+
return new MaterializationEligibilityError({
|
|
329627
|
+
message: errors2.map((e) => e.message).join("; "),
|
|
329628
|
+
reason
|
|
329629
|
+
});
|
|
329630
|
+
}
|
|
328770
329631
|
function redactConnectionSecrets(message, ...connections) {
|
|
328771
329632
|
const secrets = new Set;
|
|
328772
329633
|
const seen = new WeakSet;
|
|
@@ -328949,6 +329810,7 @@ class MaterializationService {
|
|
|
328949
329810
|
});
|
|
328950
329811
|
let instructions;
|
|
328951
329812
|
let carried;
|
|
329813
|
+
let derivedRefused = {};
|
|
328952
329814
|
if (orchestrated) {
|
|
328953
329815
|
instructions = opts.buildInstructions;
|
|
328954
329816
|
carried = this.referenceManifestToEntries(opts.referenceManifest);
|
|
@@ -328958,9 +329820,17 @@ class MaterializationService {
|
|
|
328958
329820
|
}
|
|
328959
329821
|
} else {
|
|
328960
329822
|
const priorEntries = opts.forceRefresh ? {} : await this.getMostRecentManifestEntries(environmentId, packageName, id);
|
|
328961
|
-
({
|
|
329823
|
+
({
|
|
329824
|
+
instructions,
|
|
329825
|
+
carried,
|
|
329826
|
+
refused: derivedRefused
|
|
329827
|
+
} = this.deriveSelfInstructions(compiled, opts.sourceNames, priorEntries, incremental));
|
|
328962
329828
|
}
|
|
328963
|
-
const {
|
|
329829
|
+
const {
|
|
329830
|
+
entries,
|
|
329831
|
+
failures,
|
|
329832
|
+
sourcesRefused: instructedRefused
|
|
329833
|
+
} = await this.executeInstructedBuild(compiled, environment, instructions, carried, signal, opts.strictUpstreams ?? false, orchestrated ? { environmentId, packageName } : undefined, {
|
|
328964
329834
|
packageMaterialization: pkg.getMaterializationConfig?.() ?? null,
|
|
328965
329835
|
context: {
|
|
328966
329836
|
queryClass: "materialize",
|
|
@@ -328969,8 +329839,9 @@ class MaterializationService {
|
|
|
328969
329839
|
trigger: opts.runContext?.trigger ?? opts.trigger?.toLowerCase(),
|
|
328970
329840
|
runId: opts.runContext?.runId ?? id
|
|
328971
329841
|
}
|
|
328972
|
-
}, incremental);
|
|
329842
|
+
}, incremental, Object.values(derivedRefused));
|
|
328973
329843
|
const { sourcesBuilt, sourcesFailed, sourcesReused } = tallySources(entries, failures, carried);
|
|
329844
|
+
const sourcesRefused = Object.keys(derivedRefused).length + instructedRefused;
|
|
328974
329845
|
const durationMs = Date.now() - startedAt;
|
|
328975
329846
|
await this.commitManifest(id, entries, failures, {
|
|
328976
329847
|
forceRefresh: opts.forceRefresh,
|
|
@@ -328979,24 +329850,27 @@ class MaterializationService {
|
|
|
328979
329850
|
trigger: opts.trigger,
|
|
328980
329851
|
sourcesBuilt,
|
|
328981
329852
|
sourcesReused,
|
|
329853
|
+
...sourcesRefused > 0 ? { sourcesRefused } : {},
|
|
329854
|
+
...Object.keys(derivedRefused).length > 0 ? { refusedSources: derivedRefused } : {},
|
|
328982
329855
|
durationMs
|
|
328983
329856
|
});
|
|
328984
329857
|
if (!orchestrated) {
|
|
328985
329858
|
await this.autoLoadManifest(environment, packageName, entries);
|
|
328986
329859
|
}
|
|
328987
|
-
recordSourcesOutcome("built", sourcesBuilt);
|
|
328988
|
-
recordSourcesOutcome("reused", sourcesReused);
|
|
328989
|
-
|
|
328990
|
-
|
|
328991
|
-
|
|
328992
|
-
this.recordRun(mode,
|
|
328993
|
-
logger[
|
|
329860
|
+
recordSourcesOutcome("built", sourcesBuilt, mode);
|
|
329861
|
+
recordSourcesOutcome("reused", sourcesReused, mode);
|
|
329862
|
+
recordSourcesOutcome("failed", sourcesFailed - instructedRefused, mode);
|
|
329863
|
+
recordSourcesOutcome("refused", sourcesRefused, mode);
|
|
329864
|
+
const partial = sourcesFailed > 0;
|
|
329865
|
+
this.recordRun(mode, partial ? "partial" : "success", startedAt);
|
|
329866
|
+
logger[partial || sourcesRefused > 0 ? "warn" : "info"](sourcesFailed > 0 ? "Materialization build complete with failed sources" : sourcesRefused > 0 ? "Materialization build complete with refused sources" : "Materialization build complete", {
|
|
328994
329867
|
materializationId: id,
|
|
328995
329868
|
packageName,
|
|
328996
329869
|
mode,
|
|
328997
329870
|
sourcesBuilt,
|
|
328998
329871
|
sourcesReused,
|
|
328999
329872
|
sourcesFailed,
|
|
329873
|
+
sourcesRefused,
|
|
329000
329874
|
durationMs
|
|
329001
329875
|
});
|
|
329002
329876
|
} catch (err) {
|
|
@@ -329008,6 +329882,8 @@ class MaterializationService {
|
|
|
329008
329882
|
const include = sourceNames ? new Set(sourceNames) : null;
|
|
329009
329883
|
const instructions = [];
|
|
329010
329884
|
const carried = {};
|
|
329885
|
+
const refused = {};
|
|
329886
|
+
const refusals = [];
|
|
329011
329887
|
const seen = new Set;
|
|
329012
329888
|
for (const graph of compiled.graphs) {
|
|
329013
329889
|
for (const persistSource of iterGraphSources(graph, compiled.sources)) {
|
|
@@ -329017,24 +329893,28 @@ class MaterializationService {
|
|
|
329017
329893
|
continue;
|
|
329018
329894
|
}
|
|
329019
329895
|
const destination = resolveStorageDestination(persistSource);
|
|
329020
|
-
|
|
329021
|
-
|
|
329022
|
-
|
|
329023
|
-
assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
|
|
329024
|
-
} catch (err) {
|
|
329025
|
-
if (!(err instanceof MaterializationEligibilityError))
|
|
329026
|
-
throw err;
|
|
329027
|
-
logger.warn("Skipping a pre-aggregation rollup the storage tier refuses", {
|
|
329028
|
-
sourceName: persistSource.name,
|
|
329029
|
-
reason: errMessage(err)
|
|
329030
|
-
});
|
|
329031
|
-
continue;
|
|
329032
|
-
}
|
|
329033
|
-
} else {
|
|
329896
|
+
const isRollup = !!compiled.preaggregatePlans?.[persistSource.sourceID];
|
|
329897
|
+
try {
|
|
329898
|
+
if (destination) {
|
|
329034
329899
|
assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
|
|
329900
|
+
} else {
|
|
329901
|
+
assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, isRollup ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID], deriveAnnotationFields(persistSource));
|
|
329035
329902
|
}
|
|
329036
|
-
}
|
|
329037
|
-
|
|
329903
|
+
} catch (err) {
|
|
329904
|
+
if (!(err instanceof MaterializationEligibilityError))
|
|
329905
|
+
throw err;
|
|
329906
|
+
if (include)
|
|
329907
|
+
throw err;
|
|
329908
|
+
if (!refused[persistSource.sourceID]) {
|
|
329909
|
+
refused[persistSource.sourceID] = refusalRecord(persistSource, err, isRollup ? "preaggregate" : destination ? "storage" : "colocated", compiled.sourceModelPaths?.[persistSource.sourceID]);
|
|
329910
|
+
if (!isRollup)
|
|
329911
|
+
refusals.push(err);
|
|
329912
|
+
logger.warn("Skipping a persist source the eligibility gate refuses", {
|
|
329913
|
+
sourceName: persistSource.name,
|
|
329914
|
+
reason: errMessage(err)
|
|
329915
|
+
});
|
|
329916
|
+
}
|
|
329917
|
+
continue;
|
|
329038
329918
|
}
|
|
329039
329919
|
const sourceEntityId = computeSourceEntityId(persistSource, compiled.connectionDigests);
|
|
329040
329920
|
if (seen.has(sourceEntityId))
|
|
@@ -329064,7 +329944,10 @@ class MaterializationService {
|
|
|
329064
329944
|
});
|
|
329065
329945
|
}
|
|
329066
329946
|
}
|
|
329067
|
-
|
|
329947
|
+
if (refusals.length > 0 && instructions.length === 0 && Object.keys(carried).length === 0) {
|
|
329948
|
+
throw allSourcesRefusedError(refusals);
|
|
329949
|
+
}
|
|
329950
|
+
return { instructions, carried, refused };
|
|
329068
329951
|
}
|
|
329069
329952
|
referenceManifestToEntries(referenceManifest) {
|
|
329070
329953
|
const entries = {};
|
|
@@ -329157,8 +330040,9 @@ class MaterializationService {
|
|
|
329157
330040
|
return {};
|
|
329158
330041
|
}
|
|
329159
330042
|
async autoLoadManifest(environment, packageName, entries) {
|
|
330043
|
+
const builtEntries = Object.fromEntries(Object.entries(entries).filter(([, entry]) => !isLegacyFailedEntry(entry)));
|
|
329160
330044
|
const manifestEntries = {};
|
|
329161
|
-
for (const [sourceEntityId, entry] of Object.entries(
|
|
330045
|
+
for (const [sourceEntityId, entry] of Object.entries(builtEntries)) {
|
|
329162
330046
|
if (entry.physicalTableName && !entry.storageDestinationName) {
|
|
329163
330047
|
manifestEntries[sourceEntityId] = {
|
|
329164
330048
|
tableName: entry.physicalTableName,
|
|
@@ -329168,7 +330052,7 @@ class MaterializationService {
|
|
|
329168
330052
|
}
|
|
329169
330053
|
try {
|
|
329170
330054
|
await environment.reloadAllModelsForPackage(packageName, manifestEntries);
|
|
329171
|
-
await environment.bindPackageStorageServeBindings(packageName,
|
|
330055
|
+
await environment.bindPackageStorageServeBindings(packageName, builtEntries);
|
|
329172
330056
|
recordAutoLoadOutcome("success");
|
|
329173
330057
|
logger.info("Auto-run: loaded manifest into package models", {
|
|
329174
330058
|
packageName,
|
|
@@ -329269,7 +330153,7 @@ class MaterializationService {
|
|
|
329269
330153
|
...ledger !== undefined ? { callerLedger: indexCallerLedger(ledger) } : {}
|
|
329270
330154
|
};
|
|
329271
330155
|
}
|
|
329272
|
-
async executeInstructedBuild(compiled, environment, instructions, seedEntries, signal, strict = false, owner, buildMetadata, incremental) {
|
|
330156
|
+
async executeInstructedBuild(compiled, environment, instructions, seedEntries, signal, strict = false, owner, buildMetadata, incremental, priorRefusals = []) {
|
|
329273
330157
|
const { graphs, sources, connectionDigests, connections } = compiled;
|
|
329274
330158
|
const bySourceID = new Map;
|
|
329275
330159
|
const bySourceEntityId = new Map;
|
|
@@ -329305,6 +330189,7 @@ class MaterializationService {
|
|
|
329305
330189
|
const retainedThisRun = [];
|
|
329306
330190
|
const failures = {};
|
|
329307
330191
|
const failedReasons = [];
|
|
330192
|
+
const refusals = [];
|
|
329308
330193
|
const builtSources = [];
|
|
329309
330194
|
const writtenTargets = new Map;
|
|
329310
330195
|
const claimedBy = new Map;
|
|
@@ -329371,10 +330256,44 @@ class MaterializationService {
|
|
|
329371
330256
|
}
|
|
329372
330257
|
if (!instruction)
|
|
329373
330258
|
continue;
|
|
329374
|
-
|
|
329375
|
-
|
|
329376
|
-
|
|
329377
|
-
|
|
330259
|
+
try {
|
|
330260
|
+
if (orchestratedInstruction?.destination && getPersistStorageMode() !== "off") {
|
|
330261
|
+
assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
|
|
330262
|
+
} else {
|
|
330263
|
+
assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID], deriveAnnotationFields(persistSource));
|
|
330264
|
+
}
|
|
330265
|
+
} catch (err) {
|
|
330266
|
+
if (!(err instanceof MaterializationEligibilityError))
|
|
330267
|
+
throw err;
|
|
330268
|
+
const failedKey = sourceEntityId ?? instruction.sourceEntityId;
|
|
330269
|
+
if (!failures[failedKey]) {
|
|
330270
|
+
const reason = errMessage(err);
|
|
330271
|
+
refusals.push(err);
|
|
330272
|
+
logger.warn("Instructed source refused by the eligibility gate", {
|
|
330273
|
+
packageName: owner?.packageName,
|
|
330274
|
+
sourceName: persistSource.name,
|
|
330275
|
+
physicalTableName: instruction.physicalTableName,
|
|
330276
|
+
reason
|
|
330277
|
+
});
|
|
330278
|
+
failures[failedKey] = {
|
|
330279
|
+
sourceEntityId: failedKey,
|
|
330280
|
+
sourceName: persistSource.name,
|
|
330281
|
+
materializedTableId: instruction.materializedTableId,
|
|
330282
|
+
physicalTableName: instruction.physicalTableName,
|
|
330283
|
+
reason,
|
|
330284
|
+
refused: true,
|
|
330285
|
+
connectionName: persistSource.connectionName,
|
|
330286
|
+
...instruction.destination ? { storageDestinationName: instruction.destination } : {}
|
|
330287
|
+
};
|
|
330288
|
+
entries[failedKey] = {
|
|
330289
|
+
sourceEntityId: failedKey,
|
|
330290
|
+
sourceName: persistSource.name,
|
|
330291
|
+
physicalTableName: instruction.physicalTableName,
|
|
330292
|
+
materializedTableId: instruction.materializedTableId,
|
|
330293
|
+
error: reason
|
|
330294
|
+
};
|
|
330295
|
+
}
|
|
330296
|
+
continue;
|
|
329378
330297
|
}
|
|
329379
330298
|
sourceEntityId ??= computeSourceEntityId(persistSource, connectionDigests);
|
|
329380
330299
|
if (!orchestratedInstruction && instruction.destination && getPersistStorageMode() !== "off") {
|
|
@@ -329451,7 +330370,14 @@ class MaterializationService {
|
|
|
329451
330370
|
}
|
|
329452
330371
|
}
|
|
329453
330372
|
if (builtSources.length === 0 && failedReasons.length > 0) {
|
|
329454
|
-
throw new Error(
|
|
330373
|
+
throw new Error([
|
|
330374
|
+
...failedReasons,
|
|
330375
|
+
...refusals.map((e) => e.message),
|
|
330376
|
+
...priorRefusals.map((r) => r.message)
|
|
330377
|
+
].join("; "));
|
|
330378
|
+
}
|
|
330379
|
+
if (builtSources.length === 0 && refusals.length > 0) {
|
|
330380
|
+
throw allSourcesRefusedError(refusals);
|
|
329455
330381
|
}
|
|
329456
330382
|
} catch (err) {
|
|
329457
330383
|
if (owner) {
|
|
@@ -329460,7 +330386,7 @@ class MaterializationService {
|
|
|
329460
330386
|
}
|
|
329461
330387
|
throw err;
|
|
329462
330388
|
}
|
|
329463
|
-
return { entries, failures };
|
|
330389
|
+
return { entries, failures, sourcesRefused: refusals.length };
|
|
329464
330390
|
}
|
|
329465
330391
|
destinationForCleanup(environment, destinationName) {
|
|
329466
330392
|
try {
|
|
@@ -331190,6 +332116,22 @@ function rateLimitMiddleware(perMinute) {
|
|
|
331190
332116
|
});
|
|
331191
332117
|
}
|
|
331192
332118
|
|
|
332119
|
+
// src/frame_ancestors.ts
|
|
332120
|
+
var FRAME_ANCESTORS_ENV = "PUBLISHER_FRAME_ANCESTORS";
|
|
332121
|
+
function frameAncestorsMiddleware(raw) {
|
|
332122
|
+
const frameAncestors = parseFrameAncestors(raw);
|
|
332123
|
+
return (_req, res, next) => {
|
|
332124
|
+
res.setHeader("Content-Security-Policy", `frame-ancestors ${frameAncestors}`);
|
|
332125
|
+
res.removeHeader("X-Frame-Options");
|
|
332126
|
+
next();
|
|
332127
|
+
};
|
|
332128
|
+
}
|
|
332129
|
+
function parseFrameAncestors(raw) {
|
|
332130
|
+
if (raw === undefined || raw.trim() === "")
|
|
332131
|
+
return "'self'";
|
|
332132
|
+
return raw.trim();
|
|
332133
|
+
}
|
|
332134
|
+
|
|
331193
332135
|
// src/server.ts
|
|
331194
332136
|
assertSupportedNodeVersion();
|
|
331195
332137
|
function parseArgs() {
|
|
@@ -331281,6 +332223,7 @@ var isDevelopment = process.env["NODE_ENV"] === "development";
|
|
|
331281
332223
|
var app = import_express.default();
|
|
331282
332224
|
app.use(loggerMiddleware);
|
|
331283
332225
|
app.use(httpMetricsMiddleware);
|
|
332226
|
+
app.use(frameAncestorsMiddleware(process.env[FRAME_ANCESTORS_ENV]));
|
|
331284
332227
|
app.use(rateLimitMiddleware(parseRateLimit(process.env[RATE_LIMIT_ENV])));
|
|
331285
332228
|
checkHeapConfiguration();
|
|
331286
332229
|
var environmentStore = new EnvironmentStore(SERVER_ROOT);
|
|
@@ -331434,12 +332377,6 @@ async function serveFromPackage(req, res) {
|
|
|
331434
332377
|
res.status(403).end();
|
|
331435
332378
|
return;
|
|
331436
332379
|
}
|
|
331437
|
-
const ext = path17.extname(realFullPath).toLowerCase();
|
|
331438
|
-
if (ext === ".html" || ext === ".htm") {
|
|
331439
|
-
const frameAncestors = process.env.PUBLISHER_FRAME_ANCESTORS || "*";
|
|
331440
|
-
res.setHeader("Content-Security-Policy", `frame-ancestors ${frameAncestors}`);
|
|
331441
|
-
res.removeHeader("X-Frame-Options");
|
|
331442
|
-
}
|
|
331443
332380
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
331444
332381
|
res.sendFile(realFullPath, (err) => {
|
|
331445
332382
|
if (err) {
|