@malloy-publisher/server 0.7.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 (23) hide show
  1. package/dist/app/api-doc.yaml +82 -15
  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-BFbC4W45.js → LightMode-DyjqECoH.js} +1 -1
  5. package/dist/app/assets/{MainPage-BcCyzakx.js → MainPage-DAaQnh6y.js} +2 -2
  6. package/dist/app/assets/{ModelPage-wHHnxoih.js → ModelPage-B7yaRDmM.js} +2 -2
  7. package/dist/app/assets/PackagePage-7b0klbWy.js +1 -0
  8. package/dist/app/assets/{RenderedResult-CYyhQekT.es-DBHF8EwO.js → RenderedResult-CYyhQekT.es-DJ3BRrLl.js} +3 -3
  9. package/dist/app/assets/{RouteError-Blt6a2Za.js → RouteError-kdHl1qpH.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-Cb-2YbfG.js → ThemeEditorPage-DIt45eWv.js} +1 -1
  11. package/dist/app/assets/{ToggleButtonGroup-BuhURn8v.js → ToggleButtonGroup-Dl_oTC8r.js} +1 -1
  12. package/dist/app/assets/{index-BJyLY8XQ.js → index-Bzqs2UuN.js} +1 -1
  13. package/dist/app/assets/{index-Dz4y_2PE.js → index-C1qGlR2a.js} +105 -105
  14. package/dist/app/assets/{index-BELjGKhI.js → index-C5hv_y6G.js} +1 -1
  15. package/dist/app/assets/{index-kt4oTEGz.js → index-T1Cl-rQv.js} +1 -1
  16. package/dist/app/assets/{index-DCizS2BO.js → index-t9-rAE4l.js} +1 -1
  17. package/dist/app/assets/{index.es-u5PRRTzT.js → index.es-BlAu_Rj1.js} +4 -4
  18. package/dist/app/index.html +1 -1
  19. package/dist/server.mjs +911 -302
  20. package/package.json +1 -1
  21. package/dist/app/assets/EnvironmentPage-ufeWfqnK.js +0 -1
  22. package/dist/app/assets/HomePage-CN_FgmtD.js +0 -1
  23. package/dist/app/assets/PackagePage-DwdO-2dp.js +0 -1
package/dist/server.mjs CHANGED
@@ -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;
@@ -275083,8 +275261,8 @@ import {
275083
275261
  InMemoryURLReader as InMemoryURLReader2,
275084
275262
  isSourceDef as isSourceDef7,
275085
275263
  MalloyConfig as MalloyConfig2,
275086
- MalloyError as MalloyError3,
275087
- Annotations as Annotations5,
275264
+ MalloyError as MalloyError4,
275265
+ Annotations as Annotations6,
275088
275266
  modelDefToModelInfo as modelDefToModelInfo2,
275089
275267
  Runtime as Runtime2
275090
275268
  } from "@malloydata/malloy";
@@ -275959,7 +276137,7 @@ var init_model = __esm(() => {
275959
276137
  if (error instanceof Error && error.stack) {
275960
276138
  logger.error("Error stack", error.stack);
275961
276139
  }
275962
- if (error instanceof MalloyError3) {
276140
+ if (error instanceof MalloyError4) {
275963
276141
  const problems = error.problems;
275964
276142
  for (const problem of problems) {
275965
276143
  logger.error("Problem", problem);
@@ -276385,6 +276563,44 @@ var init_model = __esm(() => {
276385
276563
  async compileServeShape(enriched, rollupGroups = [], isRetry = false) {
276386
276564
  const tiers = buildServeShapeTiers(rollupGroups);
276387
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
+ }
276388
276604
  const lastTier = tiers.length - 1;
276389
276605
  for (let tier = 0;tier <= lastTier; tier++) {
276390
276606
  const { keep, groups } = tiers[tier];
@@ -276392,7 +276608,7 @@ var init_model = __esm(() => {
276392
276608
  ...b,
276393
276609
  refinements: b.refinements.filter((r) => keep.has(r.kind))
276394
276610
  } : b);
276395
- const materializer = this.buildServeShapeMaterializer(shaped, groups, tier === 0 ? this.liftedDerivedSources(enriched) : []);
276611
+ const materializer = this.buildServeShapeMaterializer(shaped, groups, tier === 0 ? lifts : []);
276396
276612
  if (tier === lastTier) {
276397
276613
  try {
276398
276614
  await materializer.getModel();
@@ -276519,10 +276735,17 @@ var init_model = __esm(() => {
276519
276735
  }
276520
276736
  serveBindingsWithRefinements(bindings) {
276521
276737
  const { contents, sourceNameById, liftText } = this.authorModelLift();
276522
- const materializedSourceNames = new Set(bindings.map((b) => b.sourceName));
276523
- 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) => {
276524
276748
  const fields = contents?.[b.sourceName]?.fields;
276525
- const schema = narrowSchemaToPublic(b.schema, fields);
276526
276749
  const refinements = [
276527
276750
  ...extractJoins(fields, {
276528
276751
  sourceNameById,
@@ -276533,13 +276756,13 @@ var init_model = __esm(() => {
276533
276756
  ...extractSourceFilters(contents?.[b.sourceName]?.filterList),
276534
276757
  ...extractViews(fields, liftText)
276535
276758
  ];
276536
- return { ...b, schema, refinements };
276537
- }).filter((b) => b.schema.length > 0);
276759
+ return { ...b, refinements };
276760
+ });
276538
276761
  }
276539
276762
  async getQueryResults(sourceName, queryName, query, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput, responseShape = "full", bypassAuthorize = false) {
276540
276763
  const startTime = performance.now();
276541
276764
  if (this.compilationError) {
276542
- if (this.compilationError instanceof MalloyError3 || this.compilationError instanceof ModelCompilationError) {
276765
+ if (this.compilationError instanceof MalloyError4 || this.compilationError instanceof ModelCompilationError) {
276543
276766
  throw this.compilationError;
276544
276767
  }
276545
276768
  throw new BadRequestError(`Model compilation failed: ${this.compilationError.message}`);
@@ -276664,7 +276887,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276664
276887
  if (error instanceof FilterValidationError) {
276665
276888
  throw new BadRequestError(error.message);
276666
276889
  }
276667
- if (error instanceof MalloyError3) {
276890
+ if (error instanceof MalloyError4) {
276668
276891
  throw error;
276669
276892
  }
276670
276893
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -276749,7 +276972,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276749
276972
  });
276750
276973
  throw new BadRequestError(err instanceof Error ? err.message : String(err));
276751
276974
  }
276752
- if (err instanceof MalloyError3) {
276975
+ if (err instanceof MalloyError4) {
276753
276976
  throw err;
276754
276977
  }
276755
276978
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -276885,7 +277108,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276885
277108
  if (!this.modelDef)
276886
277109
  return;
276887
277110
  try {
276888
- return new Annotations5(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
277111
+ return new Annotations6(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
276889
277112
  } catch {
276890
277113
  return;
276891
277114
  }
@@ -276900,7 +277123,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
276900
277123
  const def = entry;
276901
277124
  if (!def.annotations)
276902
277125
  return;
276903
- return new Annotations5(def.annotations).parseAsTag("@").tag;
277126
+ return new Annotations6(def.annotations).parseAsTag("@").tag;
276904
277127
  } catch {
276905
277128
  return;
276906
277129
  }
@@ -277045,7 +277268,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
277045
277268
  });
277046
277269
  throw new BadRequestError(error instanceof Error ? error.message : String(error));
277047
277270
  }
277048
- if (error instanceof MalloyError3) {
277271
+ if (error instanceof MalloyError4) {
277049
277272
  throw error;
277050
277273
  }
277051
277274
  if (error instanceof PayloadTooLargeError) {
@@ -309253,18 +309476,59 @@ class StorageManager {
309253
309476
 
309254
309477
  // src/service/environment.ts
309255
309478
  init_telemetry();
309256
- import { MalloyError as MalloyError5, Runtime as Runtime3 } from "@malloydata/malloy";
309479
+ import { MalloyError as MalloyError6, Runtime as Runtime3 } from "@malloydata/malloy";
309257
309480
  init_constants();
309258
309481
  init_errors();
309259
309482
  init_authorize();
309260
- init_authorize_metrics();
309261
- init_config();
309262
- init_logger();
309263
- init_pg_helpers();
309264
309483
  import crypto4 from "crypto";
309265
309484
  import * as fs8 from "fs";
309266
309485
  import * as path12 from "path";
309267
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();
309268
309532
  init_materialization_metrics();
309269
309533
  init_path_safety();
309270
309534
  init_utils4();
@@ -309382,7 +309646,7 @@ import {
309382
309646
  EmptyURLReader,
309383
309647
  FixedConnectionMap as FixedConnectionMap2,
309384
309648
  MalloyConfig as MalloyConfig3,
309385
- MalloyError as MalloyError4
309649
+ MalloyError as MalloyError5
309386
309650
  } from "@malloydata/malloy";
309387
309651
 
309388
309652
  // src/package_load_metrics.ts
@@ -310271,7 +310535,7 @@ function toTableNameManifest(entries) {
310271
310535
  return out;
310272
310536
  }
310273
310537
  function packageLoadFailureStatus(error) {
310274
- if (error instanceof ModelCompilationError || error instanceof MalloyError4) {
310538
+ if (error instanceof ModelCompilationError || error instanceof MalloyError5) {
310275
310539
  return "compilation_error";
310276
310540
  }
310277
310541
  if (error instanceof BadRequestError) {
@@ -310451,7 +310715,7 @@ class Package {
310451
310715
  defaultConnectionName: "duckdb"
310452
310716
  }).catch((err) => {
310453
310717
  const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
310454
- if (realError instanceof MalloyError4 || realError instanceof ModelCompilationError) {
310718
+ if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError) {
310455
310719
  throw realError;
310456
310720
  }
310457
310721
  throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`);
@@ -311124,7 +311388,7 @@ class Package {
311124
311388
  });
311125
311389
  } catch (err) {
311126
311390
  const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
311127
- if (realError instanceof MalloyError4 || realError instanceof ModelCompilationError) {
311391
+ if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError) {
311128
311392
  throw realError;
311129
311393
  }
311130
311394
  throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`);
@@ -311656,6 +311920,15 @@ function getPackageAdmissionRejectionsCounter() {
311656
311920
  });
311657
311921
  return packageAdmissionRejectionsCounter;
311658
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
+ }
311659
311932
  var COMPILE_SCOPES = ["append", "file", "package"];
311660
311933
  async function denyHiddenAsNotQueryable(convert, gate) {
311661
311934
  try {
@@ -311884,7 +312157,7 @@ ${source}` : source ?? "";
311884
312157
  }, () => hasExactGateModel ? gateModel.assertAuthorizedForRunnable(finalQuery, givens ?? {}) : gateModel.assertAuthorizedFromCompiledRunnable(finalQuery, givens ?? {}));
311885
312158
  }
311886
312159
  } catch (error) {
311887
- if (!(error instanceof MalloyError5))
312160
+ if (!(error instanceof MalloyError6))
311888
312161
  throw error;
311889
312162
  }
311890
312163
  }
@@ -311944,6 +312217,36 @@ ${source}` : source ?? "";
311944
312217
  }
311945
312218
  return { problems };
311946
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
+ }
311947
312250
  try {
311948
312251
  const modelMaterializer = runtime.loadModel(virtualUrl);
311949
312252
  const model = await modelMaterializer.getModel();
@@ -311969,14 +312272,14 @@ ${source}` : source ?? "";
311969
312272
  if (typeof givenCode === "string" && givenCode.startsWith("runtime-given-")) {
311970
312273
  throw new BadRequestError(error instanceof Error ? error.message : String(error));
311971
312274
  }
311972
- if (error instanceof MalloyError5) {
312275
+ if (error instanceof MalloyError6) {
311973
312276
  throw error;
311974
312277
  }
311975
312278
  }
311976
312279
  }
311977
312280
  return { problems: tagProblems(model.problems), sql };
311978
312281
  } catch (error) {
311979
- if (error instanceof MalloyError5) {
312282
+ if (error instanceof MalloyError6) {
311980
312283
  return { problems: tagProblems(error.problems) };
311981
312284
  }
311982
312285
  throw error;
@@ -317195,7 +317498,7 @@ var compileShape = {
317195
317498
  var COMPILE_DESCRIPTION = `Compile-check Malloy without running a query. Use this while authoring instead of a throwaway execute_query.
317196
317499
 
317197
317500
  ## Scopes (the scope parameter)
317198
- - "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.
317199
317502
  - "file": compile source AS modelPath. Use to validate an EDIT before saving; positions match the submitted file.
317200
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.
317201
317504
 
@@ -319067,7 +319370,7 @@ a permanent parallel mechanism.`
319067
319370
  {
319068
319371
  name: "eval-answer/ledger-schema",
319069
319372
  description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
319070
- 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."
319071
319374
  },
319072
319375
  {
319073
319376
  name: "eval-diagnose",
@@ -319977,7 +320280,145 @@ Available: case-format.`
319977
320280
  {
319978
320281
  name: "eval-import/case-format",
319979
320282
  description: "Reading an arriving question list. Reference detail for the eval-import skill.",
319980
- 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.`
319981
320422
  },
319982
320423
  {
319983
320424
  name: "eval-improve",
@@ -320236,7 +320677,7 @@ reply, ONLY this JSON object:
320236
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).",
320237
320678
  body: `# The judge
320238
320679
 
320239
- JUDGE_VERSION: 5
320680
+ JUDGE_VERSION: 6
320240
320681
 
320241
320682
  This skill IS the judge. One fresh judge subagent is spawned per attempt, with
320242
320683
  this skill installed in its workspace and the case materials in its prompt. It
@@ -320416,6 +320857,29 @@ Output, exactly this shape:
320416
320857
  total; the question had never asked for one. A rubric that means "campaign
320417
320858
  total only" must say so as \`REQUIRED\`, and the question should say so too.
320418
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
+
320419
320883
  ### Anchors
320420
320884
 
320421
320885
  - **match**: question "total sales by category"; golden 8 rows
@@ -320661,6 +321125,14 @@ Rules that follow from this:
320661
321125
  must say "for the campaign as a whole". If it does not, the rubric must accept
320662
321126
  a correct figure at any stated grain (judge rule 11). A rubric that quietly
320663
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.
320664
321136
  - **A right value plus a missing \`CREDITED\` disclosure is a \`match\`.** Not a
320665
321137
  near match. Do not deduct for it.
320666
321138
  - **\`DIVERGENT\` is about definitions, not arithmetic.** A clause permitting a
@@ -322012,6 +322484,10 @@ check, a judge, and a conformant \`events.jsonl\`.
322012
322484
  # if it does not reach \`ready\`: the sync is lazy, so without it the first
322013
322485
  # cases are answered LEXICALLY and the run reports that as the model's
322014
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).
322015
322491
  python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
322016
322492
  --server-root <root> --port 4811 --mcp-port 4040 --trace-retrieval \\
322017
322493
  --warm-retrieval --environment <env> --package <pkg> \\
@@ -323155,8 +323631,11 @@ Scanned at a glance is a dashboard; read top to bottom is a notebook.
323155
323631
  include wants \`"scope": "package"\`**, which recompiles every file as saved: \`file\` only checks the
323156
323632
  one you are editing, so renaming a source in \`_shared.malloy\` passes it while breaking every
323157
323633
  dashboard that imports it. A clean compile is not a working dashboard: some tag mistakes surface
323158
- at step 6, and some only when you look at the page in step 7. (The third scope, \`append\`, is the
323159
- 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.
323160
323639
  6. **SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS.** \`reload_package\`, or
323161
323640
  \`GET …/packages/<pkg>?reload=true\`. Check the status the reload returns as well as the warnings:
323162
323641
  a 424 means the package did not load and your edit is not live. **The \`warnings\` key is absent
@@ -324059,7 +324538,42 @@ view: overview is {
324059
324538
  {
324060
324539
  name: "malloy-html-data-app-embedding",
324061
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.",
324062
- 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.`
324063
324577
  },
324064
324578
  {
324065
324579
  name: "malloy-html-data-app-runtime",
@@ -324936,12 +325450,12 @@ Present coverage in this order:
324936
325450
  {
324937
325451
  name: "malloy-materialization",
324938
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.",
324939
- 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**."
324940
325454
  },
324941
325455
  {
324942
325456
  name: "malloy-materialization-tuning",
324943
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.",
324944
- 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."
324945
325459
  },
324946
325460
  {
324947
325461
  name: "malloy-model",
@@ -325928,7 +326442,7 @@ source: first_touch is conn.table('events') -> {
325928
326442
  {
325929
326443
  name: "malloy-modeling",
325930
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.",
325931
- 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.\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"
325932
326446
  },
325933
326447
  {
325934
326448
  name: "malloy-notebook-chat",
@@ -329095,6 +329609,25 @@ function tallySources(entries, failures, carried) {
329095
329609
  sourcesReused: Object.keys(carried).filter((id) => !failures[id]).length
329096
329610
  };
329097
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
+ }
329098
329631
  function redactConnectionSecrets(message, ...connections) {
329099
329632
  const secrets = new Set;
329100
329633
  const seen = new WeakSet;
@@ -329277,6 +329810,7 @@ class MaterializationService {
329277
329810
  });
329278
329811
  let instructions;
329279
329812
  let carried;
329813
+ let derivedRefused = {};
329280
329814
  if (orchestrated) {
329281
329815
  instructions = opts.buildInstructions;
329282
329816
  carried = this.referenceManifestToEntries(opts.referenceManifest);
@@ -329286,9 +329820,17 @@ class MaterializationService {
329286
329820
  }
329287
329821
  } else {
329288
329822
  const priorEntries = opts.forceRefresh ? {} : await this.getMostRecentManifestEntries(environmentId, packageName, id);
329289
- ({ 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));
329290
329828
  }
329291
- 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, {
329292
329834
  packageMaterialization: pkg.getMaterializationConfig?.() ?? null,
329293
329835
  context: {
329294
329836
  queryClass: "materialize",
@@ -329297,8 +329839,9 @@ class MaterializationService {
329297
329839
  trigger: opts.runContext?.trigger ?? opts.trigger?.toLowerCase(),
329298
329840
  runId: opts.runContext?.runId ?? id
329299
329841
  }
329300
- }, incremental);
329842
+ }, incremental, Object.values(derivedRefused));
329301
329843
  const { sourcesBuilt, sourcesFailed, sourcesReused } = tallySources(entries, failures, carried);
329844
+ const sourcesRefused = Object.keys(derivedRefused).length + instructedRefused;
329302
329845
  const durationMs = Date.now() - startedAt;
329303
329846
  await this.commitManifest(id, entries, failures, {
329304
329847
  forceRefresh: opts.forceRefresh,
@@ -329307,24 +329850,27 @@ class MaterializationService {
329307
329850
  trigger: opts.trigger,
329308
329851
  sourcesBuilt,
329309
329852
  sourcesReused,
329853
+ ...sourcesRefused > 0 ? { sourcesRefused } : {},
329854
+ ...Object.keys(derivedRefused).length > 0 ? { refusedSources: derivedRefused } : {},
329310
329855
  durationMs
329311
329856
  });
329312
329857
  if (!orchestrated) {
329313
329858
  await this.autoLoadManifest(environment, packageName, entries);
329314
329859
  }
329315
- recordSourcesOutcome("built", sourcesBuilt);
329316
- recordSourcesOutcome("reused", sourcesReused);
329317
- if (sourcesFailed > 0) {
329318
- recordSourcesOutcome("failed", sourcesFailed);
329319
- }
329320
- this.recordRun(mode, sourcesFailed > 0 ? "partial" : "success", startedAt);
329321
- 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", {
329322
329867
  materializationId: id,
329323
329868
  packageName,
329324
329869
  mode,
329325
329870
  sourcesBuilt,
329326
329871
  sourcesReused,
329327
329872
  sourcesFailed,
329873
+ sourcesRefused,
329328
329874
  durationMs
329329
329875
  });
329330
329876
  } catch (err) {
@@ -329336,6 +329882,8 @@ class MaterializationService {
329336
329882
  const include = sourceNames ? new Set(sourceNames) : null;
329337
329883
  const instructions = [];
329338
329884
  const carried = {};
329885
+ const refused = {};
329886
+ const refusals = [];
329339
329887
  const seen = new Set;
329340
329888
  for (const graph of compiled.graphs) {
329341
329889
  for (const persistSource of iterGraphSources(graph, compiled.sources)) {
@@ -329345,24 +329893,28 @@ class MaterializationService {
329345
329893
  continue;
329346
329894
  }
329347
329895
  const destination = resolveStorageDestination(persistSource);
329348
- if (destination) {
329349
- if (compiled.preaggregatePlans?.[persistSource.sourceID]) {
329350
- try {
329351
- assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
329352
- } catch (err) {
329353
- if (!(err instanceof MaterializationEligibilityError))
329354
- throw err;
329355
- logger.warn("Skipping a pre-aggregation rollup the storage tier refuses", {
329356
- sourceName: persistSource.name,
329357
- reason: errMessage(err)
329358
- });
329359
- continue;
329360
- }
329361
- } else {
329896
+ const isRollup = !!compiled.preaggregatePlans?.[persistSource.sourceID];
329897
+ try {
329898
+ if (destination) {
329362
329899
  assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
329900
+ } else {
329901
+ assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, isRollup ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID], deriveAnnotationFields(persistSource));
329363
329902
  }
329364
- } else {
329365
- 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;
329366
329918
  }
329367
329919
  const sourceEntityId = computeSourceEntityId(persistSource, compiled.connectionDigests);
329368
329920
  if (seen.has(sourceEntityId))
@@ -329392,7 +329944,10 @@ class MaterializationService {
329392
329944
  });
329393
329945
  }
329394
329946
  }
329395
- 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 };
329396
329951
  }
329397
329952
  referenceManifestToEntries(referenceManifest) {
329398
329953
  const entries = {};
@@ -329485,8 +330040,9 @@ class MaterializationService {
329485
330040
  return {};
329486
330041
  }
329487
330042
  async autoLoadManifest(environment, packageName, entries) {
330043
+ const builtEntries = Object.fromEntries(Object.entries(entries).filter(([, entry]) => !isLegacyFailedEntry(entry)));
329488
330044
  const manifestEntries = {};
329489
- for (const [sourceEntityId, entry] of Object.entries(entries)) {
330045
+ for (const [sourceEntityId, entry] of Object.entries(builtEntries)) {
329490
330046
  if (entry.physicalTableName && !entry.storageDestinationName) {
329491
330047
  manifestEntries[sourceEntityId] = {
329492
330048
  tableName: entry.physicalTableName,
@@ -329496,7 +330052,7 @@ class MaterializationService {
329496
330052
  }
329497
330053
  try {
329498
330054
  await environment.reloadAllModelsForPackage(packageName, manifestEntries);
329499
- await environment.bindPackageStorageServeBindings(packageName, entries);
330055
+ await environment.bindPackageStorageServeBindings(packageName, builtEntries);
329500
330056
  recordAutoLoadOutcome("success");
329501
330057
  logger.info("Auto-run: loaded manifest into package models", {
329502
330058
  packageName,
@@ -329597,7 +330153,7 @@ class MaterializationService {
329597
330153
  ...ledger !== undefined ? { callerLedger: indexCallerLedger(ledger) } : {}
329598
330154
  };
329599
330155
  }
329600
- 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 = []) {
329601
330157
  const { graphs, sources, connectionDigests, connections } = compiled;
329602
330158
  const bySourceID = new Map;
329603
330159
  const bySourceEntityId = new Map;
@@ -329633,6 +330189,7 @@ class MaterializationService {
329633
330189
  const retainedThisRun = [];
329634
330190
  const failures = {};
329635
330191
  const failedReasons = [];
330192
+ const refusals = [];
329636
330193
  const builtSources = [];
329637
330194
  const writtenTargets = new Map;
329638
330195
  const claimedBy = new Map;
@@ -329699,10 +330256,44 @@ class MaterializationService {
329699
330256
  }
329700
330257
  if (!instruction)
329701
330258
  continue;
329702
- if (orchestratedInstruction?.destination && getPersistStorageMode() !== "off") {
329703
- assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
329704
- } else {
329705
- 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;
329706
330297
  }
329707
330298
  sourceEntityId ??= computeSourceEntityId(persistSource, connectionDigests);
329708
330299
  if (!orchestratedInstruction && instruction.destination && getPersistStorageMode() !== "off") {
@@ -329779,7 +330370,14 @@ class MaterializationService {
329779
330370
  }
329780
330371
  }
329781
330372
  if (builtSources.length === 0 && failedReasons.length > 0) {
329782
- 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);
329783
330381
  }
329784
330382
  } catch (err) {
329785
330383
  if (owner) {
@@ -329788,7 +330386,7 @@ class MaterializationService {
329788
330386
  }
329789
330387
  throw err;
329790
330388
  }
329791
- return { entries, failures };
330389
+ return { entries, failures, sourcesRefused: refusals.length };
329792
330390
  }
329793
330391
  destinationForCleanup(environment, destinationName) {
329794
330392
  try {
@@ -331518,6 +332116,22 @@ function rateLimitMiddleware(perMinute) {
331518
332116
  });
331519
332117
  }
331520
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
+
331521
332135
  // src/server.ts
331522
332136
  assertSupportedNodeVersion();
331523
332137
  function parseArgs() {
@@ -331609,6 +332223,7 @@ var isDevelopment = process.env["NODE_ENV"] === "development";
331609
332223
  var app = import_express.default();
331610
332224
  app.use(loggerMiddleware);
331611
332225
  app.use(httpMetricsMiddleware);
332226
+ app.use(frameAncestorsMiddleware(process.env[FRAME_ANCESTORS_ENV]));
331612
332227
  app.use(rateLimitMiddleware(parseRateLimit(process.env[RATE_LIMIT_ENV])));
331613
332228
  checkHeapConfiguration();
331614
332229
  var environmentStore = new EnvironmentStore(SERVER_ROOT);
@@ -331762,12 +332377,6 @@ async function serveFromPackage(req, res) {
331762
332377
  res.status(403).end();
331763
332378
  return;
331764
332379
  }
331765
- const ext = path17.extname(realFullPath).toLowerCase();
331766
- if (ext === ".html" || ext === ".htm") {
331767
- const frameAncestors = process.env.PUBLISHER_FRAME_ANCESTORS || "*";
331768
- res.setHeader("Content-Security-Policy", `frame-ancestors ${frameAncestors}`);
331769
- res.removeHeader("X-Frame-Options");
331770
- }
331771
332380
  res.setHeader("X-Content-Type-Options", "nosniff");
331772
332381
  res.sendFile(realFullPath, (err) => {
331773
332382
  if (err) {