@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.
Files changed (25) hide show
  1. package/dist/app/api-doc.yaml +136 -37
  2. package/dist/app/assets/EnvironmentPage-Be-JAMS6.js +1 -0
  3. package/dist/app/assets/HomePage-DsdQpZuT.js +1 -0
  4. package/dist/app/assets/{LightMode-BpHRmI5M.js → LightMode-DyjqECoH.js} +1 -1
  5. package/dist/app/assets/{MainPage-BHleF-Kb.js → MainPage-DAaQnh6y.js} +2 -2
  6. package/dist/app/assets/{ModelPage-Cgw-SbZi.js → ModelPage-B7yaRDmM.js} +2 -2
  7. package/dist/app/assets/PackagePage-7b0klbWy.js +1 -0
  8. package/dist/app/assets/{RenderedResult-DSgqZUiN.es-CL0FEank.js → RenderedResult-CYyhQekT.es-DJ3BRrLl.js} +3 -3
  9. package/dist/app/assets/{RouteError-DBMc9ccU.js → RouteError-kdHl1qpH.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-ByJlsmNu.js → ThemeEditorPage-DIt45eWv.js} +1 -1
  11. package/dist/app/assets/{ToggleButtonGroup-BHEVw4Ti.js → ToggleButtonGroup-Dl_oTC8r.js} +1 -1
  12. package/dist/app/assets/{index-rWWmF7hn.js → index-Bzqs2UuN.js} +1 -1
  13. package/dist/app/assets/{index-C5BtJ1GX.js → index-C1qGlR2a.js} +105 -105
  14. package/dist/app/assets/{index-c3i6ScGn.js → index-C5hv_y6G.js} +1 -1
  15. package/dist/app/assets/{index-BdwkgpbO.js → index-T1Cl-rQv.js} +1 -1
  16. package/dist/app/assets/{index-Dyxfz1yO.js → index-t9-rAE4l.js} +1 -1
  17. package/dist/app/assets/{index.es-VI5lesX5.js → index.es-BlAu_Rj1.js} +4 -4
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +54 -5
  20. package/dist/server.mjs +1293 -356
  21. package/dist/{sshcrypto-xqan60jb.node → sshcrypto-cqb52edz.node} +0 -0
  22. package/package.json +1 -1
  23. package/dist/app/assets/EnvironmentPage-C4IrQrKG.js +0 -1
  24. package/dist/app/assets/HomePage-DDyGPzmK.js +0 -1
  25. 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-xqan60jb.node");
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. Label: outcome ('built'|'reused'|'failed').");
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 failed " + "tier index, 0=full).");
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]) => typeof def?.extends === "string" && !(ctx.boundSourceNames ?? ctx.shapeSourceNames).has(name) && def?.persistent !== true && !optsOutOfPersist(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.extends);
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 givenNames = Array.from(givenNamesById.values());
273050
+ const givenNames2 = Array.from(givenNamesById.values());
272677
273051
  const literalNames = referencedGivenNames(filterText);
272678
- const accountedFor = new Set(givenNames);
273052
+ const accountedFor = new Set(givenNames2);
272679
273053
  const unaccounted = literalNames.filter((name) => !accountedFor.has(name));
272680
- if (givenNames.length !== expansion.givenIds.size || unaccounted.length > 0) {
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 Annotations4 } from "@malloydata/malloy";
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((term) => ({
273650
- code: term.code,
273651
- givens: term.givens
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 Annotations4(def.annotations).parseAsTag("@").tag.has("persist");
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 declaresStorage = !!annotationFields.storage;
274187
+ const declaresStorage2 = !!annotationFields.storage;
274010
274188
  const rollup = options?.preaggregatePlans?.[sourceID];
274011
274189
  if (!rollup) {
274012
274190
  try {
274013
- if (declaresStorage) {
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: declaresStorage ? "storage" : "colocated",
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 (declaresStorage) {
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 declRe = /source\s*:\s*(?:`([^`]+)`|(\w+))\s+is\s+(?:`([^`]+)`|(\w+))/g;
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(query)) !== null) {
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(text) {
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 MalloyError3,
275084
- Annotations as Annotations5,
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 MalloyError3) {
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(`No queryable source "${target}".`);
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 ? this.liftedDerivedSources(enriched) : []);
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 materializedSourceNames = new Set(bindings.map((b) => b.sourceName));
276519
- return bindings.map((b) => {
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, schema, refinements };
276533
- }).filter((b) => b.schema.length > 0);
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 MalloyError3 || this.compilationError instanceof ModelCompilationError) {
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 MalloyError3) {
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 MalloyError3) {
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 Annotations5(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
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 Annotations5(def.annotations).parseAsTag("@").tag;
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 MalloyError3) {
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(`${modelPath} does not exist`);
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 MalloyError5, Runtime as Runtime3 } from "@malloydata/malloy";
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 MalloyError4
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 MalloyError4) {
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 MalloyError4 || realError instanceof ModelCompilationError) {
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 public and cannot be explores. ` + `Fix: remove it, and list a ${MODEL_FILE_SUFFIX} model file instead.`
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 listed in explores 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 remove it from explores.`
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 MalloyError4 || realError instanceof ModelCompilationError) {
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 listed in ` + `'explores' and this package sets ` + `queryableSources: "declared", so its query would be ` + `refused. It is not served. Add it to 'explores', or set ` + `queryableSources: "all" to keep the curated surface for ` + `discovery only. 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.`,
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 MalloyError5))
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 MalloyError5) {
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 MalloyError5) {
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: "<!-- 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. -->\n\n# Reading an arriving question list\n\n`reference/ledger-schema.md` in `skill:eval-answer` defines `set.json`,\n`cases.jsonl` and every golden field. This covers two things it does not: what\nan arriving item maps onto, and how the four formats break.\n\n## What an import fills in\n\nOn the case:\n\n| field | from the source | when it is missing |\n|---|---|---|\n| `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 |\n| `question` | the question text, byte for byte | it is not a case. Do not reconstruct a question from a criterion |\n| `questionSha` | never from the source | stamped at conversion by `import_cases.py --stamp` |\n| `split` | rarely present | you choose, and freeze it at import |\n| `source` | how it arrived: the filename, or the log query | say `unknown` rather than guessing a provenance |\n| `golden` | see the classification table in `SKILL.md` | absent. A case with no golden is legitimate |\n| `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 |\n\n`expectedEntities` is the one most worth leaving empty. A wrong `required` id\ncannot be delivered by any run, so it reads as a model failure on every case\nit touches: five copied ids cost a real set two days.\n\nOn `set.json`: `name`, `description`, `datasetVersion: 1`,\n`targetModelPath`, `targetPackage` (the package under test -- it arms the\nguard that refuses a \"truth\" server also serving it), and a `sourceNote` saying\nwhere the questions came from and what shape they arrived in. `truthPackage` is\nusually absent at import, and until it exists no golden can reach `verified`;\n`verify_goldens.py --promote` is what takes it there afterwards. `init_truth_package.py` in\n`skill:eval-answer` scaffolds one.\n\n## Their field names will not be your field names\n\nAn arriving JSONL is somebody else's schema. Map it explicitly rather than\ntrusting a name that looks familiar, and write down the mapping you used in\n`sourceNote`. Names seen in real sets:\n\n| theirs | usually means | trap |\n|---|---|---|\n| `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 |\n| `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 |\n| `sql`, `malloy`, `gold_query` | their derivation | run it (`SKILL.md` step 3). This is the field that upgrades a typed number into something checkable |\n| `criteria`, `rubric`, `assertions`, `must_include` | prose criteria | apply the query test in `SKILL.md` step 4 to EACH one, not to the field |\n| `id`, `case_id`, `uuid` | their id | keep it, prefixed. Two sets from one customer collide otherwise |\n| `difficulty`, `category`, `tags` | their own labels | keep as-is on the case for slicing. Never let one become `split` |\n\nA field you cannot map is a question for its author. Keep it verbatim under a\n`sourceFields` object on the case rather than discarding it or forcing it into\na field it does not mean.\n\n## The four formats\n\n**JSONL.** One object per line, and the easy one. The failures are all in the\nmapping above, plus two mechanical ones: a trailing incomplete line, and a\n`\\n` inside a question that a naive line split breaks. Parse it as JSON per\nline, and count the lines you produced against the lines they sent. Report the\ncount in the import summary: an import that silently dropped 3 of 50 is a\nmeasurement on 47 cases claiming to be one on 50.\n\n**CSV or a spreadsheet export.** Questions contain commas, quotes and newlines,\nso use a real CSV reader, never a split on `,`. Watch for a header row that is\nnot row one (an exported sheet often has a title and a blank line above it), for\nmerged cells arriving as empty strings that belong to the row above, and for a\nnumber that arrived as text with a thousands separator or a currency symbol. A\nnumber you cannot parse cleanly is a string; keep it as their text and say so,\nrather than reading `1,234` as `1.234` or as `1234` without checking.\n\n**A markdown doc or an email thread.** The questions are prose among other\nprose, so the hard part is deciding what IS a question. Rules that hold up:\na heading or a numbered list item is usually a case boundary; a sentence\nending in a question mark inside a paragraph about something else usually is\nnot. Criteria attached to a question tend to follow it as a bullet list or a\n\"should\" sentence. Quote a boundary you are unsure of and ask, rather than\nsplitting on your best guess: a question invented by a bad split is\nindistinguishable, later, from one a human asked. Nothing in this format is\nbyte-clean, which makes the verbatim copy in `as-received/` load-bearing.\n\n**A pull from production logs.** The best source, because real traffic asks\nwhat people actually ask, and the messiest. Three things to do:\nde-duplicate near-identical prompts before you import 40 of one question;\nstrip the parts that are not the question (a system preamble, a UI-injected\nscope line, a pasted table); and keep the trace or request id on the case as\n`source`, so a case can be taken back to the session it came from. Almost\nnone of these arrive with a golden, which is fine: bare questions are a\ncoverage measurement. Where the log also holds the query the agent ran, that\nis NOT a golden. It is what an unevaluated agent did, and importing it as a\nkey scores the model against its own past behavior.\n\n## A worked line\n\nTheirs:\n\n```json\n{\"case_id\": \"q-014\", \"prompt\": \"How did denim sell last quarter versus the one before?\",\n \"sql\": \"SELECT ...\", \"answer\": \"Up 12.4% to 1.83M. Should break out by channel.\",\n \"difficulty\": \"hard\"}\n```\n\nYours, after applying step 3 and step 4:\n\n```json\n{\"qid\": \"ecom-q-014\", \"question\": \"How did denim sell last quarter versus the one before?\",\n \"questionSha\": \"<stamped>\", \"split\": \"dev\", \"source\": \"questions.jsonl\",\n \"golden\": {\"status\": \"provisional\", \"kind\": \"scalar\", \"value\": 1830000,\n \"canonicalQuery\": \"SELECT ...\", \"verifiedBy\": \"authored_query\",\n \"rubric\": \"Reports the quarter-over-quarter change for denim, and breaks the figure out by channel.\"},\n \"sourceFields\": {\"difficulty\": \"hard\"}}\n```\n\nAnd one that arrived as criteria alone, with no number anywhere:\n\n```json\n{\"qid\": \"ecom-q-021\", \"question\": \"Which channels are underperforming?\",\n \"questionSha\": \"<stamped>\", \"split\": \"dev\", \"source\": \"questions.jsonl\",\n \"golden\": {\"status\": \"verified\", \"kind\": \"criteria\",\n \"verifiedBy\": \"authored_criteria\",\n \"rubric\": \"Names the channels and says what it measured underperformance against. Does not use list price.\",\n \"mustNotUse\": [\"retail_price\"]}}\n```\n\nNo query settles \"underperforming\", so there is nothing to derive and nothing\nthat can be numerically wrong: the clauses are the whole key, and this case\nscores on the first run.\n\n`criteria` and `unanswerable` are the two kinds an import may mark `verified`,\nand they arrive looking alike. A criterion reading \"PASS: a refusal that names\nthe missing data\" belongs to the second: the key is that the data is absent,\nso `kind: unanswerable`, and filing it as `criteria` beside a value would make\na correct refusal fail.\n\nWhat happened to that one `answer` string: the number became a provisional\nvalue, checkable because they sent the query that produced it; \"should break\nout by channel\" is a shape nothing can settle with a query, so it became\nrubric prose and scores on day one; and \"Up 12.4%\" is a second figure the\nrubric now has to state consistently with the value, which\n`verify_goldens.py` reads the accepting clause for."
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: 5
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 with retrieval tracing on, so a call's ranked results
320919
- can be recovered afterwards (open-source Publisher: \`PUBLISHER_MCP_TRACE=retrieval\`).
320920
- Confirm a trace lookup is available (absent means tracing is off).
320921
- Refuse to start a scored run without it: failures without traces cannot be
320922
- attributed.
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
- ## Retrieval and coverage
322768
+ ## Coverage, retrieval, accuracy, cost
322113
322769
 
322114
- Covered? -> Retrieved? -> Correct?, with the per-arm numbers under each.
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 \`check_coverage.py --set <set> --model <pkg> --out coverage.json\`, then re-run with \`--coverage\` so it charges the failures |
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 everything that stopped the run measuring the model.
322190
- Report every one that occurred, with its count and its qids, and say plainly
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
- ## Retrieval and coverage belong in every report
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
- A pass rate says an answer was wrong. It does not say WHERE, and the three
322212
- metrics that do are already in the run summary and the notebook. Report them,
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: "The workflow for answering a data question over a Malloy semantic model - a metric, breakdown, trend or chart - from get_context discovery through execute_query to verified delivery.",
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. (The third scope, \`append\`, is the
322929
- default and is what a not-yet-saved file gets.)
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 \`explores\` also loses its own findings, so a short list for a
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 if the package's \`publisher.json\` carries an
323321
- \`explores\` list: a dashboard whose file is missing from it is withheld rather than served, and the
323322
- warning says so and names the fix. The list is what matters, not the \`queryableSources\` setting, which
323323
- is \`declared\` by default; a package with no \`explores\` list withholds nothing. Where there is one, a
323324
- \`suggest\` source has to be queryable as well as resolvable, so it needs to be on the list too.
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: '# Embedding an HTML Data App\n\n> `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.\n\n## The host-page pattern\n\n```html\n<script src="https://your-publisher/sdk/publisher.js"></script>\n<div id="dashboard"></div>\n<script>\n const handle = Publisher.embed("#dashboard", {\n src: "https://your-publisher/environments/demo/packages/sales/index.html",\n });\n // handle.destroy() removes the iframe and detaches its listeners.\n</script>\n```\n\n`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).\n\n## Sizing and the resize contract\n\nOmit `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.\n\nDo 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.\n\n## Auth\n\n- Same-origin or same-tenant: pass no token. The browser\'s cookies authenticate the iframe.\n- 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.\n\n## Guardrails (v1)\n\n- The iframe is sandboxed (`allow-scripts allow-same-origin allow-forms`). Design for that: no top-level navigation, no popups.\n- 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.'
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, makes the colocated persist **refuse with a 422** - 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."
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
- By default a package exposes **everything**: every model is listed and every source is directly queryable. That's the right behavior for most packages, and it's the safe default: **omit these fields and nothing changes.** Reach for them 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.
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
- Two optional fields opt the package into curation:
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": "declared"
326564
+ "queryableSources": "all"
325788
326565
  }
325789
326566
  \`\`\`
325790
326567
 
325791
- - **\`explores\`** (\`string[]\`) - an allowlist of **model file paths** (relative to the package root, not source or view names) whose models agents should discover and land on. Declaring it is the single opt-in for all discovery curation. With \`explores\` set, listings narrow to those files, and within each file to its \`export { ... }\` closure (below), so anything a listed file doesn't export is also dropped. Other files still compile, and can still be imported or joined, but are hidden from listings. Leaving \`explores\` **absent or empty** means every model is listed, unchanged from today, so existing packages don't break when this field is added. 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.
325792
- - **\`queryableSources\`** (\`"declared"\` | \`"all"\`, default \`"declared"\`) - the query boundary. Only takes effect once \`explores\` is set. \`"declared"\` makes queryable == discoverable: only the \`explores\` files and their \`export {}\` closure are valid top-level query targets; other sources still compile, import, and join, but a direct query against one is denied. \`"all"\` curates discovery only: every compiled source stays queryable even though \`explores\` narrows what's listed.
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.** \`queryableSources\` 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 \`explores\`-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.
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
- ({ instructions, carried } = this.deriveSelfInstructions(compiled, opts.sourceNames, priorEntries, incremental));
329823
+ ({
329824
+ instructions,
329825
+ carried,
329826
+ refused: derivedRefused
329827
+ } = this.deriveSelfInstructions(compiled, opts.sourceNames, priorEntries, incremental));
328962
329828
  }
328963
- const { entries, failures } = await this.executeInstructedBuild(compiled, environment, instructions, carried, signal, opts.strictUpstreams ?? false, orchestrated ? { environmentId, packageName } : undefined, {
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
- if (sourcesFailed > 0) {
328990
- recordSourcesOutcome("failed", sourcesFailed);
328991
- }
328992
- this.recordRun(mode, sourcesFailed > 0 ? "partial" : "success", startedAt);
328993
- logger[sourcesFailed > 0 ? "warn" : "info"](sourcesFailed > 0 ? "Materialization build complete with failed sources" : "Materialization build complete", {
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
- if (destination) {
329021
- if (compiled.preaggregatePlans?.[persistSource.sourceID]) {
329022
- try {
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
- } else {
329037
- assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID], deriveAnnotationFields(persistSource));
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
- return { instructions, carried };
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(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, entries);
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
- if (orchestratedInstruction?.destination && getPersistStorageMode() !== "off") {
329375
- assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
329376
- } else {
329377
- assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID], deriveAnnotationFields(persistSource));
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(failedReasons.join("; "));
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) {