@malloy-publisher/server 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/app/api-doc.yaml +17 -0
  2. package/dist/app/assets/EnvironmentPage-J-7-dQTF.js +1 -0
  3. package/dist/app/assets/HomePage-C_iQUW74.js +1 -0
  4. package/dist/app/assets/{LightMode-emBuHCdz.js → LightMode-X55OaqWn.js} +1 -1
  5. package/dist/app/assets/{MainPage-BVqdH0TB.js → MainPage-HlxDGd7Z.js} +1 -1
  6. package/dist/app/assets/ModelPage-4DQomN_d.js +2 -0
  7. package/dist/app/assets/PackagePage-B_-oiLXm.js +1 -0
  8. package/dist/app/assets/{RenderedResult-CDA9mN_6.es-C4f_ZzvB.js → RenderedResult-DKOl0FQ8.es-BATOL6jV.js} +3 -3
  9. package/dist/app/assets/{RouteError-CtRFTa_q.js → RouteError-DWKxdq5d.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-Bnd5dgI0.js → ThemeEditorPage-Dc7994Z3.js} +1 -1
  11. package/dist/app/assets/{ToggleButtonGroup-CxU1OIGN.js → ToggleButtonGroup-0p89QLtB.js} +1 -1
  12. package/dist/app/assets/{index-aXk5ykLp.js → index-C8Lbp1ov.js} +1 -1
  13. package/dist/app/assets/{index-CNQ_NxFx.js → index-CSUU2t5Q.js} +92 -92
  14. package/dist/app/assets/{index-BWGMOIxQ.js → index-CqxaMaWg.js} +1 -1
  15. package/dist/app/assets/{index-OsUlLBi_.js → index-Csw7MKd3.js} +1 -1
  16. package/dist/app/assets/{index-xgxo45Xn.js → index-YH_yBCQI.js} +1 -1
  17. package/dist/app/assets/{index.es-BCvB2VgL.js → index.es-PC1YK9io.js} +14 -14
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +24 -1
  20. package/dist/server.mjs +1660 -711
  21. package/package.json +1 -1
  22. package/dist/app/assets/EnvironmentPage-dgZIJhMw.js +0 -1
  23. package/dist/app/assets/HomePage-D2FGM4cV.js +0 -1
  24. package/dist/app/assets/ModelPage-DAJxkTmk.js +0 -2
  25. package/dist/app/assets/PackagePage-BGGcyDeJ.js +0 -1
package/dist/server.mjs CHANGED
@@ -176433,6 +176433,15 @@ function internalErrorToHttpError(error) {
176433
176433
  return httpError(404, error.message);
176434
176434
  } else if (error instanceof NotQueryableError) {
176435
176435
  return httpError(404, error.message);
176436
+ } else if (error instanceof QueryCompileError) {
176437
+ return {
176438
+ status: 400,
176439
+ json: {
176440
+ code: 400,
176441
+ message: error.message,
176442
+ problems: error.problems.map(toQueryTextProblem)
176443
+ }
176444
+ };
176436
176445
  } else if (error instanceof MalloyError) {
176437
176446
  return httpError(400, error.message);
176438
176447
  } else if (error instanceof TableNotFoundError) {
@@ -176491,7 +176500,15 @@ function httpError(code, message, reason) {
176491
176500
  }
176492
176501
  };
176493
176502
  }
176494
- var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, PublisherConfigError, FrozenConfigError, AccessDeniedError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
176503
+ function toQueryTextProblem(problem) {
176504
+ return {
176505
+ message: problem.message,
176506
+ severity: problem.severity,
176507
+ code: problem.code,
176508
+ ...problem.at ? { at: { range: problem.at.range } } : {}
176509
+ };
176510
+ }
176511
+ var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, PublisherConfigError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
176495
176512
  var init_errors = __esm(() => {
176496
176513
  init_constants();
176497
176514
  init_logger();
@@ -176598,6 +176615,12 @@ var init_errors = __esm(() => {
176598
176615
  this.name = "AccessDeniedError";
176599
176616
  }
176600
176617
  };
176618
+ QueryCompileError = class QueryCompileError extends MalloyError {
176619
+ constructor(message, problems) {
176620
+ super(message, problems);
176621
+ this.name = "QueryCompileError";
176622
+ }
176623
+ };
176601
176624
  NotQueryableError = class NotQueryableError extends Error {
176602
176625
  constructor(message) {
176603
176626
  super(message);
@@ -265368,6 +265391,690 @@ var require_src139 = __commonJS((exports) => {
265368
265391
  } });
265369
265392
  });
265370
265393
 
265394
+ // src/service/query_text.ts
265395
+ import { ParseUtil } from "@malloydata/malloy-tag";
265396
+ function extractRunTargetSourceName(query) {
265397
+ return extractRunTargetSourceNames(query).at(-1);
265398
+ }
265399
+ function extractRunTargetSourceNames(query) {
265400
+ if (!query)
265401
+ return [];
265402
+ const tokens = tokenize(stripMalloyCommentsAndLiterals(query));
265403
+ const targets = [];
265404
+ for (let k = 0;k + 1 < tokens.count; k++) {
265405
+ if (!isWord(tokens, k, "run") || !textIs(tokens, k + 1, ":"))
265406
+ continue;
265407
+ let j = k + 2;
265408
+ while (textIs(tokens, j, "(") && tokens.kind[j] === PUNCT)
265409
+ j++;
265410
+ if (isName(tokens, j))
265411
+ targets.push(textOf(tokens, j));
265412
+ }
265413
+ if (targets.length > 0)
265414
+ return targets;
265415
+ for (let k = 0;k + 2 < tokens.count; k++) {
265416
+ if (isName(tokens, k) && textIs(tokens, k + 1, "-") && textIs(tokens, k + 2, ">") && tokens.start[k + 2] === tokens.start[k + 1] + 1 && opensLine(tokens, k)) {
265417
+ return [textOf(tokens, k)];
265418
+ }
265419
+ }
265420
+ return [];
265421
+ }
265422
+ function opensLine(tokens, k) {
265423
+ if (k === 0)
265424
+ return true;
265425
+ const from = tokens.end[k - 1];
265426
+ const to = tokens.start[k];
265427
+ for (let at = from;at < to; at++) {
265428
+ const code = tokens.source.charCodeAt(at);
265429
+ if (code === 10 || code === 13)
265430
+ return true;
265431
+ }
265432
+ return false;
265433
+ }
265434
+ function malloyIdentifiers(text) {
265435
+ return [...scanIdentifiers(stripMalloyCommentsAndLiterals(text))];
265436
+ }
265437
+ function buildSourceAliasMap(query) {
265438
+ const aliasOf = new Map;
265439
+ readDefinitions(query, (keyword, name, base) => {
265440
+ if (keyword === "source" && base !== UNREADABLE_BASE) {
265441
+ aliasOf.set(name, base);
265442
+ }
265443
+ });
265444
+ return aliasOf;
265445
+ }
265446
+ function stripMalloyCommentsAndLiterals(text) {
265447
+ if (typeof text !== "string")
265448
+ throw new TypeError("query text must be a string");
265449
+ const parts = [];
265450
+ let copied = 0;
265451
+ const blank = (from, to) => {
265452
+ const end = Math.min(to, text.length);
265453
+ if (from >= end)
265454
+ return;
265455
+ parts.push(text.slice(copied, from));
265456
+ let run = from;
265457
+ for (let k = from;k < end; k++) {
265458
+ if (text.charCodeAt(k) < 32) {
265459
+ let slashes = k;
265460
+ while (slashes > run && text.charCodeAt(slashes - 1) === 92)
265461
+ slashes--;
265462
+ parts.push(" ".repeat(slashes - run), text.slice(slashes, k + 1));
265463
+ run = k + 1;
265464
+ }
265465
+ }
265466
+ let tail = end;
265467
+ if (end < text.length && text.charCodeAt(end) < 32) {
265468
+ while (tail > run && text.charCodeAt(tail - 1) === 92)
265469
+ tail--;
265470
+ }
265471
+ parts.push(" ".repeat(tail - run), text.slice(tail, end));
265472
+ copied = end;
265473
+ };
265474
+ const inert = (from, to) => {
265475
+ parts.push(text.slice(copied, from), "~".repeat(to - from));
265476
+ copied = to;
265477
+ };
265478
+ const scan = new StringScan(text);
265479
+ const sqlResume = [];
265480
+ let depth = 0;
265481
+ const sql = (from) => {
265482
+ const stop = sqlStop(text, from);
265483
+ blank(from, stop.bodyEnd);
265484
+ if (stop.opensCode) {
265485
+ sqlResume.push(depth);
265486
+ depth++;
265487
+ }
265488
+ return stop.end;
265489
+ };
265490
+ let prefixAt = -1;
265491
+ let blankedSince = -1;
265492
+ const marks = [];
265493
+ const trivia = (from, to) => {
265494
+ blank(from, to);
265495
+ if (prefixAt < 0)
265496
+ return;
265497
+ for (let k = Math.min(to, text.length) - 1;k >= from; k--) {
265498
+ if (text.charCodeAt(k) >= 32) {
265499
+ blankedSince = k;
265500
+ break;
265501
+ }
265502
+ }
265503
+ };
265504
+ let i = 0;
265505
+ while (i < text.length) {
265506
+ const code = text.charCodeAt(i);
265507
+ if (text.startsWith("--", i) || text.startsWith("//", i) || code === 35) {
265508
+ const end = code === 35 ? skipAnnotation(text, i, trivia) : lineTrivia(text, i, trivia);
265509
+ if (end >= 0) {
265510
+ i = end;
265511
+ continue;
265512
+ }
265513
+ } else if (text.startsWith("/*", i)) {
265514
+ const close = text.indexOf("*/", i + 2);
265515
+ const end = close === -1 ? text.length : close + 2;
265516
+ trivia(i, end);
265517
+ i = end;
265518
+ continue;
265519
+ }
265520
+ if (prefixAt >= 0 && !isLexerSpace(code)) {
265521
+ if ((code === 39 || code === 34 || code === 96) && blankedSince >= 0) {
265522
+ marks.push(blankedSince);
265523
+ }
265524
+ prefixAt = -1;
265525
+ blankedSince = -1;
265526
+ }
265527
+ if (code === 123) {
265528
+ depth++;
265529
+ i++;
265530
+ continue;
265531
+ }
265532
+ if (code === 125) {
265533
+ depth--;
265534
+ i++;
265535
+ if (sqlResume.length > 0 && sqlResume[sqlResume.length - 1] === depth) {
265536
+ sqlResume.pop();
265537
+ i = sql(i);
265538
+ }
265539
+ continue;
265540
+ }
265541
+ if (text.startsWith('"""', i)) {
265542
+ i = sql(i + 3);
265543
+ continue;
265544
+ }
265545
+ if (code === 39 || code === 34 || code === 96) {
265546
+ const close = scan.stringClose(i);
265547
+ if (close < 0) {
265548
+ i++;
265549
+ continue;
265550
+ }
265551
+ if (code !== 96)
265552
+ blank(i + 1, close);
265553
+ i = close + 1;
265554
+ continue;
265555
+ }
265556
+ const prefixed = scan.prefixedLiteral(i);
265557
+ if (prefixed) {
265558
+ const { open, quote, bodyEnd, end } = prefixed;
265559
+ const tick = text.charCodeAt(open) === 96;
265560
+ if (tick)
265561
+ inert(open, open + quote);
265562
+ blank(open + quote, bodyEnd);
265563
+ if (tick && text.charCodeAt(bodyEnd) === 96)
265564
+ inert(bodyEnd, end);
265565
+ i = end;
265566
+ continue;
265567
+ }
265568
+ const letter = code | 32;
265569
+ if ((letter === 102 || letter === 115) && !continuesToken(text, i)) {
265570
+ prefixAt = i;
265571
+ }
265572
+ i++;
265573
+ }
265574
+ parts.push(text.slice(copied));
265575
+ let out = parts.join("");
265576
+ for (const at of marks)
265577
+ out = `${out.slice(0, at)}~${out.slice(at + 1)}`;
265578
+ return out;
265579
+ }
265580
+ function lineTrivia(text, i, blank) {
265581
+ const end = toEndOfLine(text, i);
265582
+ if (end >= 0)
265583
+ blank(i, end);
265584
+ return end;
265585
+ }
265586
+ function lineEnd(text, i) {
265587
+ let at = i;
265588
+ while (at < text.length) {
265589
+ const code = text.charCodeAt(at);
265590
+ if (code === 10 || code === 13)
265591
+ return at;
265592
+ at++;
265593
+ }
265594
+ return at;
265595
+ }
265596
+ function toEndOfLine(text, i) {
265597
+ const end = lineEnd(text, i);
265598
+ if (text.charCodeAt(end) === 13 && text.charCodeAt(end + 1) !== 10) {
265599
+ return -1;
265600
+ }
265601
+ return end;
265602
+ }
265603
+ function nextLine(text, i) {
265604
+ const end = lineEnd(text, i);
265605
+ if (text.charCodeAt(end) === 13 && text.charCodeAt(end + 1) === 10) {
265606
+ return end + 2;
265607
+ }
265608
+ return Math.min(end + 1, text.length);
265609
+ }
265610
+ function skipAnnotation(text, i, blank) {
265611
+ const end = toEndOfLine(text, i);
265612
+ if (end < 0)
265613
+ return -1;
265614
+ const closer = text.startsWith("##|", i) ? "|##" : text.startsWith("#|", i) ? "|#" : undefined;
265615
+ if (!closer) {
265616
+ blank(i, end);
265617
+ return end;
265618
+ }
265619
+ let column = 0;
265620
+ for (let at2 = text.lastIndexOf(`
265621
+ `, i - 1) + 1;at2 < i; at2++) {
265622
+ if ((text.charCodeAt(at2) & 64512) !== 56320)
265623
+ column++;
265624
+ }
265625
+ let at = nextLine(text, i);
265626
+ blank(i, at);
265627
+ while (at < text.length) {
265628
+ const closes = closesBlock(text, at, column, closer);
265629
+ const after = nextLine(text, at);
265630
+ blank(at, after);
265631
+ at = after;
265632
+ if (closes)
265633
+ break;
265634
+ }
265635
+ return at;
265636
+ }
265637
+ function closesBlock(text, at, column, closer) {
265638
+ for (let c = 0;c < column; c++) {
265639
+ const code = text.charCodeAt(at + c);
265640
+ if (code !== 32 && code !== 9)
265641
+ return false;
265642
+ }
265643
+ const from = at + column;
265644
+ if (!text.startsWith(closer, from))
265645
+ return false;
265646
+ return closer !== "|#" || text.charCodeAt(from + 2) !== 35;
265647
+ }
265648
+ function sqlStop(text, from) {
265649
+ const reach = [false, false, false, false];
265650
+ let furthest = from;
265651
+ const mark = (at) => {
265652
+ reach[at & 3] = true;
265653
+ furthest = Math.max(furthest, at);
265654
+ };
265655
+ mark(from);
265656
+ let stop = {
265657
+ bodyEnd: text.length,
265658
+ end: text.length,
265659
+ opensCode: false
265660
+ };
265661
+ let found = false;
265662
+ for (let j = from;j < text.length && j <= furthest; j++) {
265663
+ if (!reach[j & 3])
265664
+ continue;
265665
+ reach[j & 3] = false;
265666
+ const code = text.charCodeAt(j);
265667
+ const next = text.charCodeAt(j + 1);
265668
+ if (code === 34 && next === 34 && text.charCodeAt(j + 2) === 34) {
265669
+ if (!found || j + 3 > stop.end) {
265670
+ stop = { bodyEnd: j, end: j + 3, opensCode: false };
265671
+ }
265672
+ found = true;
265673
+ continue;
265674
+ }
265675
+ if (code === 37 && next === 123) {
265676
+ if (!found || j + 2 > stop.end) {
265677
+ stop = { bodyEnd: j, end: j + 2, opensCode: true };
265678
+ }
265679
+ found = true;
265680
+ continue;
265681
+ }
265682
+ if (code === 92) {
265683
+ mark(j + 1);
265684
+ mark(j + 2);
265685
+ } else if (code === 34) {
265686
+ mark(next === 34 ? j + 3 : j + 2);
265687
+ } else if (code === 37) {
265688
+ mark(j + 2);
265689
+ } else {
265690
+ mark(j + 1);
265691
+ }
265692
+ }
265693
+ return stop;
265694
+ }
265695
+ function isLexerSpace(code) {
265696
+ return code === 32 || code === 9 || code === 10 || code === 11 || code === 13 || code === 160;
265697
+ }
265698
+
265699
+ class StringScan {
265700
+ text;
265701
+ stringFailedAt = [-1, -1, -1];
265702
+ tripleUnclosedFrom = [Infinity, Infinity, Infinity];
265703
+ constructor(text) {
265704
+ this.text = text;
265705
+ }
265706
+ stringClose(i) {
265707
+ const text = this.text;
265708
+ const quote = text.charCodeAt(i);
265709
+ const slot = quoteSlot(quote);
265710
+ if (i < this.stringFailedAt[slot])
265711
+ return -1;
265712
+ let j = i + 1;
265713
+ while (j < text.length) {
265714
+ const code = text.charCodeAt(j);
265715
+ if (code === quote)
265716
+ return j;
265717
+ if (code === 92) {
265718
+ if (j + 1 >= text.length || text.charCodeAt(j + 1) === 10)
265719
+ break;
265720
+ j += 2;
265721
+ continue;
265722
+ }
265723
+ if (code < 32 && code !== 9)
265724
+ break;
265725
+ j++;
265726
+ }
265727
+ this.stringFailedAt[slot] = j;
265728
+ return -1;
265729
+ }
265730
+ prefixedLiteral(i) {
265731
+ const text = this.text;
265732
+ const code = text.charCodeAt(i);
265733
+ if (code === 47) {
265734
+ if (text.charCodeAt(i + 1) !== 39)
265735
+ return;
265736
+ return this.raw(i + 1, 39, false);
265737
+ }
265738
+ const letter = code | 32;
265739
+ if (letter !== 102 && letter !== 114 && letter !== 115)
265740
+ return;
265741
+ if (continuesToken(text, i))
265742
+ return;
265743
+ if (letter === 114) {
265744
+ return text.charCodeAt(i + 1) === 39 ? this.raw(i + 1, 39, false) : undefined;
265745
+ }
265746
+ let open = i + 1;
265747
+ while (open < text.length && isLexerSpace(text.charCodeAt(open)))
265748
+ open++;
265749
+ const quote = text.charCodeAt(open);
265750
+ if (letter === 115) {
265751
+ return quote === 39 || quote === 34 ? this.raw(open, quote, true) : undefined;
265752
+ }
265753
+ if (quote !== 39 && quote !== 34 && quote !== 96)
265754
+ return;
265755
+ if (text.charCodeAt(open + 1) === quote && text.charCodeAt(open + 2) === quote) {
265756
+ const close = this.tripleClose(open + 3, quote);
265757
+ if (close >= 0) {
265758
+ return { open, quote: 3, bodyEnd: close, end: close + 3 };
265759
+ }
265760
+ }
265761
+ return this.raw(open, quote, true);
265762
+ }
265763
+ raw(open, quote, endsAtNewline) {
265764
+ const text = this.text;
265765
+ let j = open + 1;
265766
+ while (j < text.length) {
265767
+ const code = text.charCodeAt(j);
265768
+ if (code === 10) {
265769
+ return endsAtNewline ? { open, quote: 1, bodyEnd: j, end: j + 1 } : undefined;
265770
+ }
265771
+ if (code === 92) {
265772
+ if (j + 1 >= text.length || text.charCodeAt(j + 1) === 10) {
265773
+ return;
265774
+ }
265775
+ j += 2;
265776
+ continue;
265777
+ }
265778
+ if (code === quote)
265779
+ return { open, quote: 1, bodyEnd: j, end: j + 1 };
265780
+ j++;
265781
+ }
265782
+ return;
265783
+ }
265784
+ tripleClose(body, quote) {
265785
+ const text = this.text;
265786
+ const slot = quoteSlot(quote);
265787
+ if (body >= this.tripleUnclosedFrom[slot])
265788
+ return -1;
265789
+ let j = body;
265790
+ while (j < text.length) {
265791
+ const code = text.charCodeAt(j);
265792
+ if (code === 92) {
265793
+ j += 2;
265794
+ continue;
265795
+ }
265796
+ if (code === quote && text.charCodeAt(j + 1) === quote && text.charCodeAt(j + 2) === quote) {
265797
+ return j;
265798
+ }
265799
+ j++;
265800
+ }
265801
+ this.tripleUnclosedFrom[slot] = body;
265802
+ return -1;
265803
+ }
265804
+ }
265805
+ function quoteSlot(quote) {
265806
+ return quote === 39 ? 0 : quote === 34 ? 1 : 2;
265807
+ }
265808
+ function continuesToken(text, i) {
265809
+ if (i === 0)
265810
+ return false;
265811
+ let at = i - 1;
265812
+ if ((text.charCodeAt(at) & 64512) === 56320 && at > 0)
265813
+ at--;
265814
+ const code = text.charCodeAt(at);
265815
+ return code === 36 || isNameCode(code, text, at);
265816
+ }
265817
+ function buildDerivationBaseMap(query) {
265818
+ const out = new Map;
265819
+ readDefinitions(query, (_keyword, name, base) => addEdge(out, name, base));
265820
+ return out;
265821
+ }
265822
+ function readDefinitions(query, onItem) {
265823
+ const tokens = tokenize(stripMalloyCommentsAndLiterals(query));
265824
+ let depth = 0;
265825
+ for (let i = 0;i < tokens.count; i++) {
265826
+ if (isPunct(tokens, i, "{"))
265827
+ depth++;
265828
+ else if (isPunct(tokens, i, "}"))
265829
+ depth--;
265830
+ if (depth !== 0 || !isStatementKeyword(tokens, i))
265831
+ continue;
265832
+ const keyword = isWord(tokens, i, "source") ? "source" : isWord(tokens, i, "query") ? "query" : undefined;
265833
+ if (!keyword)
265834
+ continue;
265835
+ readStatementItems(tokens, i + 2, false, (name, base) => onItem(keyword, name, base));
265836
+ }
265837
+ }
265838
+ function isNameCode(code, text, at) {
265839
+ if (code < 128) {
265840
+ return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 95;
265841
+ }
265842
+ return NAME_CHAR.test(String.fromCodePoint(text.codePointAt(at)));
265843
+ }
265844
+ function isSpaceCode(code, text, at) {
265845
+ if (code < 128)
265846
+ return code === 32 || code >= 9 && code <= 13;
265847
+ return SPACE_CHAR.test(String.fromCodePoint(text.codePointAt(at)));
265848
+ }
265849
+ function unitsAt(text, at) {
265850
+ return text.codePointAt(at) > 65535 ? 2 : 1;
265851
+ }
265852
+ function tokenize(text) {
265853
+ const size = text.length + 1;
265854
+ const tokens = {
265855
+ source: text,
265856
+ count: 0,
265857
+ kind: new Uint8Array(size),
265858
+ start: new Int32Array(size),
265859
+ end: new Int32Array(size),
265860
+ partner: new Int32Array(size).fill(-1),
265861
+ decoded: new Map
265862
+ };
265863
+ const push = (kind, start, end) => {
265864
+ const index = tokens.count++;
265865
+ tokens.kind[index] = kind;
265866
+ tokens.start[index] = start;
265867
+ tokens.end[index] = end;
265868
+ return index;
265869
+ };
265870
+ const open = [];
265871
+ const scan = new StringScan(text);
265872
+ let i = 0;
265873
+ while (i < text.length) {
265874
+ const code = text.charCodeAt(i);
265875
+ if (isSpaceCode(code, text, i)) {
265876
+ i += unitsAt(text, i);
265877
+ continue;
265878
+ }
265879
+ const close = code === 96 ? scan.stringClose(i) : -1;
265880
+ if (close >= 0) {
265881
+ const index2 = push(QUOTED, i + 1, close);
265882
+ const inner = text.slice(i + 1, close);
265883
+ if (inner.includes("\\")) {
265884
+ tokens.decoded.set(index2, ParseUtil.parseString(inner));
265885
+ }
265886
+ i = close + 1;
265887
+ continue;
265888
+ }
265889
+ if (isNameCode(code, text, i)) {
265890
+ let j = i + unitsAt(text, i);
265891
+ while (j < text.length && isNameCode(text.charCodeAt(j), text, j)) {
265892
+ j += unitsAt(text, j);
265893
+ }
265894
+ push(NAME, i, j);
265895
+ i = j;
265896
+ continue;
265897
+ }
265898
+ const units = unitsAt(text, i);
265899
+ const index = push(PUNCT, i, i + units);
265900
+ if (code === 40 || code === 91 || code === 123) {
265901
+ open.push(index);
265902
+ } else if (code === 41 || code === 93 || code === 125) {
265903
+ const at = open.pop();
265904
+ if (at !== undefined) {
265905
+ tokens.partner[at] = index;
265906
+ tokens.partner[index] = at;
265907
+ }
265908
+ }
265909
+ i += units;
265910
+ }
265911
+ return tokens;
265912
+ }
265913
+ function textOf(tokens, i) {
265914
+ return tokens.decoded.get(i) ?? tokens.source.slice(tokens.start[i], tokens.end[i]);
265915
+ }
265916
+ function textIs(tokens, i, char) {
265917
+ return i >= 0 && i < tokens.count && tokens.end[i] - tokens.start[i] === 1 && tokens.source.charCodeAt(tokens.start[i]) === char.charCodeAt(0);
265918
+ }
265919
+ function isPunct(tokens, i, char) {
265920
+ return textIs(tokens, i, char) && tokens.kind[i] === PUNCT;
265921
+ }
265922
+ function isName(tokens, i) {
265923
+ return i >= 0 && i < tokens.count && tokens.kind[i] !== PUNCT;
265924
+ }
265925
+ function isWord(tokens, i, word) {
265926
+ if (i < 0 || i >= tokens.count || tokens.kind[i] !== NAME || tokens.end[i] - tokens.start[i] !== word.length) {
265927
+ return false;
265928
+ }
265929
+ const from = tokens.start[i];
265930
+ for (let k = 0;k < word.length; k++) {
265931
+ const code = tokens.source.charCodeAt(from + k);
265932
+ const lower = code >= 65 && code <= 90 ? code + 32 : code;
265933
+ if (lower !== word.charCodeAt(k))
265934
+ return false;
265935
+ }
265936
+ return true;
265937
+ }
265938
+ function isStatementKeyword(tokens, i) {
265939
+ return i < tokens.count && tokens.kind[i] === NAME && textIs(tokens, i + 1, ":") && !textIs(tokens, i + 2, ":") && !textIs(tokens, i - 1, ":") && !textIs(tokens, i - 1, ".");
265940
+ }
265941
+ function readBase(tokens, i) {
265942
+ let j = i;
265943
+ while (textIs(tokens, j, "(") && tokens.kind[j] === PUNCT)
265944
+ j++;
265945
+ if (!isName(tokens, j) || isLiteralPrefix(tokens, j))
265946
+ return UNREADABLE_BASE;
265947
+ return textOf(tokens, j);
265948
+ }
265949
+ function isLiteralPrefix(tokens, i) {
265950
+ if (!(isWord(tokens, i, "f") || isWord(tokens, i, "s") || isWord(tokens, i, "r"))) {
265951
+ return false;
265952
+ }
265953
+ return ["'", '"', "`", "~"].some((quote) => isPunct(tokens, i + 1, quote));
265954
+ }
265955
+ function readStatementItems(tokens, start, shorthand, onItem) {
265956
+ let atItemStart = true;
265957
+ let i = start;
265958
+ while (i < tokens.count) {
265959
+ if (tokens.kind[i] === PUNCT) {
265960
+ if (textIs(tokens, i, ";"))
265961
+ return;
265962
+ if (tokens.partner[i] > i) {
265963
+ i = tokens.partner[i] + 1;
265964
+ atItemStart = false;
265965
+ continue;
265966
+ }
265967
+ if (textIs(tokens, i, ")") || textIs(tokens, i, "]") || textIs(tokens, i, "}")) {
265968
+ return;
265969
+ }
265970
+ atItemStart = textIs(tokens, i, ",");
265971
+ i++;
265972
+ continue;
265973
+ }
265974
+ if (isStatementKeyword(tokens, i))
265975
+ return;
265976
+ let k = i + 1;
265977
+ if (textIs(tokens, k, "(") && tokens.partner[k] > k) {
265978
+ k = tokens.partner[k] + 1;
265979
+ }
265980
+ if (isWord(tokens, k, "is")) {
265981
+ onItem(textOf(tokens, i), readBase(tokens, k + 1));
265982
+ i = k + 1;
265983
+ } else {
265984
+ if (shorthand && atItemStart) {
265985
+ const name = textOf(tokens, i);
265986
+ onItem(name, name);
265987
+ }
265988
+ i++;
265989
+ }
265990
+ atItemStart = false;
265991
+ }
265992
+ }
265993
+ function addEdge(map, name, base) {
265994
+ const bases = map.get(name) ?? new Set;
265995
+ bases.add(base);
265996
+ map.set(name, bases);
265997
+ }
265998
+ function buildJoinBaseMap(query) {
265999
+ const out = new Map;
266000
+ const tokens = tokenize(stripMalloyCommentsAndLiterals(query));
266001
+ for (let i = 0;i < tokens.count; i++) {
266002
+ if (!isStatementKeyword(tokens, i))
266003
+ continue;
266004
+ if (!/^join_(?:one|many|cross)$/i.test(textOf(tokens, i)))
266005
+ continue;
266006
+ readStatementItems(tokens, i + 2, true, (alias, base) => addEdge(out, alias, base));
266007
+ }
266008
+ return out;
266009
+ }
266010
+ function buildIsEdgeMap(query) {
266011
+ const out = new Map;
266012
+ const tokens = tokenize(stripMalloyCommentsAndLiterals(query));
266013
+ for (let k = 1;k < tokens.count; k++) {
266014
+ if (!isWord(tokens, k, "is"))
266015
+ continue;
266016
+ let n = k - 1;
266017
+ if (textIs(tokens, n, ")") && tokens.kind[n] === PUNCT && tokens.partner[n] >= 0) {
266018
+ n = tokens.partner[n] - 1;
266019
+ }
266020
+ if (!isName(tokens, n))
266021
+ continue;
266022
+ addEdge(out, textOf(tokens, n), readBase(tokens, k + 1));
266023
+ }
266024
+ return out;
266025
+ }
266026
+ function scanIdentifiers(text) {
266027
+ const tokens = tokenize(text);
266028
+ const names = new Set;
266029
+ for (let i = 0;i < tokens.count; i++) {
266030
+ if (isName(tokens, i))
266031
+ names.add(textOf(tokens, i));
266032
+ }
266033
+ return names;
266034
+ }
266035
+ function collectIdentifierNames(text) {
266036
+ const names = scanIdentifiers(text);
266037
+ for (const name of scanIdentifiers(stripMalloyCommentsAndLiterals(text))) {
266038
+ names.add(name);
266039
+ }
266040
+ return names;
266041
+ }
266042
+ function formatProblem(problem) {
266043
+ const start = problem.at?.range?.start;
266044
+ return start?.line === undefined ? problem.message : `line ${start.line + 1}:${(start.character ?? 0) + 1} ${problem.message}`;
266045
+ }
266046
+ function locateProblemsInCallerText(problems, callerText, prefixLines) {
266047
+ const callerLines = callerText.split(`
266048
+ `).length;
266049
+ return problems.map((problem) => {
266050
+ const at = problem.at;
266051
+ if (!at)
266052
+ return problem;
266053
+ const start = at.range.start.line - prefixLines;
266054
+ const end = at.range.end.line - prefixLines;
266055
+ const inCallerText = at.url.startsWith("internal://") && start >= 0 && end < callerLines;
266056
+ if (!inCallerText) {
266057
+ const { at: _dropped, ...unlocated } = problem;
266058
+ return unlocated;
266059
+ }
266060
+ return {
266061
+ ...problem,
266062
+ at: {
266063
+ ...at,
266064
+ range: {
266065
+ start: { ...at.range.start, line: start },
266066
+ end: { ...at.range.end, line: end }
266067
+ }
266068
+ }
266069
+ };
266070
+ });
266071
+ }
266072
+ var UNREADABLE_BASE = "`", PUNCT = 0, NAME = 1, QUOTED = 2, NAME_CHAR, SPACE_CHAR;
266073
+ var init_query_text = __esm(() => {
266074
+ NAME_CHAR = /\p{Alphabetic}/u;
266075
+ SPACE_CHAR = /\s/u;
266076
+ });
266077
+
265371
266078
  // ../../node_modules/lunr/lunr.js
265372
266079
  var require_lunr = __commonJS((exports, module) => {
265373
266080
  (function() {
@@ -267793,9 +268500,9 @@ function endOfDelimited(text, start) {
267793
268500
  for (;; ) {
267794
268501
  const newline = text.indexOf(`
267795
268502
  `, lineStart);
267796
- const lineEnd = newline === -1 ? text.length : newline;
267797
- if (text.slice(lineStart, lineEnd).trim() === ">>>")
267798
- return lineEnd;
268503
+ const lineEnd2 = newline === -1 ? text.length : newline;
268504
+ if (text.slice(lineStart, lineEnd2).trim() === ">>>")
268505
+ return lineEnd2;
267799
268506
  if (newline === -1)
267800
268507
  return text.length;
267801
268508
  lineStart = newline + 1;
@@ -274386,7 +275093,7 @@ function parseFilterAnnotation(annotation) {
274386
275093
  return null;
274387
275094
  }
274388
275095
  const body = trimmed2.slice(ANNOTATION_PREFIX.length).trim();
274389
- const tokens = tokenize(body);
275096
+ const tokens = tokenize2(body);
274390
275097
  let name;
274391
275098
  let dimension;
274392
275099
  let type;
@@ -274525,7 +275232,7 @@ function injectFilterRefinement(query, filterClause) {
274525
275232
  return `${query.trimEnd()}
274526
275233
  + {where: ${filterClause}}`;
274527
275234
  }
274528
- function tokenize(input) {
275235
+ function tokenize2(input) {
274529
275236
  const tokens = [];
274530
275237
  let current = "";
274531
275238
  let inQuote = false;
@@ -274626,660 +275333,6 @@ var init_json_utils = __esm(() => {
274626
275333
  MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
274627
275334
  });
274628
275335
 
274629
- // src/service/query_text.ts
274630
- import { ParseUtil } from "@malloydata/malloy-tag";
274631
- function extractRunTargetSourceName(query) {
274632
- return extractRunTargetSourceNames(query).at(-1);
274633
- }
274634
- function extractRunTargetSourceNames(query) {
274635
- if (!query)
274636
- return [];
274637
- const tokens = tokenize2(stripMalloyCommentsAndLiterals(query));
274638
- const targets = [];
274639
- for (let k = 0;k + 1 < tokens.count; k++) {
274640
- if (!isWord(tokens, k, "run") || !textIs(tokens, k + 1, ":"))
274641
- continue;
274642
- let j = k + 2;
274643
- while (textIs(tokens, j, "(") && tokens.kind[j] === PUNCT)
274644
- j++;
274645
- if (isName(tokens, j))
274646
- targets.push(textOf(tokens, j));
274647
- }
274648
- if (targets.length > 0)
274649
- return targets;
274650
- for (let k = 0;k + 2 < tokens.count; k++) {
274651
- if (isName(tokens, k) && textIs(tokens, k + 1, "-") && textIs(tokens, k + 2, ">") && tokens.start[k + 2] === tokens.start[k + 1] + 1 && opensLine(tokens, k)) {
274652
- return [textOf(tokens, k)];
274653
- }
274654
- }
274655
- return [];
274656
- }
274657
- function opensLine(tokens, k) {
274658
- if (k === 0)
274659
- return true;
274660
- const from = tokens.end[k - 1];
274661
- const to = tokens.start[k];
274662
- for (let at = from;at < to; at++) {
274663
- const code = tokens.source.charCodeAt(at);
274664
- if (code === 10 || code === 13)
274665
- return true;
274666
- }
274667
- return false;
274668
- }
274669
- function malloyIdentifiers(text) {
274670
- return [...scanIdentifiers(stripMalloyCommentsAndLiterals(text))];
274671
- }
274672
- function buildSourceAliasMap(query) {
274673
- const aliasOf = new Map;
274674
- readDefinitions(query, (keyword, name, base) => {
274675
- if (keyword === "source" && base !== UNREADABLE_BASE) {
274676
- aliasOf.set(name, base);
274677
- }
274678
- });
274679
- return aliasOf;
274680
- }
274681
- function stripMalloyCommentsAndLiterals(text) {
274682
- if (typeof text !== "string")
274683
- throw new TypeError("query text must be a string");
274684
- const parts = [];
274685
- let copied = 0;
274686
- const blank = (from, to) => {
274687
- const end = Math.min(to, text.length);
274688
- if (from >= end)
274689
- return;
274690
- parts.push(text.slice(copied, from));
274691
- let run = from;
274692
- for (let k = from;k < end; k++) {
274693
- if (text.charCodeAt(k) < 32) {
274694
- let slashes = k;
274695
- while (slashes > run && text.charCodeAt(slashes - 1) === 92)
274696
- slashes--;
274697
- parts.push(" ".repeat(slashes - run), text.slice(slashes, k + 1));
274698
- run = k + 1;
274699
- }
274700
- }
274701
- let tail = end;
274702
- if (end < text.length && text.charCodeAt(end) < 32) {
274703
- while (tail > run && text.charCodeAt(tail - 1) === 92)
274704
- tail--;
274705
- }
274706
- parts.push(" ".repeat(tail - run), text.slice(tail, end));
274707
- copied = end;
274708
- };
274709
- const inert = (from, to) => {
274710
- parts.push(text.slice(copied, from), "~".repeat(to - from));
274711
- copied = to;
274712
- };
274713
- const scan = new StringScan(text);
274714
- const sqlResume = [];
274715
- let depth = 0;
274716
- const sql = (from) => {
274717
- const stop = sqlStop(text, from);
274718
- blank(from, stop.bodyEnd);
274719
- if (stop.opensCode) {
274720
- sqlResume.push(depth);
274721
- depth++;
274722
- }
274723
- return stop.end;
274724
- };
274725
- let prefixAt = -1;
274726
- let blankedSince = -1;
274727
- const marks = [];
274728
- const trivia = (from, to) => {
274729
- blank(from, to);
274730
- if (prefixAt < 0)
274731
- return;
274732
- for (let k = Math.min(to, text.length) - 1;k >= from; k--) {
274733
- if (text.charCodeAt(k) >= 32) {
274734
- blankedSince = k;
274735
- break;
274736
- }
274737
- }
274738
- };
274739
- let i = 0;
274740
- while (i < text.length) {
274741
- const code = text.charCodeAt(i);
274742
- if (text.startsWith("--", i) || text.startsWith("//", i) || code === 35) {
274743
- const end = code === 35 ? skipAnnotation(text, i, trivia) : lineTrivia(text, i, trivia);
274744
- if (end >= 0) {
274745
- i = end;
274746
- continue;
274747
- }
274748
- } else if (text.startsWith("/*", i)) {
274749
- const close = text.indexOf("*/", i + 2);
274750
- const end = close === -1 ? text.length : close + 2;
274751
- trivia(i, end);
274752
- i = end;
274753
- continue;
274754
- }
274755
- if (prefixAt >= 0 && !isLexerSpace(code)) {
274756
- if ((code === 39 || code === 34 || code === 96) && blankedSince >= 0) {
274757
- marks.push(blankedSince);
274758
- }
274759
- prefixAt = -1;
274760
- blankedSince = -1;
274761
- }
274762
- if (code === 123) {
274763
- depth++;
274764
- i++;
274765
- continue;
274766
- }
274767
- if (code === 125) {
274768
- depth--;
274769
- i++;
274770
- if (sqlResume.length > 0 && sqlResume[sqlResume.length - 1] === depth) {
274771
- sqlResume.pop();
274772
- i = sql(i);
274773
- }
274774
- continue;
274775
- }
274776
- if (text.startsWith('"""', i)) {
274777
- i = sql(i + 3);
274778
- continue;
274779
- }
274780
- if (code === 39 || code === 34 || code === 96) {
274781
- const close = scan.stringClose(i);
274782
- if (close < 0) {
274783
- i++;
274784
- continue;
274785
- }
274786
- if (code !== 96)
274787
- blank(i + 1, close);
274788
- i = close + 1;
274789
- continue;
274790
- }
274791
- const prefixed = scan.prefixedLiteral(i);
274792
- if (prefixed) {
274793
- const { open: open2, quote, bodyEnd, end } = prefixed;
274794
- const tick = text.charCodeAt(open2) === 96;
274795
- if (tick)
274796
- inert(open2, open2 + quote);
274797
- blank(open2 + quote, bodyEnd);
274798
- if (tick && text.charCodeAt(bodyEnd) === 96)
274799
- inert(bodyEnd, end);
274800
- i = end;
274801
- continue;
274802
- }
274803
- const letter = code | 32;
274804
- if ((letter === 102 || letter === 115) && !continuesToken(text, i)) {
274805
- prefixAt = i;
274806
- }
274807
- i++;
274808
- }
274809
- parts.push(text.slice(copied));
274810
- let out = parts.join("");
274811
- for (const at of marks)
274812
- out = `${out.slice(0, at)}~${out.slice(at + 1)}`;
274813
- return out;
274814
- }
274815
- function lineTrivia(text, i, blank) {
274816
- const end = toEndOfLine(text, i);
274817
- if (end >= 0)
274818
- blank(i, end);
274819
- return end;
274820
- }
274821
- function lineEnd(text, i) {
274822
- let at = i;
274823
- while (at < text.length) {
274824
- const code = text.charCodeAt(at);
274825
- if (code === 10 || code === 13)
274826
- return at;
274827
- at++;
274828
- }
274829
- return at;
274830
- }
274831
- function toEndOfLine(text, i) {
274832
- const end = lineEnd(text, i);
274833
- if (text.charCodeAt(end) === 13 && text.charCodeAt(end + 1) !== 10) {
274834
- return -1;
274835
- }
274836
- return end;
274837
- }
274838
- function nextLine(text, i) {
274839
- const end = lineEnd(text, i);
274840
- if (text.charCodeAt(end) === 13 && text.charCodeAt(end + 1) === 10) {
274841
- return end + 2;
274842
- }
274843
- return Math.min(end + 1, text.length);
274844
- }
274845
- function skipAnnotation(text, i, blank) {
274846
- const end = toEndOfLine(text, i);
274847
- if (end < 0)
274848
- return -1;
274849
- const closer = text.startsWith("##|", i) ? "|##" : text.startsWith("#|", i) ? "|#" : undefined;
274850
- if (!closer) {
274851
- blank(i, end);
274852
- return end;
274853
- }
274854
- let column = 0;
274855
- for (let at2 = text.lastIndexOf(`
274856
- `, i - 1) + 1;at2 < i; at2++) {
274857
- if ((text.charCodeAt(at2) & 64512) !== 56320)
274858
- column++;
274859
- }
274860
- let at = nextLine(text, i);
274861
- blank(i, at);
274862
- while (at < text.length) {
274863
- const closes = closesBlock(text, at, column, closer);
274864
- const after = nextLine(text, at);
274865
- blank(at, after);
274866
- at = after;
274867
- if (closes)
274868
- break;
274869
- }
274870
- return at;
274871
- }
274872
- function closesBlock(text, at, column, closer) {
274873
- for (let c = 0;c < column; c++) {
274874
- const code = text.charCodeAt(at + c);
274875
- if (code !== 32 && code !== 9)
274876
- return false;
274877
- }
274878
- const from = at + column;
274879
- if (!text.startsWith(closer, from))
274880
- return false;
274881
- return closer !== "|#" || text.charCodeAt(from + 2) !== 35;
274882
- }
274883
- function sqlStop(text, from) {
274884
- const reach = [false, false, false, false];
274885
- let furthest = from;
274886
- const mark = (at) => {
274887
- reach[at & 3] = true;
274888
- furthest = Math.max(furthest, at);
274889
- };
274890
- mark(from);
274891
- let stop = {
274892
- bodyEnd: text.length,
274893
- end: text.length,
274894
- opensCode: false
274895
- };
274896
- let found = false;
274897
- for (let j = from;j < text.length && j <= furthest; j++) {
274898
- if (!reach[j & 3])
274899
- continue;
274900
- reach[j & 3] = false;
274901
- const code = text.charCodeAt(j);
274902
- const next = text.charCodeAt(j + 1);
274903
- if (code === 34 && next === 34 && text.charCodeAt(j + 2) === 34) {
274904
- if (!found || j + 3 > stop.end) {
274905
- stop = { bodyEnd: j, end: j + 3, opensCode: false };
274906
- }
274907
- found = true;
274908
- continue;
274909
- }
274910
- if (code === 37 && next === 123) {
274911
- if (!found || j + 2 > stop.end) {
274912
- stop = { bodyEnd: j, end: j + 2, opensCode: true };
274913
- }
274914
- found = true;
274915
- continue;
274916
- }
274917
- if (code === 92) {
274918
- mark(j + 1);
274919
- mark(j + 2);
274920
- } else if (code === 34) {
274921
- mark(next === 34 ? j + 3 : j + 2);
274922
- } else if (code === 37) {
274923
- mark(j + 2);
274924
- } else {
274925
- mark(j + 1);
274926
- }
274927
- }
274928
- return stop;
274929
- }
274930
- function isLexerSpace(code) {
274931
- return code === 32 || code === 9 || code === 10 || code === 11 || code === 13 || code === 160;
274932
- }
274933
-
274934
- class StringScan {
274935
- text;
274936
- stringFailedAt = [-1, -1, -1];
274937
- tripleUnclosedFrom = [Infinity, Infinity, Infinity];
274938
- constructor(text) {
274939
- this.text = text;
274940
- }
274941
- stringClose(i) {
274942
- const text = this.text;
274943
- const quote = text.charCodeAt(i);
274944
- const slot = quoteSlot(quote);
274945
- if (i < this.stringFailedAt[slot])
274946
- return -1;
274947
- let j = i + 1;
274948
- while (j < text.length) {
274949
- const code = text.charCodeAt(j);
274950
- if (code === quote)
274951
- return j;
274952
- if (code === 92) {
274953
- if (j + 1 >= text.length || text.charCodeAt(j + 1) === 10)
274954
- break;
274955
- j += 2;
274956
- continue;
274957
- }
274958
- if (code < 32 && code !== 9)
274959
- break;
274960
- j++;
274961
- }
274962
- this.stringFailedAt[slot] = j;
274963
- return -1;
274964
- }
274965
- prefixedLiteral(i) {
274966
- const text = this.text;
274967
- const code = text.charCodeAt(i);
274968
- if (code === 47) {
274969
- if (text.charCodeAt(i + 1) !== 39)
274970
- return;
274971
- return this.raw(i + 1, 39, false);
274972
- }
274973
- const letter = code | 32;
274974
- if (letter !== 102 && letter !== 114 && letter !== 115)
274975
- return;
274976
- if (continuesToken(text, i))
274977
- return;
274978
- if (letter === 114) {
274979
- return text.charCodeAt(i + 1) === 39 ? this.raw(i + 1, 39, false) : undefined;
274980
- }
274981
- let open2 = i + 1;
274982
- while (open2 < text.length && isLexerSpace(text.charCodeAt(open2)))
274983
- open2++;
274984
- const quote = text.charCodeAt(open2);
274985
- if (letter === 115) {
274986
- return quote === 39 || quote === 34 ? this.raw(open2, quote, true) : undefined;
274987
- }
274988
- if (quote !== 39 && quote !== 34 && quote !== 96)
274989
- return;
274990
- if (text.charCodeAt(open2 + 1) === quote && text.charCodeAt(open2 + 2) === quote) {
274991
- const close = this.tripleClose(open2 + 3, quote);
274992
- if (close >= 0) {
274993
- return { open: open2, quote: 3, bodyEnd: close, end: close + 3 };
274994
- }
274995
- }
274996
- return this.raw(open2, quote, true);
274997
- }
274998
- raw(open2, quote, endsAtNewline) {
274999
- const text = this.text;
275000
- let j = open2 + 1;
275001
- while (j < text.length) {
275002
- const code = text.charCodeAt(j);
275003
- if (code === 10) {
275004
- return endsAtNewline ? { open: open2, quote: 1, bodyEnd: j, end: j + 1 } : undefined;
275005
- }
275006
- if (code === 92) {
275007
- if (j + 1 >= text.length || text.charCodeAt(j + 1) === 10) {
275008
- return;
275009
- }
275010
- j += 2;
275011
- continue;
275012
- }
275013
- if (code === quote)
275014
- return { open: open2, quote: 1, bodyEnd: j, end: j + 1 };
275015
- j++;
275016
- }
275017
- return;
275018
- }
275019
- tripleClose(body, quote) {
275020
- const text = this.text;
275021
- const slot = quoteSlot(quote);
275022
- if (body >= this.tripleUnclosedFrom[slot])
275023
- return -1;
275024
- let j = body;
275025
- while (j < text.length) {
275026
- const code = text.charCodeAt(j);
275027
- if (code === 92) {
275028
- j += 2;
275029
- continue;
275030
- }
275031
- if (code === quote && text.charCodeAt(j + 1) === quote && text.charCodeAt(j + 2) === quote) {
275032
- return j;
275033
- }
275034
- j++;
275035
- }
275036
- this.tripleUnclosedFrom[slot] = body;
275037
- return -1;
275038
- }
275039
- }
275040
- function quoteSlot(quote) {
275041
- return quote === 39 ? 0 : quote === 34 ? 1 : 2;
275042
- }
275043
- function continuesToken(text, i) {
275044
- if (i === 0)
275045
- return false;
275046
- let at = i - 1;
275047
- if ((text.charCodeAt(at) & 64512) === 56320 && at > 0)
275048
- at--;
275049
- const code = text.charCodeAt(at);
275050
- return code === 36 || isNameCode(code, text, at);
275051
- }
275052
- function buildDerivationBaseMap(query) {
275053
- const out = new Map;
275054
- readDefinitions(query, (_keyword, name, base) => addEdge(out, name, base));
275055
- return out;
275056
- }
275057
- function readDefinitions(query, onItem) {
275058
- const tokens = tokenize2(stripMalloyCommentsAndLiterals(query));
275059
- let depth = 0;
275060
- for (let i = 0;i < tokens.count; i++) {
275061
- if (isPunct(tokens, i, "{"))
275062
- depth++;
275063
- else if (isPunct(tokens, i, "}"))
275064
- depth--;
275065
- if (depth !== 0 || !isStatementKeyword(tokens, i))
275066
- continue;
275067
- const keyword = isWord(tokens, i, "source") ? "source" : isWord(tokens, i, "query") ? "query" : undefined;
275068
- if (!keyword)
275069
- continue;
275070
- readStatementItems(tokens, i + 2, false, (name, base) => onItem(keyword, name, base));
275071
- }
275072
- }
275073
- function isNameCode(code, text, at) {
275074
- if (code < 128) {
275075
- return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 95;
275076
- }
275077
- return NAME_CHAR.test(String.fromCodePoint(text.codePointAt(at)));
275078
- }
275079
- function isSpaceCode(code, text, at) {
275080
- if (code < 128)
275081
- return code === 32 || code >= 9 && code <= 13;
275082
- return SPACE_CHAR.test(String.fromCodePoint(text.codePointAt(at)));
275083
- }
275084
- function unitsAt(text, at) {
275085
- return text.codePointAt(at) > 65535 ? 2 : 1;
275086
- }
275087
- function tokenize2(text) {
275088
- const size = text.length + 1;
275089
- const tokens = {
275090
- source: text,
275091
- count: 0,
275092
- kind: new Uint8Array(size),
275093
- start: new Int32Array(size),
275094
- end: new Int32Array(size),
275095
- partner: new Int32Array(size).fill(-1),
275096
- decoded: new Map
275097
- };
275098
- const push = (kind, start, end) => {
275099
- const index = tokens.count++;
275100
- tokens.kind[index] = kind;
275101
- tokens.start[index] = start;
275102
- tokens.end[index] = end;
275103
- return index;
275104
- };
275105
- const open2 = [];
275106
- const scan = new StringScan(text);
275107
- let i = 0;
275108
- while (i < text.length) {
275109
- const code = text.charCodeAt(i);
275110
- if (isSpaceCode(code, text, i)) {
275111
- i += unitsAt(text, i);
275112
- continue;
275113
- }
275114
- const close = code === 96 ? scan.stringClose(i) : -1;
275115
- if (close >= 0) {
275116
- const index2 = push(QUOTED, i + 1, close);
275117
- const inner = text.slice(i + 1, close);
275118
- if (inner.includes("\\")) {
275119
- tokens.decoded.set(index2, ParseUtil.parseString(inner));
275120
- }
275121
- i = close + 1;
275122
- continue;
275123
- }
275124
- if (isNameCode(code, text, i)) {
275125
- let j = i + unitsAt(text, i);
275126
- while (j < text.length && isNameCode(text.charCodeAt(j), text, j)) {
275127
- j += unitsAt(text, j);
275128
- }
275129
- push(NAME, i, j);
275130
- i = j;
275131
- continue;
275132
- }
275133
- const units = unitsAt(text, i);
275134
- const index = push(PUNCT, i, i + units);
275135
- if (code === 40 || code === 91 || code === 123) {
275136
- open2.push(index);
275137
- } else if (code === 41 || code === 93 || code === 125) {
275138
- const at = open2.pop();
275139
- if (at !== undefined) {
275140
- tokens.partner[at] = index;
275141
- tokens.partner[index] = at;
275142
- }
275143
- }
275144
- i += units;
275145
- }
275146
- return tokens;
275147
- }
275148
- function textOf(tokens, i) {
275149
- return tokens.decoded.get(i) ?? tokens.source.slice(tokens.start[i], tokens.end[i]);
275150
- }
275151
- function textIs(tokens, i, char) {
275152
- return i >= 0 && i < tokens.count && tokens.end[i] - tokens.start[i] === 1 && tokens.source.charCodeAt(tokens.start[i]) === char.charCodeAt(0);
275153
- }
275154
- function isPunct(tokens, i, char) {
275155
- return textIs(tokens, i, char) && tokens.kind[i] === PUNCT;
275156
- }
275157
- function isName(tokens, i) {
275158
- return i >= 0 && i < tokens.count && tokens.kind[i] !== PUNCT;
275159
- }
275160
- function isWord(tokens, i, word) {
275161
- if (i < 0 || i >= tokens.count || tokens.kind[i] !== NAME || tokens.end[i] - tokens.start[i] !== word.length) {
275162
- return false;
275163
- }
275164
- const from = tokens.start[i];
275165
- for (let k = 0;k < word.length; k++) {
275166
- const code = tokens.source.charCodeAt(from + k);
275167
- const lower = code >= 65 && code <= 90 ? code + 32 : code;
275168
- if (lower !== word.charCodeAt(k))
275169
- return false;
275170
- }
275171
- return true;
275172
- }
275173
- function isStatementKeyword(tokens, i) {
275174
- return i < tokens.count && tokens.kind[i] === NAME && textIs(tokens, i + 1, ":") && !textIs(tokens, i + 2, ":") && !textIs(tokens, i - 1, ":") && !textIs(tokens, i - 1, ".");
275175
- }
275176
- function readBase(tokens, i) {
275177
- let j = i;
275178
- while (textIs(tokens, j, "(") && tokens.kind[j] === PUNCT)
275179
- j++;
275180
- if (!isName(tokens, j) || isLiteralPrefix(tokens, j))
275181
- return UNREADABLE_BASE;
275182
- return textOf(tokens, j);
275183
- }
275184
- function isLiteralPrefix(tokens, i) {
275185
- if (!(isWord(tokens, i, "f") || isWord(tokens, i, "s") || isWord(tokens, i, "r"))) {
275186
- return false;
275187
- }
275188
- return ["'", '"', "`", "~"].some((quote) => isPunct(tokens, i + 1, quote));
275189
- }
275190
- function readStatementItems(tokens, start, shorthand, onItem) {
275191
- let atItemStart = true;
275192
- let i = start;
275193
- while (i < tokens.count) {
275194
- if (tokens.kind[i] === PUNCT) {
275195
- if (textIs(tokens, i, ";"))
275196
- return;
275197
- if (tokens.partner[i] > i) {
275198
- i = tokens.partner[i] + 1;
275199
- atItemStart = false;
275200
- continue;
275201
- }
275202
- if (textIs(tokens, i, ")") || textIs(tokens, i, "]") || textIs(tokens, i, "}")) {
275203
- return;
275204
- }
275205
- atItemStart = textIs(tokens, i, ",");
275206
- i++;
275207
- continue;
275208
- }
275209
- if (isStatementKeyword(tokens, i))
275210
- return;
275211
- let k = i + 1;
275212
- if (textIs(tokens, k, "(") && tokens.partner[k] > k) {
275213
- k = tokens.partner[k] + 1;
275214
- }
275215
- if (isWord(tokens, k, "is")) {
275216
- onItem(textOf(tokens, i), readBase(tokens, k + 1));
275217
- i = k + 1;
275218
- } else {
275219
- if (shorthand && atItemStart) {
275220
- const name = textOf(tokens, i);
275221
- onItem(name, name);
275222
- }
275223
- i++;
275224
- }
275225
- atItemStart = false;
275226
- }
275227
- }
275228
- function addEdge(map, name, base) {
275229
- const bases = map.get(name) ?? new Set;
275230
- bases.add(base);
275231
- map.set(name, bases);
275232
- }
275233
- function buildJoinBaseMap(query) {
275234
- const out = new Map;
275235
- const tokens = tokenize2(stripMalloyCommentsAndLiterals(query));
275236
- for (let i = 0;i < tokens.count; i++) {
275237
- if (!isStatementKeyword(tokens, i))
275238
- continue;
275239
- if (!/^join_(?:one|many|cross)$/i.test(textOf(tokens, i)))
275240
- continue;
275241
- readStatementItems(tokens, i + 2, true, (alias, base) => addEdge(out, alias, base));
275242
- }
275243
- return out;
275244
- }
275245
- function buildIsEdgeMap(query) {
275246
- const out = new Map;
275247
- const tokens = tokenize2(stripMalloyCommentsAndLiterals(query));
275248
- for (let k = 1;k < tokens.count; k++) {
275249
- if (!isWord(tokens, k, "is"))
275250
- continue;
275251
- let n = k - 1;
275252
- if (textIs(tokens, n, ")") && tokens.kind[n] === PUNCT && tokens.partner[n] >= 0) {
275253
- n = tokens.partner[n] - 1;
275254
- }
275255
- if (!isName(tokens, n))
275256
- continue;
275257
- addEdge(out, textOf(tokens, n), readBase(tokens, k + 1));
275258
- }
275259
- return out;
275260
- }
275261
- function scanIdentifiers(text) {
275262
- const tokens = tokenize2(text);
275263
- const names = new Set;
275264
- for (let i = 0;i < tokens.count; i++) {
275265
- if (isName(tokens, i))
275266
- names.add(textOf(tokens, i));
275267
- }
275268
- return names;
275269
- }
275270
- function collectIdentifierNames(text) {
275271
- const names = scanIdentifiers(text);
275272
- for (const name of scanIdentifiers(stripMalloyCommentsAndLiterals(text))) {
275273
- names.add(name);
275274
- }
275275
- return names;
275276
- }
275277
- var UNREADABLE_BASE = "`", PUNCT = 0, NAME = 1, QUOTED = 2, NAME_CHAR, SPACE_CHAR;
275278
- var init_query_text = __esm(() => {
275279
- NAME_CHAR = /\p{Alphabetic}/u;
275280
- SPACE_CHAR = /\s/u;
275281
- });
275282
-
275283
275336
  // src/service/caller_joins.ts
275284
275337
  import { isSourceDef as isSourceDef6 } from "@malloydata/malloy";
275285
275338
  function isCallerAuthored(location, region, authorUrls) {
@@ -276709,6 +276762,25 @@ function isGivenBindingFailure(err) {
276709
276762
  const problems = err?.problems;
276710
276763
  return Array.isArray(problems) && problems.some((p) => isGivenCode(p?.code));
276711
276764
  }
276765
+ function queryCompileError(error, callerText) {
276766
+ const problems = locateProblemsInCallerText(error.problems, callerText, AD_HOC_QUERY_PREFIX.split(`
276767
+ `).length - 1);
276768
+ return new QueryCompileError(describeProblems(problems, error), problems);
276769
+ }
276770
+ function describeProblems(problems, error) {
276771
+ const errors2 = problems.filter((p) => p.severity === "error");
276772
+ if (errors2.length === 0)
276773
+ return error.message;
276774
+ return errors2.map(formatProblem).join("; ");
276775
+ }
276776
+ async function compileErrorOf(runnable) {
276777
+ try {
276778
+ await runnable.getPreparedQuery();
276779
+ return;
276780
+ } catch (error) {
276781
+ return error instanceof MalloyError4 ? error : undefined;
276782
+ }
276783
+ }
276712
276784
  function bindingsAllowDegradeToLive(bindings) {
276713
276785
  return bindings.length > 0 && bindings.every((b) => b.origin === "preaggregate" || b.freshnessFallback === "live");
276714
276786
  }
@@ -276801,7 +276873,8 @@ function joinOutline(field) {
276801
276873
  fields: fields.map((inner) => inner && typeof inner === "object" ? joinOutline(inner) : inner)
276802
276874
  };
276803
276875
  }
276804
- var MALLOY_VERSION, REQUEST_CHAIN_MAX_NAMES = 64, NO_PREAGGREGATE_VIOLATIONS, Model, SOURCE_IDENTITY_KEYS;
276876
+ var MALLOY_VERSION, REQUEST_CHAIN_MAX_NAMES = 64, AD_HOC_QUERY_PREFIX = `
276877
+ `, NO_PREAGGREGATE_VIOLATIONS, Model, SOURCE_IDENTITY_KEYS;
276805
276878
  var init_model = __esm(() => {
276806
276879
  init_telemetry();
276807
276880
  init_materialization_metrics();
@@ -276863,6 +276936,7 @@ var init_model = __esm(() => {
276863
276936
  compilationError;
276864
276937
  filterMap;
276865
276938
  givens;
276939
+ notebookCellDeclaredGivens = [];
276866
276940
  declaredQueryMetadataMemo;
276867
276941
  declaredSourceQueryMetadataMemo;
276868
276942
  preaggregateViolationsMemo;
@@ -276902,6 +276976,15 @@ var init_model = __esm(() => {
276902
276976
  this.compilationError = compilationError;
276903
276977
  this.filterMap = filterMap ?? new Map;
276904
276978
  this.givens = givens;
276979
+ this.notebookCellDeclaredGivens = (this.runnableNotebookCells ?? []).map((cell) => {
276980
+ if (!cell.modelDef)
276981
+ return;
276982
+ const registry = cell.modelDef.givens ?? {};
276983
+ return {
276984
+ ids: new Set(Object.keys(registry)),
276985
+ names: new Set(Object.values(registry).map((g) => g.name))
276986
+ };
276987
+ });
276905
276988
  try {
276906
276989
  this.entryPointGatesBySource = this.computeEntryPointGatesBySource();
276907
276990
  } catch {
@@ -276994,18 +277077,23 @@ var init_model = __esm(() => {
276994
277077
  getAccessFilter(sourceName) {
276995
277078
  return this.sources?.find((source) => source.name === sourceName)?.accessFilter ?? [];
276996
277079
  }
276997
- filterGivensToModelSurface(givens) {
277080
+ filterGivensToModelSurface(givens, cellDeclared) {
276998
277081
  if (!givens)
276999
277082
  return givens;
277000
277083
  const surfaceNames = new Set((this.givens ?? []).map((g) => g.name));
277001
277084
  const filtered = {};
277002
277085
  for (const [name, value] of Object.entries(givens)) {
277003
277086
  const authorizeOnly = !surfaceNames.has(name) && this.authorizeReferencedGivenNames.has(name);
277004
- if (!authorizeOnly)
277087
+ const outOfCellScope = cellDeclared !== undefined && surfaceNames.has(name) && !cellDeclared.names.has(name) && !this.cellDeclaresGivenId(name, cellDeclared);
277088
+ if (!authorizeOnly && !outOfCellScope)
277005
277089
  filtered[name] = value;
277006
277090
  }
277007
277091
  return filtered;
277008
277092
  }
277093
+ cellDeclaresGivenId(surfaceName, cellDeclared) {
277094
+ const entry = this.modelDef?.contents[surfaceName];
277095
+ return entry?.type === "given" && cellDeclared.ids.has(entry.id);
277096
+ }
277009
277097
  computeAuthorizeReferencedGivenNames() {
277010
277098
  const names = new Set;
277011
277099
  const addExprs = (exprs) => {
@@ -278174,6 +278262,19 @@ var init_model = __esm(() => {
278174
278262
  }
278175
278263
  return "deferred";
278176
278264
  }
278265
+ queryTextSourcesQueryable(query) {
278266
+ const queryable = (name) => this.isCuratedSource(name) || this.derivesFromCurated(name, query);
278267
+ const runTargets = extractRunTargetSourceNames(query);
278268
+ if (runTargets.length === 0)
278269
+ return false;
278270
+ if (!runTargets.every(queryable))
278271
+ return false;
278272
+ for (const declared of buildDerivationBaseMap(query).keys()) {
278273
+ if (!queryable(declared))
278274
+ return false;
278275
+ }
278276
+ return true;
278277
+ }
278177
278278
  assertQueryBoundaryCompiled(compiledSource, query, derivationContext) {
278178
278279
  const { mode, exploresDeclared } = this.queryBoundary;
278179
278280
  if (mode === "all" || !exploresDeclared)
@@ -278847,6 +278948,23 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
278847
278948
  });
278848
278949
  throw new BadRequestError(`Invalid query: ${errorMessage}`);
278849
278950
  }
278951
+ if (!sourceName && !queryName && query && liveRunnable && runnable === liveRunnable) {
278952
+ const compileError = await compileErrorOf(liveRunnable);
278953
+ if (compileError && !isGivenBindingFailure(compileError)) {
278954
+ if (boundary === "deferred" && !this.queryTextSourcesQueryable(query)) {
278955
+ const explainable = extractRunTargetSourceNames(query).some((t) => this.declaresSource(t) && !this.isCuratedSource(t) && !this.derivesFromCurated(t, query));
278956
+ throw this.notQueryable("Query target is not queryable.", explainable, "source");
278957
+ }
278958
+ this.queryExecutionHistogram.record(performance.now() - startTime, this.queryMetricAttributes({
278959
+ environment: queryMetadataInput?.environment,
278960
+ queryName,
278961
+ sourceName,
278962
+ status: "error",
278963
+ servedFrom
278964
+ }));
278965
+ throw queryCompileError(compileError, query);
278966
+ }
278967
+ }
278850
278968
  const compiledSource = await this.resolveAuthorizeSourceFromRunnable(runnable);
278851
278969
  if (boundary === "deferred") {
278852
278970
  this.assertQueryBoundaryCompiled(compiledSource, query);
@@ -279259,7 +279377,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
279259
279377
  const cellMaxRows = getMaxQueryRows();
279260
279378
  const cellMaxBytes = getMaxResponseBytes();
279261
279379
  const buildManifest = this.withoutPreaggregateEntries(this.resolveFreshBuildManifest());
279262
- const cellSurfaceGivens = this.filterGivensToModelSurface(givens);
279380
+ const cellSurfaceGivens = this.filterGivensToModelSurface(givens, this.notebookCellDeclaredGivens[cellIndex]);
279263
279381
  const preparedCell = await runnableToExecute.getPreparedResult({
279264
279382
  givens: cellSurfaceGivens,
279265
279383
  buildManifest
@@ -301724,6 +301842,7 @@ function recordDashboardWrite(outcome, durationMs) {
301724
301842
 
301725
301843
  // src/controller/dashboard.controller.ts
301726
301844
  init_path_safety();
301845
+ init_query_text();
301727
301846
  function outcomeOf(error) {
301728
301847
  if (error instanceof WriteConflictError)
301729
301848
  return "conflict";
@@ -301734,10 +301853,6 @@ function outcomeOf(error) {
301734
301853
  return "refused";
301735
301854
  }
301736
301855
  var DASHBOARD_FILE = /^dashboards\/[^/]+\.malloy$/;
301737
- var describeProblem = (problem) => {
301738
- const start = problem.at?.range?.start;
301739
- return start?.line === undefined ? problem.message : `line ${start.line + 1}:${(start.character ?? 0) + 1} ${problem.message}`;
301740
- };
301741
301856
  var contentHashOf = (text) => createHash2("sha256").update(text, "utf8").digest("hex");
301742
301857
 
301743
301858
  class DashboardController {
@@ -301786,7 +301901,7 @@ class DashboardController {
301786
301901
  const { problems } = await environment.compileSource(packageName, modelPath, body.source, false, undefined, "file");
301787
301902
  const errors2 = problems.filter((problem) => problem.severity === "error");
301788
301903
  if (errors2.length > 0) {
301789
- throw new CompileRefusedError(`The dashboard does not compile, so it was not written: ` + errors2.map(describeProblem).join("; "));
301904
+ throw new CompileRefusedError(`The dashboard does not compile, so it was not written: ` + errors2.map(formatProblem).join("; "));
301790
301905
  }
301791
301906
  const { previous } = await environment.writeModelFileTransactional(packageName, modelPath, body.source, (current) => {
301792
301907
  if (body.expectedHash === undefined) {
@@ -321492,7 +321607,7 @@ a permanent parallel mechanism.`
321492
321607
  {
321493
321608
  name: "eval-answer/ledger-schema",
321494
321609
  description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
321495
- body: "# The eval ledger: files and events\n\nThe ledger is plain files in the model package's git repository. There is no\neval API and no eval database. The conductor (`skill:eval-loop`) reads and\nwrites these files directly; the stages share them as their contract.\n`eval-answer` writes `attempt`, `tool_call`, and `score`. An engine-side\n`eval-retrieval` skill, which does not ship here, writes `retrieval_score` and\n`probe`; both stay in this contract so one validator covers every run\ndirectory.\n`eval-diagnose` writes `issue` and `issue_status`. `eval-improve` writes\n`candidate`. `eval-loop` writes `acceptance_check`, further `issue_status`, and\n`checkpoint`.\n\n**The contract is code: `eval-answer/scripts/ledger.py`.** Every script that\nwrites `run.json` or `events.jsonl` builds its lines through that module, so a\nfield rename that reaches only one writer fails at write time. This document\nis the human-readable rendering; where the two disagree, the module is right\nand this file is the bug. Check any run directory with:\n\n```\npython3 skills/eval-answer/scripts/ledger.py validate <runDir>\n```\n\nErrors are broken identity (missing `run.json`, a missing required field, two\nscores for one attempt); warnings are missing comparison pins (a run without\n`skillsVersion` still measures, but cannot anchor an A/B on that pin) and\ngrandfathered unknown fields on old runs.\n\n## Layout\n\n```\nevals/<set>/\n set.json # set metadata (below)\n cases.jsonl # one case per line\n judge-regressions.jsonl # judge verdicts a human overruled\n runs/<runId>/\n run.json # run config, the attribution pins\n events.jsonl # append-only event lines\n artifacts/ # prediction CSVs, judge outputs, transcripts\n```\n\nThe set directory lives in the SAME git repository as the model it evaluates,\nso a checkpoint (a git commit) pins the model and the ledger together. Never\nplace `evals/` inside the directory tree the answerer's package serves: gold\nin the served tree is a contamination path.\n\nRules that make the ledger trustworthy:\n\n- `events.jsonl` is append-only. Never edit a line. To void one, append a new\n event whose payload marks the old one `voided`. One sanctioned exception: a\n re-runnable stage (diagnose, improve) replaces ITS OWN prior events, keyed by\n issue id, through `ledger.replace_events()` -- so a crash-and-rerun does not\n duplicate issues. A stage never touches another stage's lines.\n- A stage never rewrites another stage's fields.\n- One artifact directory per attempt (`qid` plus `sample`). Never overwrite a\n previous attempt's prediction CSV.\n- End-of-run numbers come from counting event lines (`jq` over\n `events.jsonl`), never from arithmetic recalled in prose.\n\n## `set.json`\n\n| Field | Notes |\n|---|---|\n| `name` | Set name; also the directory name. |\n| `description` | |\n| `datasetVersion` | Integer. Bump on any golden repair or case change. Runs record the version they scored against. |\n| `targetModelPath` | Model path within the package. |\n| `truthPackage` | Name of the package holding the semantics-free sources every golden is re-derived from -- a package NAME, not an object. Without it `verify_goldens.py` re-derives nothing: it reports \"nothing to re-derive against\" and **exits 3**, which a caller must read as unverified rather than as a pass. The checks that need no truth server -- rubric figures, verification axis, stale rubric claims, set names -- still run and still report. `init_truth_package.py` scaffolds the package. |\n| `truthModel` | Model file inside that package. Default `truth.malloy`. |\n| `targetPackage` | Name of the package UNDER TEST. `verify_goldens.py` refuses a \"truth\" server that also serves it, because an answerer on that server can retrieve the raw truth sources beside the model and quietly change what is under test. `run_baseline.py` and `improve.py` pass the package they were given, so this field is the fallback for a direct call; without either the guard cannot fire. |\n| `truthTableRewrite` | Boolean, default false. Rewrites `duckdb.table('data/x.parquet')` refs to bare `x` in canonical queries, for a truth package whose tables are registered rather than read from files. |\n\n## `cases.jsonl`\n\nOne JSON object per line:\n\n| Field | Notes |\n|---|---|\n| `qid` | Stable case id. |\n| `question` | Exact text the answerer will see. |\n| `split` | `dev` or `holdout`. Frozen at import. Diagnose and improve read `dev` only; the acceptance check runs both. |\n| `tags` | list |\n| `state` | `candidate` / `selected` / `excluded`. |\n| `source` | Where the case came from. |\n| `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (artifact under the set directory), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |\n| `golden.kind` | What the key IS. A value (`scalar`, rows), `unanswerable`, or `criteria`: prose with no value, where the author's criteria are the whole key. `criteria` is the one kind that is legitimately `verified` on arrival, because there is no derivation to perform and nothing that can be numerically wrong; an imported key holding a VALUE is `provisional` until re-derived (`skill:eval-import`). **`verify_goldens.py --promote` is the only thing that writes `golden.status`**: it marks a `provisional` golden `verified` when its value re-derived cleanly from the truth package AND a second derivation exists. `--refresh` rewrites a drifted VALUE and never changes a status; `invalid` and `ambiguous` are settled by a person through the golden side door. |\n| `golden.rubric` | **On the golden, not on the case.** The prose the judge is shown as `CASE RUBRIC`, naming what counts as correct and what does not. `run_baseline.py` reads `golden.rubric`; a rubric written at the case's top level is silently not passed, the judge is told `CASE RUBRIC: none`, and it then scores from the golden value and the answer alone -- which reads as a judge that ignores its instructions. |\n| `golden.mustState` | **On the golden, not on the case.** What the answer has to say out loud. Read from `golden.mustState` by the run-package builder. |\n| `goldenRevision` | Integer. Bump on every golden change; `score` events stamp the revision they compared against. |\n| `expectedEntities` | `required`: entity ids (`kind:source:name`) the answer cannot be produced without. `requiredAnyOf`: a list of **groups**, each a list of ids of which any one suffices -- for a case the model can answer through more than one route. Naming only one route scores the other as a retrieval miss and steers the fix to the docs for an entity whose docs were never the problem. `acceptable`: ids that are not noise if returned. An entity counts as delivered when returned under its exact id, under the same type and name on a sibling source, or by name in a returned source's documentation; `score_retrieval.py` records the route per entity as `delivery`. |\n\n## Term files (`eval-retrieval` only)\n\nA customer set has no term file. The engine-side `eval-retrieval` skill keeps\n`intents.jsonl` fixtures -- search term, entity type, a description of what the\nsearcher meant, a validity flag -- beside its own scripts, for evaluating the\nengine against fixed inputs. Their shape and the `retrieval_score` events they\nproduce are documented there and validated here.\n\n## `run.json`\n\nThe attribution pins. Two runs are comparable only when these match where it\nmatters. Two runs with different `target` values are comparable on answer\nverdicts only if the same model version reached both, which is rarely worth\nassuming:\n\nRequired fields are the run's **identity** -- every run ever written carries\nthem. The rest are optional in the schema, but the **comparison pins** are\nwarned about when absent, because a run missing one cannot take part in an A/B\non that axis.\n\n| Field | Notes |\n|---|---|\n| `runId` *(required)* | Directory name. |\n| `target` *(required)* | Which server answered, and how it reached the data: `local`, `local-proxied`, or `platform`. Decides what an improve step is even allowed to do (see `skill:eval-loop`). |\n| `answererModel` *(required)* | |\n| `phase` / `started` *(required)* | |\n| `judgeModel` / `judgeVersion` / `rubricSha` *(pins)* | The judge's model, and the version + content sha of `skill:eval-judge`. |\n| `judgePromptSha` *(pin)* | Content sha of the prompt the judge is SHOWN, which is the harness's own template and not the skill file `rubricSha` covers. The template carries the golden rendering, the evidence blocks and the tie-breaking rules, so two runs whose prompts differed compared as the same judge -- the blind spot that let a golden-rendering defect run through three arms with every test passing, because the tests covered the code paths and not the text the model sees. Absent on runs written before it existed. |\n| `datasetVersion` *(pin)* | From `set.json` at run time. |\n| `modelSha` *(pin)* | Content sha of the `model.malloy` snapshot in the run directory -- the bytes the answerer actually queried. A git sha is not enough: a snapshot host serves a copy, often of a dirty tree no commit names. |\n| `skillsVersion` *(pin)* | HEAD of the checkout the agents' skills were loaded from, dirty-marked (`ledger.skills_git_sha(root)`). The skills are the doctrine the agents load; a run that cannot name their version cannot anchor a skills A/B. |\n| `skillsRoot` / `harnessVersion` | Which checkout supplied the doctrine (`--skills-root`, e.g. a Publisher checkout for the open-source skills; default this one), and this checkout's own HEAD. The eval-* skills always come from the harness checkout, whatever `skillsRoot` says. Two runs whose `skillsRoot` differ are a skills A/B only if their manifests name the same skills. |\n| `diagnoserModel` / `improverModel` | Written by `diagnose.py` / `improve.py` when those stages run, so the run names every LLM that touched it. Absent on a run that was only answered and judged. |\n| `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when that path had uncommitted changes -- fine for a band measurement, not for an A/B pin. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\n| `modelDir` | The package directory `--model-dir` named, when it was: the path the `-dirty` marker on `modelGitSha` was decided over. Absent means the whole repo, where any unrelated untracked file marks the model dirty. |\n| `environment` / `package` / `modelPath` | What was served, and from where. On a platform target `environment` is the organization and `package` is the workspace the MCP URL is scoped to. |\n| `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |\n| `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |\n| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |\n| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |\n| `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |\n| `retrievalCalls` | `{semantic, lexical, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |\n| `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart are lexical by design and an arm started immediately measures two retrievers. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |\n| `coverageReport` | The `check_coverage.py --out` report this run read, when `--coverage` named one: its path, the model version it was stamped with, its `agentModel`, and `decided` of `cases`. Coverage is a read of the MODEL, so it is measured separately and costs a judge call per case; the run does not run it, it consumes it. Its per-case verdict then beats the authored `coverage` label in retrieval attribution, and each `score_retrieval` row records `coverage_source` (`measured`, `authored`, `none`) so nobody has to guess which one charged the failure. Absent when no report was given, which is a different fact from a report that found everything covered. |\n| `label` | `<set>-<phase>-<nn>`, assigned by `run_baseline.py` from the set name, the run's phase and the next free number beside it (`ecommerce-baseline-01`, `ecommerce-baseline-02`, `ecommerce-blind_gate-01`). The A/A pair is two runs of the same phase; the post-edit arms are two runs of `blind_gate`. Hand-typed names do not survive one afternoon of runs -- `base`, `rejudged2`, `r3`, `post1` sort wrongly, group not at all, and cannot be matched to an arm. `--label` overrides for a run that genuinely needs a human name. |\n| `effort` | |\n| `maxTurns` / `answererTimeout` *(pins)* | The answerer's turn cap and per-attempt timeout, as the arm ran them. `skill:eval-loop` step 6 lists the call budget among the pins to freeze for a whole run, and nothing wrote one, so no two runs could be compared on the setting that decides whether a case got to finish at all. Distinct from `callBudget`, which no harness has ever written. Size the cap from a pilot rather than taking the default: on a 29-case arm at the default 30, completed attempts had a median of 17 turns and a 90th percentile of 26, and the four cases that ran past it were recorded as wrong answers. |\n| `truncated` / `contaminated` | The qids excluded for a HARNESS-owned reason: the cap cut the answer off, or the attempt breached isolation. Both leave the denominator, both are named here rather than only in the console, and either one makes `status` `incomplete`. |\n| `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every \"cost per arm\" quoted before then was the answerer alone. Per-case judge cost is on each `score` event. |\n| `answererCostCopiedFrom` | The run whose attempts `answererCostUsd` was COPIED from, when this run spawned no answerer -- a `--rejudge`, a `--rebuild`, a `--from`. Those copy the source's attempt events verbatim, `cost_usd` included, so the figure is right about the attempts and wrong as this run's spend: one re-judge reported $17.56 of answering that never happened. Null when this run paid, so a total over several run files can skip the copies. |\n| `goldenCheck` | What `verify_goldens.py` said before the run started: `N ok, M drifted, K other finding(s)`, or why it did not run (no truth server on a platform target; `--skip-golden-check`; rebuild). A run that started on a drifted set says so here rather than pretending its verdicts mean something. `run_baseline` calls `verify()` without a model, so the set-name audit (check 5) does not run in-arm; the trailing `N stale entity name(s)` is the in-arm lint's own count, and `entity-name lint not run` says there was no model text to lint against. Without it the field read `0 other finding(s)` for a check that never happened. |\n| `staleEntityNames` | The `expectedEntities` names that appear nowhere in the served model, from the in-arm lint. Each one depresses the run's own recall on every case listing it, so a stale set reads as a retrieval failure until someone checks by hand. `null` when there was no model text to lint against (a platform target), which is a different fact from `[]`. |\n| `status` | `complete`; `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust; or `incomplete` when the arm ran to the end and still cannot report a rate, because `truncated` or `contaminated` is non-empty. `incomplete` is a third state, not a soft `complete`: the arm did everything asked of it and the number is still unearned. It does NOT stop the run being compared: `flip_table.py` scores the cases both arms did, and names the ones this arm left unscored -- an excluded case carries no verdict, so it can never be counted as a flip. |\n| `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package, so an edit to an imported file moves it where a sha of the one `--model-path` does not; `servedRevision` is minted per load, so it identifies a load rather than content and is a poor pin alone. Measured: `publisher.json`'s `version` moves neither, and nothing in Publisher reads it -- it is not in the Package API schema and never returned, so it pins nothing. |\n| `datasetSha` *(pin)* | Content hash of `set.json` + `cases.jsonl`. Deliberately SEPARATE from the model's sha: a golden repair is not a model change, and one pin covering both would make every answer-key fix read as an edit to the model, which is the distinction an A/B rests on. Automatic, so nobody has to remember it; `datasetVersion` stays beside it as the human-readable sequence. **Local targets only.** A hosted target that publishes IMMUTABLE versions needs none of this: the set rides inside the version, and immutability -- not hashing -- is what makes a pin trustworthy. There, `targetVersion` alone identifies model and set together. |\n| `doubtedGoldens` | The cases whose golden is not believed: `qid`, `gold_status` (`suspect` or `verified_wrong`), `gold_note`, and `declaredBy` (`judge` or `set`). A status the SET declared is a key somebody already settled, not an opinion the judge formed this run, and attributing both to the judge claimed a judgement that never happened. **Read this before diagnose.** These are dataset issues, not model failures, and they go through the golden side door in `skill:eval-loop`. Empty list when no key is in doubt from either source. Written from the same scan that prints the end-of-run warning, because a warning that lives only in console text is one scrollback away from sending a modelling agent at a model that is already right. |\n| `mode` / `setName` / `serverVersion` / `traceMode` / `callBudget` | Defined and accepted, **not yet written by any harness** -- kept in the schema for the platform target and the conductor, which need them. (`targetVersion` and `status` were in this list and are both written now.) |\n\n## Events\n\nEvery line in `events.jsonl` is **flat**: `{ \"kind\": ..., <fields> }`, one\nJSON object per line, with an optional `at` ISO timestamp. Case-scoped kinds\n(`attempt`, `tool_call`, `score`) carry `qid`, `sample`, `phase` on the line;\nrun-level kinds do not. (An earlier draft of this document nested fields under\na `payload` with a `caseId`; no writer ever did that, and 23 run directories\nexist in the flat shape, so the flat shape is the contract.) `kind` is one of:\n`attempt`, `tool_call`, `score`, `retrieval_score`, `issue`, `issue_status`,\n`candidate`, `acceptance_check`, `checkpoint`.\n\n### `attempt`\n\n| Field | Type | Notes |\n|---|---|---|\n| `qid` | string | |\n| `sample` | int or null | Which repeat. Required even when null. |\n| `phase` | string | `baseline` / `loop` / `blind_gate` / `canary` / `final`. `phase` lives here, on the attempt, not in run config. |\n| `question_sha` | string | Hash of the exact text the answerer saw, so two runs can be compared on whether they asked the same thing. A case may carry `questionSha`, the seal stamped at conversion, and that wins when present; the ecommerce set writes a 16-char prefix of it, and a set without one left this null. Neither form proves the question was not edited, since both follow the case as it now reads: that comparison is check 6 of `verify_goldens.py`. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |\n| `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |\n| `servedRevision` | string or null | From the package actually queried. |\n| `n_get_context` / `n_execute` / `n_execute_errors` | int | |\n| `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |\n| `mcp_tool_uses` | int or null | The MCP subset: `get_context` plus `execute_query`. Null on a run written before the split. |\n| `reported_calls` | int | MCP calls the answerer claimed. `reported_calls > host_tool_uses` is the under-report floor from `skill:eval-answer`: the answerer cannot have made more calls than the host logged. `validate_run` warns on it, and only for runs that carry `mcp_tool_uses`, because the field meant something narrower before. |\n| `contaminated` | bool or `\"unknown\"` | `\"unknown\"` when no host log exists. Read it with `ledger.is_contaminated`, never for truthiness: runs written before 2026-09-03 carry the strings `\"true\"`/`\"false\"`, and `bool(\"false\")` is True. `ledger.event` now rejects those strings on write; `validate_run` grandfathers them on read as a warning. |\n| `contamination_reasons` | list | Empty when clean. |\n| `input_tokens` / `output_tokens` / `cache_read_tokens` / `cache_write_tokens` | int or null | Answerer token usage, the four counts that reprice a run from its own ledger. Null when the host does not report it. `cost_usd` beside them comes from the CLI's `total_cost_usd`, which prices cache reads and writes at their rates, so it was always cache-inclusive; what was missing was the breakdown that reproduces it. `cache_write_tokens` (`cache_creation_input_tokens` upstream) was never captured before 2026-09-15, and on one analysed run cache writes were 44% of the agent's cost, the single largest line. **Read `input_tokens` with care:** it counts only the tokens after the last cache breakpoint. One run recorded 224 for 24 questions beside 2.4M cache reads; the 224 is not the context volume, and a token total that omits the cache columns is not a size. |\n| `cost_usd` | float or null | Answerer cost for this attempt. |\n| `num_turns` / `wall_seconds` | int, float or null | |\n| `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |\n| `skills_invoked` | list of string | The skills the answerer invoked through the `Skill` tool, deduplicated and sorted, counted from its own transcript. Always a list on a run that recorded it, so an EMPTY list is the finding: the arm granted a manifest and the agent reached for none of it. Absent on runs written before 2026-09-15, which is why every reader takes `or []`. Only explicit `Skill` calls are counted, and only skills the arm granted -- one outside `--answerer-skills` is a contamination breach and lands in `contamination_reasons` instead. What it is for: a manifest names what an arm GRANTED, and comparing two arms' doctrine is meaningless if the doctrine never loaded. |\n| `transcriptPath` | string | The answerer's transcript under `artifacts/`. |\n\nToken counts sit here rather than being derived later because the claim a\nsemantic model makes is about cost as well as correctness -- that a documented\nmodel reaches a good answer in fewer turns and tokens than working from raw\nschema. A ledger that counts calls but not tokens can state half of that.\n\n### `tool_call`\n\nOne event per MCP `get_context` or `execute_query` the attempt made.\n\n| Field | Type | Notes |\n|---|---|---|\n| `tool` | string | `get_context` or `execute_query`. |\n| `traceId` | string or null | `get_context` only; look up in your host's trace store. |\n| `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |\n| `rankedSummary` | object | Copied at capture so evidence survives trace eviction: `entityIds` (identity, in response order), `hits` (one row per entity: `entity_id`, `source`, `relevance`, `matched_targets`, `position`, `position_in_source`), `resultCount`, `docTokens`. **There is no rank.** This field previously documented `ranks` and \"per-target `targets` with within-target ranks\"; no writer ever wrote either, and what `ranks` did hold was `range(1, n+1)` over a list already flattened source-card by source-card -- a position, not a rank, and not even a relevance order, since the response sorts globally and then buckets into cards. Ask relevance questions of `hits[].relevance`. |\n| `rankedSummary.hits[].matched_targets` | list or null | Which of the caller's search targets matched this entity, each with the server's own relevance. **`null` means no attribution was published**, which is not the same as `[]`: the server attaches it to entities only (a source card's attribution is discarded before an entity object exists) and only on the semantic path, because a lexical score is relative to its own query and comparing two of them means nothing. A lexical run therefore cannot say which target found what, and a reader must report that as unavailable rather than as \"never asked\". |\n| `retrieval_mode` | string or null | `get_context` only: the `retrieval` field of the response that answered, `semantic` or `lexical`. Null when the server named none, which means no embedding provider. Recorded per call because the semantic path can fall over partway through a run. |\n| `query` | string or null | `execute_query` only: the Malloy the call sent, so the final query can be chosen by which call the server actually answered. |\n| `modelPath` | string or null | `execute_query` only: the model file the call named. A source does not resolve outside the file that declares it, so re-execution needs this. |\n| `error` | string or null | |\n\nNever persist `execute_query` result rows, givens, or credentials.\n\n`targets` records the request; `rankedSummary` records the response. Without\nboth, a low per-attempt recall has two readings that cannot be told apart: the\nanswerer searched for the wrong thing, or it searched well and retrieval ranked\nthe right entity too low. Those have opposite owners -- `agent-skill` and\n`retrieval` -- so a ledger holding only the response cannot attribute the\nfailure, and any recall computed from it is a blend of answerer behaviour and\nretrieval quality.\n\nThat blend is also why per-attempt recall is **not** comparable across arms: a\nstronger answerer searches better and scores higher retrieval recall without\nretrieval having changed. Use it for attribution within an arm. Comparing\nretrieval itself between engine versions is `eval-retrieval`'s job, with fixed\nterms, and is not something a customer run reports.\n\n### `score`\n\nThe answer judge's verdict for one attempt (protocol in\n`skill:eval-judge`). Every attempt in a scored run gets exactly one.\n\n| Field | Type | Notes |\n|---|---|---|\n| `verdict` | string or null | `match` / `near_match` / `no_match` / `needs_human`; null when the attempt is not scorable. Only `match` and `no_match` are decisions; see below. |\n| `reason` | string | Why, from the judge; for a null verdict, why not scorable, and it is ALWAYS one of these: `not_submitted`, `answerer_truncated`, `contaminated`, `judge_unparseable`, `no_saved_verdict`, `judge_error: <text>`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. A null verdict never arrives without one: a nulling that does not say which of these it was is indistinguishable from the field being dropped on the write path, and was read as exactly that from a real run. The `golden_*` reasons and `answerer_truncated` are decided before the judge is called, so neither an unestablished key nor a cut-off attempt costs a judge call. |\n| `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |\n| `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |\n| `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |\n| `golden_revision` | int | From the case at score time. |\n| `contaminated` | bool or `\"unknown\"` | Copied from the attempt; true or unknown means `verdict: null`. |\n| `must_not_use_hits` | list or null | Entries of `golden.mustNotUse` a script found in the final query. A hit forces `verdict: no_match`. Decided by `check_must_not_use.py`, never by the judge: it is a question about query text. Only a BARE name vetoes; an entry with a connective (`X as ...`, `X through ...`) objects to a use of X rather than to X, and goes to the judge with the prose entries and a path's bare leaf. |\n| `judge_verdict` | string or null | What the judge said when a MECHANICAL override replaced it: a `must_not_use_hits` veto, or contamination voiding the verdict. Null otherwise, so the judge's own agreement rate stays measurable across both. When a case is vetoed AND contaminated this still holds the judge's read, not the veto's `no_match`. |\n| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `gold_status` | string | `verified` / `verified_benign` / `suspect` / `verified_wrong`, and never anything else: the case file's `golden.status` is a DIFFERENT vocabulary that shares only `verified` and `verified_wrong`, and only those two fall back into this field. A `provisional` / `invalid` / `ambiguous` key is reported through the verdict's `reason` (`golden_<status>`) instead, so nothing downstream has to match on two vocabularies in one field. **From the judge**, which scored against the golden as written and reports separately whether it believes it; falls back to the case's standing `golden.status` when the judge does not say, written onto the verdict itself so the run summary and the aggregates see it too. `verified_wrong` excludes the case from run aggregates -- from the judge OR from the case file; assigning the fallback into a copy of the verdict meant a key the set had marked wrong stayed in the aggregates unless the judge independently agreed. `suspect` and `verified_wrong` route to the golden side door as `dataset` issues, never to improve. |\n| `gold_note` | string or null | The judge's evidence for a non-`verified` status: the two values, or the model line against the rubric sentence. Null when `verified`. |\n\nA `golden_*` reason means the ANSWER KEY is not something a verdict can be\nissued against. A golden the set stamps `provisional`, `invalid` or `ambiguous`\nis refused, as is a case with no golden at all and one the set marks\n`verified_wrong`; a golden with NO `status` is unguarded rather than\nunscorable, because sets that predate the field would otherwise score zero of\nzero. A golden holding no value is not refused either: that is how a\n`kind: unanswerable` case and a `kind: criteria` golden are written. This\nmatters most for an imported set, because `skill:eval-import` stamps\n`provisional` on every golden it writes and enforces it -- so a set is\nunscorable by construction until its keys are re-derived, and `run_baseline`\nrefuses to spend at all when every case in a set is in that state.\n\n`not_submitted` means the attempt produced NOTHING to judge: no answer text and\nno query. An attempt that wrote prose without querying **is judged**, and\nagainst a golden that holds a value an answer containing none of it is\n`no_match` (`skill:eval-judge`, `reference/refusal.md` rule 10). Excusing those\nas unscorable dropped a confident refusal on an answerable case out of the pass\nrate, which is the one thing the answerable-sounds-unanswerable cases exist to\nmeasure. A refusal is exempt only where `golden.kind` is `unanswerable`, and\nthere naming the gap is the pass and a confident number is the fail.\n\n`answerer_truncated` means the answerer hit the run's `maxTurns` cap and the\nCLI returned whatever it had, which is usually a sentence stopping mid-thought.\nIt is refused BEFORE the golden gate and before a saved verdict is reused, so\n`--rebuild` over a run made without this re-derives the right ledger from its\ntranscripts. A cap is a harness setting, so a case cut off by one says nothing\nabout whether the model could answer it: measured on one 29-case arm, four\nattempts ended at exactly 31 turns and each was judged as a wrong answer, one\nof them on the text \"Let me build the correct monthly trend query.\"\n\n`contaminated` means the attempt breached isolation (`contamination_reasons`\nnames how) and the verdict is withheld rather than lost. The judge's own read\nmoves to `judge_verdict`. Before this reason was written, a voided verdict kept\nthe judge's prose in `reason` beside `verdict: null`, and a run report\nreasonably concluded the ledger was dropping the field -- it recovered nine\n\"lost\" verdicts by re-parsing the stored judge replies, every one of which had\nbeen voided on purpose.\n\nBoth are HARNESS-owned, not model-owned, and a run containing either reports\n`status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`\n(no established golden) and `judge_unparseable` do not suppress the rate: the\nfirst is a dataset state no arm can fix, and the second already has a retry.\n\nAggregates count decided verdicts only. `match` and `no_match` are the pass and\nthe fail; **`near_match`, `needs_human` and null are none of the above** and stay\nout of acceptance arithmetic. `near_match` is excluded because it means \"defensibly\ndifferent\", and an arguable verdict that moves a pass rate is a measurement\nartefact rather than a result (`skill:eval-judge` rule 7). Report its count:\nit rising is how a set tells you its rubrics are going vague.\n\nThis applies to `score`. On `retrieval_score` below, `near_match` **is** counted\ntowards recall and precision, because an overlapping entity is a genuine\nretrieval success. The two are different questions that share a word.\n\n### `retrieval_score` (written by `eval-retrieval`, not by a customer run)\n\nOne per term judged in a fixed-term replay (run-level; no `caseId`).\n\n| Field | Type | Notes |\n|---|---|---|\n| `intentId` / `term` / `entityType` | string | From the term file. |\n| `in_scope` | bool | Does THIS model version represent the concept? False is a coverage gap, not a retrieval failure. |\n| `judgments` | list | Per returned entity: `entityId`, `rank`, `level` (`match` / `near_match` / `no_match`), `confidence`, `why`. Empty when nothing returned. |\n| `judge_version` / `rubric_sha` | string | |\n| `traceId` | string or null | The `get_context` call judged. |\n\nRun-level metrics fall out by counting:\n\n- `coverage` = in-scope terms / valid terms -- a property of the model and its\n data, reported on its own, never as a retrieval number.\n- `recall` (on in-scope terms) = fraction whose judgments contain a `match`.\n- `precision@N` = `match` judgments / judged, with N stated.\n\n### `issue`\n\n| Field | Type | Notes |\n|---|---|---|\n| `issue_id` | string | Stable across status events. |\n| `qids` | list | Affected cases. |\n| `primary_code` / `contributing_codes` | string / list | From `skill:eval-diagnose`, verbatim. An event written before the codes were renamed may carry a retired one: `COVERAGE` is now `MISSING`, `NO-DISAMBIG` is `AMBIGUOUS`, and `CONVENTION` is `RULE_UNWRITTEN`. Read those as the new name; nothing rewrites old events. |\n| `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |\n| `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |\n| `severity` / `confidence` | string | |\n| `sufficiency` | string | `sufficient` / `insufficient` / `unknown`. |\n| `traceIds` | list | |\n| `diagnosis` | string | Written before any edit exists. |\n\n### `issue_status`\n\n`issue_id` plus `status`: `open` / `batched` / `fixed` / `rejected` /\n`deferred`. Readers take the latest event for that `issue_id`. Do not invent\na status column.\n\n### `candidate`\n\nWritten by `eval-improve`, for every proposed edit, accepted or not. A\nrejected direction keeps its record.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `files` | Paths the edit touched. |\n| `diffSummary` | One line per file. |\n| `probes` | Query and result for each factual claim. |\n| `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |\n| `goldenSuspect` | Each `{qid, entity, stored, rederived}`: a golden this edit may have invalidated. Reported by the improver, never repaired by it. **Non-empty halts the acceptance check** until adjudicated through the golden side door. |\n| `goldenAudit` | The set's `verify_goldens.py` run against the edited model: `{ran, clean, model, tail}`. Catches drift and rubric-vs-model contradictions only; a golden whose value silently moved is invisible to it, which is why `goldenSuspect` exists alongside. |\n\n### `acceptance_check`\n\nCalled `gate` before 2026-09-03. `ledger.py` reads the old kind as this one, so\nrun directories written earlier still validate and nothing rewrites them.\n\nWritten by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint commit.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `decision` | `accepted` / `rejected`. |\n| `class` | `docs` / `definition` / `retrieval` / `justified`. |\n| `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |\n| `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |\n| `holdoutDelta` | Confident-verdict delta on the holdout slice. |\n| `reason` | Including independent deterministic justification when that is the basis. |\n\n### `checkpoint`\n\nWritten by `eval-loop` after an accepted acceptance check, or when a restore runs. The\nmodel bytes live in git, not in this payload.\n\n| Field | Notes |\n|---|---|\n| `action` | `created` / `restored`. |\n| `label` | |\n| `modelGitSha` | The commit this checkpoint names (create), or the commit restored to. |\n| `issueIds` | Issues the accepted edit closed. Empty on restore. |\n\n## `judge-regressions.jsonl`\n\nAppend a line whenever a human overrules a judge verdict: the case or intent,\nthe judge's verdict, the human's, and why. Re-run this file against the judge\nwhenever `skill:eval-judge` or the judge model changes; a rubric change that\nflips old human-settled verdicts is a judge regression, not new truth."
321610
+ 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 `--model-dir` (or the whole repo, without it) had uncommitted changes -- fine for a band measurement, not for an A/B pin. Null when git cannot answer for the scope (a path outside the repo): absent beats wrong. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\n| `modelDir` | The package directory `--model-dir` named, when it was: the path the `-dirty` marker on `modelGitSha` was decided over. Absent means the whole repo, where any unrelated untracked file marks the model dirty. |\n| `environment` / `package` / `modelPath` | What was served, and from where. On a platform target `environment` is the organization and `package` is the workspace the MCP URL is scoped to. |\n| `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |\n| `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |\n| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |\n| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |\n| `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |\n| `retrievalCalls` | `{semantic, lexical, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |\n| `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart are lexical by design and an arm started immediately measures two retrievers. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |\n| `coverageReport` | The `check_coverage.py --out` report this run read, when `--coverage` named one: its path, the model version it was stamped with, its `agentModel`, and `decided` of `cases`. Coverage is a read of the MODEL, so it is measured separately and costs a judge call per case; the run does not run it, it consumes it. Its per-case verdict then beats the authored `coverage` label in retrieval attribution, and each `score_retrieval` row records `coverage_source` (`measured`, `authored`, `none`) so nobody has to guess which one charged the failure. Absent when no report was given, which is a different fact from a report that found everything covered. |\n| `label` | `<set>-<phase>-<nn>`, assigned by `run_baseline.py` from the set name, the run's phase and the next free number beside it (`ecommerce-baseline-01`, `ecommerce-baseline-02`, `ecommerce-blind_gate-01`). The A/A pair is two runs of the same phase; the post-edit arms are two runs of `blind_gate`. Hand-typed names do not survive one afternoon of runs -- `base`, `rejudged2`, `r3`, `post1` sort wrongly, group not at all, and cannot be matched to an arm. `--label` overrides for a run that genuinely needs a human name. |\n| `effort` | |\n| `maxTurns` / `answererTimeout` *(pins)* | The answerer's turn cap and per-attempt timeout, as the arm ran them. `skill:eval-loop` step 6 lists the call budget among the pins to freeze for a whole run, and nothing wrote one, so no two runs could be compared on the setting that decides whether a case got to finish at all. Distinct from `callBudget`, which no harness has ever written. Size the cap from a pilot rather than taking the default: on a 29-case arm at the default 30, completed attempts had a median of 17 turns and a 90th percentile of 26, and the four cases that ran past it were recorded as wrong answers. |\n| `truncated` / `contaminated` | The qids excluded for a HARNESS-owned reason: the cap cut the answer off, or the attempt breached isolation. Both leave the denominator, both are named here rather than only in the console, and either one makes `status` `incomplete`. |\n| `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every \"cost per arm\" quoted before then was the answerer alone. Per-case judge cost is on each `score` event. |\n| `answererCostCopiedFrom` | The run whose attempts `answererCostUsd` was COPIED from, when this run spawned no answerer -- a `--rejudge`, a `--rebuild`, a `--from`. Those copy the source's attempt events verbatim, `cost_usd` included, so the figure is right about the attempts and wrong as this run's spend: one re-judge reported $17.56 of answering that never happened. Null when this run paid, so a total over several run files can skip the copies. |\n| `goldenCheck` | What `verify_goldens.py` said before the run started: `N ok, M drifted, K other finding(s)`, or why it did not run (no truth server on a platform target; `--skip-golden-check`; rebuild). A run that started on a drifted set says so here rather than pretending its verdicts mean something. `run_baseline` calls `verify()` without a model, so the set-name audit (check 5) does not run in-arm; the trailing `N stale entity name(s)` is the in-arm lint's own count, and `entity-name lint not run` says there was no model text to lint against. Without it the field read `0 other finding(s)` for a check that never happened. |\n| `staleEntityNames` | The `expectedEntities` names that appear nowhere in the served model, from the in-arm lint. Each one depresses the run's own recall on every case listing it, so a stale set reads as a retrieval failure until someone checks by hand. `null` when there was no model text to lint against (a platform target), which is a different fact from `[]`. |\n| `status` | `complete`; `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust; or `incomplete` when the arm ran to the end and still cannot report a rate, because `truncated` or `contaminated` is non-empty. `incomplete` is a third state, not a soft `complete`: the arm did everything asked of it and the number is still unearned. It does NOT stop the run being compared: `flip_table.py` scores the cases both arms did, and names the ones this arm left unscored -- an excluded case carries no verdict, so it can never be counted as a flip. |\n| `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package, so an edit to an imported file moves it where a sha of the one `--model-path` does not; `servedRevision` is minted per load, so it identifies a load rather than content and is a poor pin alone. Measured: `publisher.json`'s `version` moves neither, and nothing in Publisher reads it -- it is not in the Package API schema and never returned, so it pins nothing. |\n| `datasetSha` *(pin)* | Content hash of `set.json` + `cases.jsonl`. Deliberately SEPARATE from the model's sha: a golden repair is not a model change, and one pin covering both would make every answer-key fix read as an edit to the model, which is the distinction an A/B rests on. Automatic, so nobody has to remember it; `datasetVersion` stays beside it as the human-readable sequence. **Local targets only.** A hosted target that publishes IMMUTABLE versions needs none of this: the set rides inside the version, and immutability -- not hashing -- is what makes a pin trustworthy. There, `targetVersion` alone identifies model and set together. |\n| `doubtedGoldens` | The cases whose golden is not believed: `qid`, `gold_status` (`suspect` or `verified_wrong`), `gold_note`, and `declaredBy` (`judge` or `set`). A status the SET declared is a key somebody already settled, not an opinion the judge formed this run, and attributing both to the judge claimed a judgement that never happened. **Read this before diagnose.** These are dataset issues, not model failures, and they go through the golden side door in `skill:eval-loop`. Empty list when no key is in doubt from either source. Written from the same scan that prints the end-of-run warning, because a warning that lives only in console text is one scrollback away from sending a modelling agent at a model that is already right. |\n| `mode` / `setName` / `serverVersion` / `traceMode` / `callBudget` | Defined and accepted, **not yet written by any harness** -- kept in the schema for the platform target and the conductor, which need them. (`targetVersion` and `status` were in this list and are both written now.) |\n\n## Events\n\nEvery line in `events.jsonl` is **flat**: `{ \"kind\": ..., <fields> }`, one\nJSON object per line, with an optional `at` ISO timestamp. Case-scoped kinds\n(`attempt`, `tool_call`, `score`) carry `qid`, `sample`, `phase` on the line;\nrun-level kinds do not. (An earlier draft of this document nested fields under\na `payload` with a `caseId`; no writer ever did that, and 23 run directories\nexist in the flat shape, so the flat shape is the contract.) `kind` is one of:\n`attempt`, `tool_call`, `score`, `retrieval_score`, `issue`, `issue_status`,\n`candidate`, `acceptance_check`, `checkpoint`.\n\n### `attempt`\n\n| Field | Type | Notes |\n|---|---|---|\n| `qid` | string | |\n| `sample` | int or null | Which repeat. Required even when null. |\n| `phase` | string | `baseline` / `loop` / `blind_gate` / `canary` / `final`. `phase` lives here, on the attempt, not in run config. |\n| `question_sha` | string | Hash of the exact text the answerer saw, so two runs can be compared on whether they asked the same thing. A case may carry `questionSha`, the seal stamped at conversion, and that wins when present; the ecommerce set writes a 16-char prefix of it, and a set without one left this null. Neither form proves the question was not edited, since both follow the case as it now reads: that comparison is check 6 of `verify_goldens.py`. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |\n| `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |\n| `servedRevision` | string or null | From the package actually queried. |\n| `n_get_context` / `n_execute` / `n_execute_errors` | int | |\n| `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |\n| `mcp_tool_uses` | int or null | The MCP subset: `get_context` plus `execute_query`. Null on a run written before the split. |\n| `reported_calls` | int | MCP calls the answerer claimed. `reported_calls > host_tool_uses` is the under-report floor from `skill:eval-answer`: the answerer cannot have made more calls than the host logged. `validate_run` warns on it, and only for runs that carry `mcp_tool_uses`, because the field meant something narrower before. |\n| `contaminated` | bool or `\"unknown\"` | `\"unknown\"` when no host log exists. Read it with `ledger.is_contaminated`, never for truthiness: runs written before 2026-09-03 carry the strings `\"true\"`/`\"false\"`, and `bool(\"false\")` is True. `ledger.event` now rejects those strings on write; `validate_run` grandfathers them on read as a warning. |\n| `contamination_reasons` | list | Empty when clean. |\n| `input_tokens` / `output_tokens` / `cache_read_tokens` / `cache_write_tokens` | int or null | Answerer token usage, the four counts that reprice a run from its own ledger. Null when the host does not report it. `cost_usd` beside them comes from the CLI's `total_cost_usd`, which prices cache reads and writes at their rates, so it was always cache-inclusive; what was missing was the breakdown that reproduces it. `cache_write_tokens` (`cache_creation_input_tokens` upstream) was never captured before 2026-09-15, and on one analysed run cache writes were 44% of the agent's cost, the single largest line. **Read `input_tokens` with care:** it counts only the tokens after the last cache breakpoint. One run recorded 224 for 24 questions beside 2.4M cache reads; the 224 is not the context volume, and a token total that omits the cache columns is not a size. |\n| `cost_usd` | float or null | Answerer cost for this attempt. |\n| `num_turns` / `wall_seconds` | int, float or null | |\n| `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |\n| `skills_invoked` | list of string | The skills the answerer invoked through the `Skill` tool, deduplicated and sorted, counted from its own transcript. Always a list on a run that recorded it, so an EMPTY list is the finding: the arm granted a manifest and the agent reached for none of it. Absent on runs written before 2026-09-15, which is why every reader takes `or []`. Only explicit `Skill` calls are counted, and only skills the arm granted -- one outside `--answerer-skills` is a contamination breach and lands in `contamination_reasons` instead. What it is for: a manifest names what an arm GRANTED, and comparing two arms' doctrine is meaningless if the doctrine never loaded. |\n| `transcriptPath` | string | The answerer's transcript under `artifacts/`. |\n\nToken counts sit here rather than being derived later because the claim a\nsemantic model makes is about cost as well as correctness -- that a documented\nmodel reaches a good answer in fewer turns and tokens than working from raw\nschema. A ledger that counts calls but not tokens can state half of that.\n\n### `tool_call`\n\nOne event per MCP `get_context` or `execute_query` the attempt made.\n\n| Field | Type | Notes |\n|---|---|---|\n| `tool` | string | `get_context` or `execute_query`. |\n| `traceId` | string or null | `get_context` only; look up in your host's trace store. |\n| `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |\n| `rankedSummary` | object | Copied at capture so evidence survives trace eviction: `entityIds` (identity, in response order), `hits` (one row per entity: `entity_id`, `source`, `relevance`, `matched_targets`, `position`, `position_in_source`), `resultCount`, `docTokens`. **There is no rank.** This field previously documented `ranks` and \"per-target `targets` with within-target ranks\"; no writer ever wrote either, and what `ranks` did hold was `range(1, n+1)` over a list already flattened source-card by source-card -- a position, not a rank, and not even a relevance order, since the response sorts globally and then buckets into cards. Ask relevance questions of `hits[].relevance`. |\n| `rankedSummary.hits[].matched_targets` | list or null | Which of the caller's search targets matched this entity, each with the server's own relevance. **`null` means no attribution was published**, which is not the same as `[]`: the server attaches it to entities only (a source card's attribution is discarded before an entity object exists) and only on the semantic path, because a lexical score is relative to its own query and comparing two of them means nothing. A lexical run therefore cannot say which target found what, and a reader must report that as unavailable rather than as \"never asked\". |\n| `retrieval_mode` | string or null | `get_context` only: the `retrieval` field of the response that answered, `semantic` or `lexical`. Null when the server named none, which means no embedding provider. Recorded per call because the semantic path can fall over partway through a run. |\n| `query` | string or null | `execute_query` only: the Malloy the call sent, so the final query can be chosen by which call the server actually answered. |\n| `modelPath` | string or null | `execute_query` only: the model file the call named. A source does not resolve outside the file that declares it, so re-execution needs this. |\n| `error` | string or null | |\n\nNever persist `execute_query` result rows, givens, or credentials.\n\n`targets` records the request; `rankedSummary` records the response. Without\nboth, a low per-attempt recall has two readings that cannot be told apart: the\nanswerer searched for the wrong thing, or it searched well and retrieval ranked\nthe right entity too low. Those have opposite owners -- `agent-skill` and\n`retrieval` -- so a ledger holding only the response cannot attribute the\nfailure, and any recall computed from it is a blend of answerer behaviour and\nretrieval quality.\n\nThat blend is also why per-attempt recall is **not** comparable across arms: a\nstronger answerer searches better and scores higher retrieval recall without\nretrieval having changed. Use it for attribution within an arm. Comparing\nretrieval itself between engine versions is `eval-retrieval`'s job, with fixed\nterms, and is not something a customer run reports.\n\n### `score`\n\nThe answer judge's verdict for one attempt (protocol in\n`skill:eval-judge`). Every attempt in a scored run gets exactly one.\n\n| Field | Type | Notes |\n|---|---|---|\n| `verdict` | string or null | `match` / `near_match` / `no_match` / `needs_human`; null when the attempt is not scorable. Only `match` and `no_match` are decisions; see below. |\n| `reason` | string | Why, from the judge; for a null verdict, why not scorable, and it is ALWAYS one of these: `not_submitted`, `answerer_truncated`, `contaminated`, `judge_unparseable`, `no_saved_verdict`, `judge_error: <text>`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. A null verdict never arrives without one: a nulling that does not say which of these it was is indistinguishable from the field being dropped on the write path, and was read as exactly that from a real run. The `golden_*` reasons and `answerer_truncated` are decided before the judge is called, so neither an unestablished key nor a cut-off attempt costs a judge call. |\n| `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |\n| `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |\n| `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |\n| `golden_revision` | int | From the case at score time. |\n| `contaminated` | bool or `\"unknown\"` | Copied from the attempt; true or unknown means `verdict: null`. |\n| `must_not_use_hits` | list or null | Entries of `golden.mustNotUse` a script found in the final query. A hit forces `verdict: no_match`. Decided by `check_must_not_use.py`, never by the judge: it is a question about query text. Only a BARE name vetoes; an entry with a connective (`X as ...`, `X through ...`) objects to a use of X rather than to X, and goes to the judge with the prose entries and a path's bare leaf. |\n| `judge_verdict` | string or null | What the judge said when a MECHANICAL override replaced it: a `must_not_use_hits` veto, or contamination voiding the verdict. Null otherwise, so the judge's own agreement rate stays measurable across both. When a case is vetoed AND contaminated this still holds the judge's read, not the veto's `no_match`. |\n| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `gold_status` | string | `verified` / `verified_benign` / `suspect` / `verified_wrong`, and never anything else: the case file's `golden.status` is a DIFFERENT vocabulary that shares only `verified` and `verified_wrong`, and only those two fall back into this field. A `provisional` / `invalid` / `ambiguous` key is reported through the verdict's `reason` (`golden_<status>`) instead, so nothing downstream has to match on two vocabularies in one field. **From the judge**, which scored against the golden as written and reports separately whether it believes it; falls back to the case's standing `golden.status` when the judge does not say, written onto the verdict itself so the run summary and the aggregates see it too. `verified_wrong` excludes the case from run aggregates -- from the judge OR from the case file; assigning the fallback into a copy of the verdict meant a key the set had marked wrong stayed in the aggregates unless the judge independently agreed. `suspect` and `verified_wrong` route to the golden side door as `dataset` issues, never to improve. |\n| `gold_note` | string or null | The judge's evidence for a non-`verified` status: the two values, or the model line against the rubric sentence. Null when `verified`. |\n\nA `golden_*` reason means the ANSWER KEY is not something a verdict can be\nissued against. A golden the set stamps `provisional`, `invalid` or `ambiguous`\nis refused, as is a case with no golden at all and one the set marks\n`verified_wrong`; a golden with NO `status` is unguarded rather than\nunscorable, because sets that predate the field would otherwise score zero of\nzero. A golden holding no value is not refused either: that is how a\n`kind: unanswerable` case and a `kind: criteria` golden are written. This\nmatters most for an imported set, because `skill:eval-import` stamps\n`provisional` on every golden it writes and enforces it -- so a set is\nunscorable by construction until its keys are re-derived, and `run_baseline`\nrefuses to spend at all when every case in a set is in that state.\n\n`not_submitted` means the attempt produced NOTHING to judge: no answer text and\nno query. An attempt that wrote prose without querying **is judged**, and\nagainst a golden that holds a value an answer containing none of it is\n`no_match` (`skill:eval-judge`, `reference/refusal.md` rule 10). Excusing those\nas unscorable dropped a confident refusal on an answerable case out of the pass\nrate, which is the one thing the answerable-sounds-unanswerable cases exist to\nmeasure. A refusal is exempt only where `golden.kind` is `unanswerable`, and\nthere naming the gap is the pass and a confident number is the fail.\n\n`answerer_truncated` means the answerer hit the run's `maxTurns` cap and the\nCLI returned whatever it had, which is usually a sentence stopping mid-thought.\nIt is refused BEFORE the golden gate and before a saved verdict is reused, so\n`--rebuild` over a run made without this re-derives the right ledger from its\ntranscripts. A cap is a harness setting, so a case cut off by one says nothing\nabout whether the model could answer it: measured on one 29-case arm, four\nattempts ended at exactly 31 turns and each was judged as a wrong answer, one\nof them on the text \"Let me build the correct monthly trend query.\"\n\n`contaminated` means the attempt breached isolation (`contamination_reasons`\nnames how) and the verdict is withheld rather than lost. The judge's own read\nmoves to `judge_verdict`. Before this reason was written, a voided verdict kept\nthe judge's prose in `reason` beside `verdict: null`, and a run report\nreasonably concluded the ledger was dropping the field -- it recovered nine\n\"lost\" verdicts by re-parsing the stored judge replies, every one of which had\nbeen voided on purpose.\n\nBoth are HARNESS-owned, not model-owned, and a run containing either reports\n`status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`\n(no established golden) and `judge_unparseable` do not suppress the rate: the\nfirst is a dataset state no arm can fix, and the second already has a retry.\n\nAggregates count decided verdicts only. `match` and `no_match` are the pass and\nthe fail; **`near_match`, `needs_human` and null are none of the above** and stay\nout of acceptance arithmetic. `near_match` is excluded because it means \"defensibly\ndifferent\", and an arguable verdict that moves a pass rate is a measurement\nartefact rather than a result (`skill:eval-judge` rule 7). Report its count:\nit rising is how a set tells you its rubrics are going vague.\n\nThis applies to `score`. On `retrieval_score` below, `near_match` **is** counted\ntowards recall and precision, because an overlapping entity is a genuine\nretrieval success. The two are different questions that share a word.\n\n### `retrieval_score` (written by `eval-retrieval`, not by a customer run)\n\nOne per term judged in a fixed-term replay (run-level; no `caseId`).\n\n| Field | Type | Notes |\n|---|---|---|\n| `intentId` / `term` / `entityType` | string | From the term file. |\n| `in_scope` | bool | Does THIS model version represent the concept? False is a coverage gap, not a retrieval failure. |\n| `judgments` | list | Per returned entity: `entityId`, `rank`, `level` (`match` / `near_match` / `no_match`), `confidence`, `why`. Empty when nothing returned. |\n| `judge_version` / `rubric_sha` | string | |\n| `traceId` | string or null | The `get_context` call judged. |\n\nRun-level metrics fall out by counting:\n\n- `coverage` = in-scope terms / valid terms -- a property of the model and its\n data, reported on its own, never as a retrieval number.\n- `recall` (on in-scope terms) = fraction whose judgments contain a `match`.\n- `precision@N` = `match` judgments / judged, with N stated.\n\n### `issue`\n\n| Field | Type | Notes |\n|---|---|---|\n| `issue_id` | string | Stable across status events. |\n| `qids` | list | Affected cases. |\n| `primary_code` / `contributing_codes` | string / list | From `skill:eval-diagnose`, verbatim. An event written before the codes were renamed may carry a retired one: `COVERAGE` is now `MISSING`, `NO-DISAMBIG` is `AMBIGUOUS`, and `CONVENTION` is `RULE_UNWRITTEN`. Read those as the new name; nothing rewrites old events. |\n| `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |\n| `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |\n| `severity` / `confidence` | string | |\n| `sufficiency` | string | `sufficient` / `insufficient` / `unknown`. |\n| `traceIds` | list | |\n| `diagnosis` | string | Written before any edit exists. |\n\n### `issue_status`\n\n`issue_id` plus `status`: `open` / `batched` / `fixed` / `rejected` /\n`deferred`. Readers take the latest event for that `issue_id`. Do not invent\na status column.\n\n### `candidate`\n\nWritten by `eval-improve`, for every proposed edit, accepted or not. A\nrejected direction keeps its record.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `files` | Paths the edit touched. |\n| `diffSummary` | One line per file. |\n| `probes` | Query and result for each factual claim. |\n| `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |\n| `goldenSuspect` | Each `{qid, entity, stored, rederived}`: a golden this edit may have invalidated. Reported by the improver, never repaired by it. **Non-empty halts the acceptance check** until adjudicated through the golden side door. |\n| `goldenAudit` | The set's `verify_goldens.py` run against the edited model: `{ran, clean, model, tail}`. Catches drift and rubric-vs-model contradictions only; a golden whose value silently moved is invisible to it, which is why `goldenSuspect` exists alongside. |\n\n### `acceptance_check`\n\nCalled `gate` before 2026-09-03. `ledger.py` reads the old kind as this one, so\nrun directories written earlier still validate and nothing rewrites them.\n\nWritten by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint commit.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `decision` | `accepted` / `rejected`. |\n| `class` | `docs` / `definition` / `retrieval` / `justified`. |\n| `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |\n| `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |\n| `holdoutDelta` | Confident-verdict delta on the holdout slice. |\n| `reason` | Including independent deterministic justification when that is the basis. |\n\n### `checkpoint`\n\nWritten by `eval-loop` after an accepted acceptance check, or when a restore runs. The\nmodel bytes live in git, not in this payload.\n\n| Field | Notes |\n|---|---|\n| `action` | `created` / `restored`. |\n| `label` | |\n| `modelGitSha` | The commit this checkpoint names (create), or the commit restored to. |\n| `issueIds` | Issues the accepted edit closed. Empty on restore. |\n\n## `judge-regressions.jsonl`\n\nAppend a line whenever a human overrules a judge verdict: the case or intent,\nthe judge's verdict, the human's, and why. Re-run this file against the judge\nwhenever `skill:eval-judge` or the judge model changes; a rubric change that\nflips old human-settled verdicts is a judge regression, not new truth."
321496
321611
  },
321497
321612
  {
321498
321613
  name: "eval-diagnose",
@@ -324671,10 +324786,14 @@ python3 skills/eval-loop/scripts/run_baseline.py \\
324671
324786
  python3 skills/eval-loop/scripts/run_baseline.py \\
324672
324787
  --set <repo>/evals/ecommerce --out results/<arm> \\
324673
324788
  --parallel 4 --truth-publisher http://localhost:4881 \\
324674
- --model-repo <repo> # the checkout the MODEL is versioned in, recorded as
324675
- # modelGitSha. It cannot be inferred: Publisher serves
324676
- # a copy under publisher_data/, whose surrounding tree
324677
- # is the server's storage, not the model's history.
324789
+ --model-repo <repo> --model-dir <package> # the checkout the MODEL is
324790
+ # versioned in, recorded as modelGitSha, and the package
324791
+ # directory inside it the -dirty marker is decided over
324792
+ # (relative to the repo). Without --model-dir any
324793
+ # untracked file anywhere in the repo marks the model
324794
+ # dirty. The repo cannot be inferred: Publisher serves a
324795
+ # copy under publisher_data/, whose surrounding tree is
324796
+ # the server's storage, not the model's history.
324678
324797
  # the run names itself <set>-<phase>-<nn> (ecommerce-baseline-01, then -02
324679
324798
  # for the second arm of the A/A). Pass --label only for a run that needs a
324680
324799
  # human name; hand-typed arm names stop being readable within an afternoon.
@@ -325298,6 +325417,7 @@ This table is a catalogue of what exists, not of what is loaded. A host that ins
325298
325417
  |-------|-------------|
325299
325418
  | \`skill:malloy-publish\` | Moving a finished model into a served package (local-to-served handoff) |
325300
325419
  | \`skill:malloy-dashboards\` | Building a dashboard: a tagged \`.malloy\` file in a package's \`dashboards/\` directory, with filter controls and drill-through |
325420
+ | \`skill:malloy-data-app-design\` | Deciding what a data app should be, before building it: audience, archetype, chart forms and depth |
325301
325421
  | \`skill:malloy-html-data-apps\` | Building an in-package HTML data app (a \`public/\` directory the package serves) |
325302
325422
  | \`skill:malloy-html-data-app-runtime\` | Writing the JavaScript that drives that app |
325303
325423
  | \`skill:malloy-html-data-app-embedding\` | Embedding a served page into a host application |
@@ -325706,7 +325826,7 @@ Publishing is out of scope for now: open-source Publisher serves the model from
325706
325826
  {
325707
325827
  name: "malloy-charts",
325708
325828
  description: "Chart selection for a Malloy view, plus the renderer reference for bar_chart, line_chart, scatter_chart, maps and layouts. For choosing a visualization or adding a chart annotation.",
325709
- body: '# Chart Selection for Malloy\n\n> Malloy uses Vega-Lite under the hood. `#` tags control visualization. Call `search_malloy_docs` with topic "rendering" for the full tag reference (or see https://docs.malloydata.dev/documentation/visualizations/overview).\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Decision Tree: Which Chart?\n\n| Data Shape | Default Choice |\n|-----------|---------------|\n| Aggregates only (no group_by) | `# big_value` |\n| 1 time column + 1 measure | `# line_chart` |\n| 1 category + 1 measure | `# bar_chart` |\n| 2 numeric columns | `# scatter_chart` |\n| Geographic (US states) + 1 measure | `# shape_map` |\n| Route data (lat/lon pairs) | `# segment_map` |\n| Multiple perspectives | `# dashboard` with `nest:` |\n| Nested query to pivot | `# pivot` |\n| Filtered aggregates side-by-side | `# flatten` |\n| Detailed rows | Default table (no annotation) |\n\n| Goal | Renderer |\n|------|---------|\n| Compare categories | `# bar_chart` (sort by value, limit ~15) |\n| Show composition | `# bar_chart.stack` |\n| Trend over time | `# line_chart` |\n| Highlight KPIs | `# big_value` with `# label` |\n| Correlation | `# scatter_chart` |\n| Compare dimensions | `# dashboard` (nest chart views) |\n| Before/after | `# transpose` or `# pivot` |\n| Multiple metrics per category | Default table, `# flatten`, or `y=[\'a\',\'b\']` |\n\n**Constraints:**\n- ONE aggregate per chart view (charts render only the first; use `y=[\'a\',\'b\']` for multi-measure)\n- No fixed scale on measure definitions: use `# currency` not `# currency=usd0m`\n- One tag per line\n- Alias joined fields in `group_by` before `order_by`\n- Define measures in source, not in views\n\n\n## Chart Types\n\n### `# bar_chart`\n\n**Data shape:** `group_by` = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# bar_chart\nview: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 }\n\n# bar_chart.stack\nview: by_region is { group_by: category, region, aggregate: revenue }\n\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: category, aggregate: revenue, cost }\n```\n\n**Key properties:** `.stack`, `.size` (spark/xs/sm/md/lg/xl/2xl), `.x`, `.x.limit`, `.y` (supports `y=[\'a\',\'b\']`), `.series`, `.series.limit` (default 20), `.title`, `.subtitle`, `.x.independent`, `.y.independent`\n\n**Field role tags:** `# x`, `# y`, `# series` on individual fields to assign roles explicitly.\n\n### `# line_chart`\n\n**Data shape:** `group_by` (temporal/numeric) = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# line_chart\nview: trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n\n# line_chart { size=spark }\nview: mini_trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n```\n\n**Key properties:** `.zero_baseline`, `.interpolate` (e.g., `step`), `.size`, `.y` (supports `y=[\'a\',\'b\']`), `.series.limit` (default 12), `.title`, `.subtitle`\n\n### `# scatter_chart`\n\n**Data shape:** Fields by position: x, y, color, size (bubble), shape.\n\n```malloy\n# scatter_chart\nview: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity }\n```\n\n### `# shape_map`\n\nChoropleth. US states only. Fields: state name, value.\n\n```malloy\n# shape_map\nview: by_state is { group_by: state, aggregate: revenue }\n```\n\n### `# segment_map`\n\nRoute map. US only. Fields: start_lat, start_lon, end_lat, end_lon, color.\n\n\n## Layout Types\n\n### `# big_value`\n\nKPI cards. Aggregates only, no `group_by`.\n\n```malloy\n# big_value\nview: summary is {\n aggregate:\n # label="Revenue"\n # currency\n revenue\n # label="Orders"\n # number=auto\n order_count\n}\n```\n\n**Properties:** `.size`, `.sparkline=<nested_view_name>`, `.comparison_field`, `.comparison_label`, `.down_is_good`\n\n### `# dashboard`\n\nCard-based multi-tile layout. Apply to a view whose body is a nested query; the view\'s own fields lay out automatically:\n\n- `group_by` dimensions -> a row header (repeats once per row; omit for a single block)\n- `aggregate` measures -> KPI cards, one per measure\n- each `nest:` -> a tile, rendered by the tag above it (`# table` default, or `# bar_chart` / `# line_chart` / `# big_value`)\n\n**Two modes.** Flex (default): tiles flow and wrap; `# break` forces a new row. Columns: `# dashboard { columns=N }` lays tiles into N equal columns, `# colspan=n` widens a tile, `# break` starts a new row, overflow wraps.\n\n```malloy\n// Flex: measures become KPI cards, the nest becomes a tile\n# dashboard\nview: overview is {\n group_by: category\n # currency\n aggregate:\n avg_retail is retail_price.avg()\n sum_retail is retail_price.sum()\n nest:\n # bar_chart\n by_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 }\n}\n\n// Columns: # colspan widens tiles, # break ends a row\n# dashboard { columns=12 }\nview: layout is {\n group_by: category\n # currency\n aggregate:\n # colspan=4\n avg_retail is retail_price.avg()\n # colspan=4\n sum_retail is retail_price.sum()\n # colspan=4\n max_retail is retail_price.max()\n nest:\n # break\n # colspan=6\n # bar_chart\n # subtitle="Top brands"\n by_brand_chart is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 8 }\n # colspan=6\n by_brand_table is { group_by: brand, aggregate: product_count is count(), limit: 8 }\n}\n```\n\n**Tags:** `# dashboard { columns=N }` (columns mode), `{ gap=PX }` (tile spacing, default 16; never a mode), `{ table.max_height=PX|none }` (cap table tiles). On a measure or nest: `# colspan=N` (columns mode only), `# break` (both modes), `# subtitle="..."` (tile), `# borderless` (drop card chrome), `# label="..."` (card title).\n\nFor rich KPI cards (sparklines, comparison deltas, several metrics on one card) nest a `# big_value` view instead of relying on the dashboard\'s own measures:\n\n```malloy\n# dashboard\nview: kpis is {\n group_by: category\n nest:\n # big_value\n revenue_card is {\n aggregate:\n # label="Revenue"\n # currency\n # big_value { sparkline=trend }\n total_revenue is retail_price.sum()\n # line_chart { size=spark y.independent=true }\n # hidden\n nest: trend is { group_by: bucket is floor(id / 100)::number, aggregate: total_revenue is retail_price.sum(), order_by: bucket, limit: 20 }\n }\n}\n```\n\n**Rules:** `# dashboard` needs a nested-query view (no effect on a scalar). `# colspan` works only in columns mode and is ignored (warns) in flex. `columns` is any positive integer; a `# colspan` over the column count clamps to a full row. Style tiles via the instance theme, or theme the views inside with `# theme.*` (see Theming below).\n\n### `# pivot`\n\nPivot nested results into columns. Max 30 pivot columns.\n\n```malloy\nview: sales is {\n group_by: product, aggregate: total\n nest: # pivot\n by_quarter is { group_by: quarter, aggregate: revenue }\n}\n```\n\n### `# transpose`\n\nSwap rows/columns. Good for period comparisons.\n\n```malloy\n# transpose\nview: comparison is {\n aggregate:\n # label="This Month"\n current_revenue\n # label="Last Month"\n prior_revenue\n}\n```\n\n### `# list` / `# list_detail`\n\nList renders as comma-separated values. List_detail shows `value (detail)` pairs.\n\n### `# flatten`\n\nCollapse nested record into parent table as columns. Use for side-by-side filtered aggregates:\n\n```malloy\nview: segments is {\n group_by: product, aggregate: total_revenue\n nest: # flatten\n enterprise is { where: segment = \'Enterprise\', aggregate: # label="Enterprise" revenue }\n nest: # flatten\n smb is { where: segment = \'SMB\', aggregate: # label="SMB" revenue }\n}\n```\n\n### `# table`\n\nDefault (implicit). Use explicitly for `.size=fill` property.\n\n\n## Field Formatting Tags\n\n| Tag | Use For | Shorthand |\n|-----|---------|-----------|\n| `# number` | Numeric formatting | `=auto` (K/M/B), `=id` (no commas), `=1k`, `=1m` |\n| `# percent` | Percentages | (none needed) |\n| `# currency` | Money | `=usd2m` (USD, 2 decimals, millions); scale only in views |\n| `# duration` | Time durations | `=seconds`, `=minutes`, `=hours`, `=days` |\n| `# data_volume` | Storage sizes | `=bytes`, `=kb`, `=mb`, `=gb` |\n| `# link` | Hyperlinks | `.url_template="https://example.com/$$"` |\n| `# image` | Inline images | `.height=40px`, `.width=100px` |\n\n**Currency codes:** `usd` ($), `eur`, `gbp`. **Scale:** K/M/B/T/Q or `auto`.\n**Number suffix styles:** `word` ("42.5 million"), `letter` ("42.5M"), `scientific`.\n\n## Utility Tags\n\n| Tag | Purpose |\n|-----|---------|\n| `# hidden` | Hide from output (still usable for sorting/references) |\n| `# label="..."` | Override display name |\n| `# description="..."` | Tooltip text |\n| `# tooltip` | Include nested view in chart tooltip |\n| `# break` | Force new dashboard row |\n| `# column { width=sm }` | Table column width |\n\n## Model-Level Defaults\n\n```malloy\n## viz.line_chart.defaults.y.independent=true\n## viz.bar_chart.defaults.stack\n```\n\n## Theming\n\nPublisher styles charts and tables from one structured theme. The instance sets it (in `publisher.config.json`\'s `theme` block or the **Settings, then Theme** editor); a model overrides it per result with `# theme.*` annotations, or model-wide with `## theme.*`. Per-chart annotations use the same nested `palette.*` / `font.*` vocabulary as the config, not flat key names, and they win over the instance theme for the keys they set. The forms:\n\n| Annotation | Controls | Modes |\n|-----------|----------|-------|\n| `# theme.palette.series` | Categorical series colors (array) | shared |\n| `# theme.palette.background.{light,dark}` | Chart canvas + table background | per-mode |\n| `# theme.palette.tableHeader.{light,dark}` | Table header text color | per-mode |\n| `# theme.palette.tableHeaderBackground.{light,dark}` | Table header row background | per-mode |\n| `# theme.palette.tableBody.{light,dark}` | Table body text color | per-mode |\n| `# theme.palette.tile.{light,dark}` | Dashboard tile background | per-mode |\n| `# theme.palette.tileTitle.{light,dark}` | Dashboard tile title color | per-mode |\n| `# theme.palette.mapColor.{light,dark}` | Choropleth gradient (`# shape_map` / `# segment_map`) | per-mode |\n| `# theme.font.family` | Font for all rendered text | shared |\n| `# theme.font.size` | Table font size (px) | shared |\n\nThe seven `palette.*` color keys each take a `.light` and/or `.dark` variant so dark mode gets its own value. `palette.series`, `font.family`, and `font.size` are single values shared across modes.\n\n```malloy\n// Model-wide defaults (## applies to every view in the model):\n## theme.palette.series = ["#14b3cb", "#e47404", "#1474a4"]\n## theme.font.family = "Inter, sans-serif"\n\n// Per-view override (# applies to this result only; beats the instance theme):\n# theme.palette.background.light = "#fafafa"\n# theme.palette.background.dark = "#111111"\n# theme.palette.tableHeader.dark = "#94a3b8"\nview: revenue_by_month is {\n group_by: month\n aggregate: revenue\n}\n```\n\n**Precedence**, highest to lowest, per key: `# theme.*` on the view, then `## theme.*` model default, then the instance theme, then Publisher\'s built-in defaults. A per-chart annotation overrides the instance for the keys it sets; unset keys fall through to the instance. (This is the reverse of a bare `@malloydata/render` embed, where the embedder wins: Publisher reads the annotation itself and layers it on top.)\n\nQuote values that contain spaces or a leading `#`. The light/dark default (`defaultMode`) and the toggle lock (`allowUserToggle`) are instance-only: set them in the config `theme` block or the editor, not as annotations. The malloy-gotchas-rendering skill lists the annotation forms that look valid but do nothing.\n\n\n## Advanced Patterns\n\n### Sparklines in KPI Cards\n\n```malloy\n# big_value { sparkline=trend }\nview: revenue_kpi is {\n aggregate: # label="Revenue" # currency revenue\n nest: # line_chart { size=spark } # hidden\n trend is { group_by: order_date, aggregate: revenue, order_by: order_date }\n}\n```\n\n### KPIs with Comparison Deltas\n\n```malloy\n# big_value { comparison_field=prior_month comparison_label="vs Last Month" }\nview: rev_delta is {\n aggregate: # label="Revenue" # currency revenue, # hidden prior_month\n}\n```\n\nUse `down_is_good=true` for metrics where decrease is positive (churn, defects).\n\n### Inline Mini-Charts in Table Rows\n\n```malloy\nview: carriers is {\n group_by: carrier, aggregate: flight_count\n nest: # line_chart { size=spark }\n trend is { group_by: month, aggregate: flight_count, order_by: month }\n}\n```\n\n### Multi-Measure Series\n\n```malloy\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost }\n```\n\n### Hierarchical Drill-Down\n\n```malloy\n# list_detail\nview: explorer is {\n group_by: region, aggregate: revenue\n nest: # bar_chart\n by_category is { group_by: category, aggregate: revenue, order_by: revenue desc, limit: 10 }\n}\n```\n\n### Distribution (Histogram)\n\nThere is no auto-binning function: `autobin(...)` does not exist and fails with `Unknown function \'autobin\'`. Bin by arithmetic, choosing the width from the column\'s actual range: query `min`, `max` and a few percentiles first, and say in the view\'s doc where the width came from. A bin width nobody derived is a business decision in disguise.\n\n```malloy\n# bar_chart\nview: price_dist is {\n group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed range\n aggregate: order_count is count()\n order_by: bucket\n}\n```\n\n\n## Patterns for Missing Chart Types\n\n| Desired | Malloy Approximation |\n|---------|---------------------|\n| Pie/donut | `# bar_chart` sorted by value |\n| Treemap | Nested table with `order_by: desc` |\n| Heatmap | `# pivot` with color values |\n| Stacked area | `# line_chart` with series (overlaid lines) |\n| Funnel | `# bar_chart` with ordered stages |\n| Gauge/bullet | `# big_value` with `.comparison_field` |\n\n\n## Chart Annotations on Queries with `nest:`\n\nA top-level chart tag (e.g., `# bar_chart`) renders only the outer query; any `nest:` views are silently hidden from the rendering (still in raw data). To show nests, use `# dashboard` on the outer query with chart tags on each nest. Otherwise, drop the `nest:`.\n\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Two aggregates in chart | ONE aggregate, or use `y=[\'a\',\'b\']` |\n| `# currency=usd0m` on measure | `# currency` (no scale) on defs; scale only in views |\n| Chart annotation on `nest:` line | Put on the **view definition** |\n| Tags on same line | One tag per line |\n| Sparkline not showing | Add `# hidden` to nested view AND reference in `.sparkline=` |\n| Pivot > 30 columns | Filter/limit the nested group_by |\n\nNOTE: The term \'constructor\' is a reserved term in Vega-Lite. If the word \'constructor\' appears in the query, it will cause the rendering to fail. Never use it in a query and avoid using it as a dimension in a model.\n\nFor more patterns, call `search_malloy_docs` with topics like "bar charts", "line charts", "dashboards", "histograms", "percent of total", "comparing timeframes", or "pivots".\n\n## Further Reading\n\n- [Visualizations Overview](https://docs.malloydata.dev/documentation/visualizations/overview) - Official docs\n- [Bar Charts](https://docs.malloydata.dev/documentation/visualizations/bar_charts) - Stacked, grouped, series\n- [Bump Charts Blog](https://docs.malloydata.dev/blog/2023-10-26-malloy-bump-chart/) - Ranking over time\n- [Dataviz is Hierarchical](https://docs.malloydata.dev/blog/2024-02-29-hierarchical-viz/) - Nested data visualization philosophy'
325829
+ body: '# Chart Selection for Malloy\n\n> Malloy uses Vega-Lite under the hood. `#` tags control visualization. Call `search_malloy_docs` with topic "rendering" for the full tag reference (or see https://docs.malloydata.dev/documentation/visualizations/overview).\n\n> **This file is about the Malloy renderer\'s `#` tags.** It applies when the view itself is rendered - a notebook, a dashboard, an explore result. It does **not** apply to an app that draws its own charts with a vendored chart library: there the tag vocabulary is irrelevant and the full form vocabulary is available, so the approximations below (a funnel as a bar chart, a treemap as a nested table) are the wrong advice. Design to whichever vocabulary the surface actually uses.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Decision Tree: Which Chart?\n\n| Data Shape | Default Choice |\n|-----------|---------------|\n| Aggregates only (no group_by) | `# big_value` |\n| 1 time column + 1 measure | `# line_chart` |\n| 1 category + 1 measure | `# bar_chart` |\n| 2 numeric columns | `# scatter_chart` |\n| Geographic (US states) + 1 measure | `# shape_map` |\n| Route data (lat/lon pairs) | `# segment_map` |\n| Multiple perspectives | `# dashboard` with `nest:` |\n| Nested query to pivot | `# pivot` |\n| Filtered aggregates side-by-side | `# flatten` |\n| Detailed rows | Default table (no annotation) |\n\n| Goal | Renderer |\n|------|---------|\n| Compare categories | `# bar_chart` (sort by value, limit ~15) |\n| Show composition | `# bar_chart.stack` |\n| Trend over time | `# line_chart` |\n| Highlight KPIs | `# big_value` with `# label` |\n| Correlation | `# scatter_chart` |\n| Compare dimensions | `# dashboard` (nest chart views) |\n| Before/after | `# transpose` or `# pivot` |\n| Multiple metrics per category | Default table, `# flatten`, or `y=[\'a\',\'b\']` |\n\n**Constraints:**\n- ONE aggregate per chart view (charts render only the first; use `y=[\'a\',\'b\']` for multi-measure)\n- No fixed scale on measure definitions: use `# currency` not `# currency=usd0m`\n- One tag per line\n- Alias joined fields in `group_by` before `order_by`\n- Define measures in source, not in views\n\n\n## Chart Types\n\n### `# bar_chart`\n\n**Data shape:** `group_by` = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# bar_chart\nview: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 }\n\n# bar_chart.stack\nview: by_region is { group_by: category, region, aggregate: revenue }\n\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: category, aggregate: revenue, cost }\n```\n\n**Key properties:** `.stack`, `.size` (spark/xs/sm/md/lg/xl/2xl), `.x`, `.x.limit`, `.y` (supports `y=[\'a\',\'b\']`), `.series`, `.series.limit` (default 20), `.title`, `.subtitle`, `.x.independent`, `.y.independent`\n\n**Field role tags:** `# x`, `# y`, `# series` on individual fields to assign roles explicitly.\n\n### `# line_chart`\n\n**Data shape:** `group_by` (temporal/numeric) = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# line_chart\nview: trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n\n# line_chart { size=spark }\nview: mini_trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n```\n\n**Key properties:** `.zero_baseline`, `.interpolate` (e.g., `step`), `.size`, `.y` (supports `y=[\'a\',\'b\']`), `.series.limit` (default 12), `.title`, `.subtitle`\n\n### `# scatter_chart`\n\n**Data shape:** Fields by position: x, y, color, size (bubble), shape.\n\n```malloy\n# scatter_chart\nview: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity }\n```\n\n### `# shape_map`\n\nChoropleth. US states only. Fields: state name, value.\n\n```malloy\n# shape_map\nview: by_state is { group_by: state, aggregate: revenue }\n```\n\n### `# segment_map`\n\nRoute map. US only. Fields: start_lat, start_lon, end_lat, end_lon, color.\n\n\n## Layout Types\n\n### `# big_value`\n\nKPI cards. Aggregates only, no `group_by`.\n\n```malloy\n# big_value\nview: summary is {\n aggregate:\n # label="Revenue"\n # currency\n revenue\n # label="Orders"\n # number=auto\n order_count\n}\n```\n\n**Properties:** `.size`, `.sparkline=<nested_view_name>`, `.comparison_field`, `.comparison_label`, `.down_is_good`\n\n### `# dashboard`\n\nCard-based multi-tile layout. Apply to a view whose body is a nested query; the view\'s own fields lay out automatically:\n\n- `group_by` dimensions -> a row header (repeats once per row; omit for a single block)\n- `aggregate` measures -> KPI cards, one per measure\n- each `nest:` -> a tile, rendered by the tag above it (`# table` default, or `# bar_chart` / `# line_chart` / `# big_value`)\n\n**Two modes.** Flex (default): tiles flow and wrap; `# break` forces a new row. Columns: `# dashboard { columns=N }` lays tiles into N equal columns, `# colspan=n` widens a tile, `# break` starts a new row, overflow wraps.\n\n```malloy\n// Flex: measures become KPI cards, the nest becomes a tile\n# dashboard\nview: overview is {\n group_by: category\n # currency\n aggregate:\n avg_retail is retail_price.avg()\n sum_retail is retail_price.sum()\n nest:\n # bar_chart\n by_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 }\n}\n\n// Columns: # colspan widens tiles, # break ends a row\n# dashboard { columns=12 }\nview: layout is {\n group_by: category\n # currency\n aggregate:\n # colspan=4\n avg_retail is retail_price.avg()\n # colspan=4\n sum_retail is retail_price.sum()\n # colspan=4\n max_retail is retail_price.max()\n nest:\n # break\n # colspan=6\n # bar_chart\n # subtitle="Top brands"\n by_brand_chart is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 8 }\n # colspan=6\n by_brand_table is { group_by: brand, aggregate: product_count is count(), limit: 8 }\n}\n```\n\n**Tags:** `# dashboard { columns=N }` (columns mode), `{ gap=PX }` (tile spacing, default 16; never a mode), `{ table.max_height=PX|none }` (cap table tiles). On a measure or nest: `# colspan=N` (columns mode only), `# break` (both modes), `# subtitle="..."` (tile), `# borderless` (drop card chrome), `# label="..."` (card title).\n\nFor rich KPI cards (sparklines, comparison deltas, several metrics on one card) nest a `# big_value` view instead of relying on the dashboard\'s own measures:\n\n```malloy\n# dashboard\nview: kpis is {\n group_by: category\n nest:\n # big_value\n revenue_card is {\n aggregate:\n # label="Revenue"\n # currency\n # big_value { sparkline=trend }\n total_revenue is retail_price.sum()\n # line_chart { size=spark y.independent=true }\n # hidden\n nest: trend is { group_by: bucket is floor(id / 100)::number, aggregate: total_revenue is retail_price.sum(), order_by: bucket, limit: 20 }\n }\n}\n```\n\n**Rules:** `# dashboard` needs a nested-query view (no effect on a scalar). `# colspan` works only in columns mode and is ignored (warns) in flex. `columns` is any positive integer; a `# colspan` over the column count clamps to a full row. Style tiles via the instance theme, or theme the views inside with `# theme.*` (see Theming below).\n\n### `# pivot`\n\nPivot nested results into columns. Max 30 pivot columns.\n\n```malloy\nview: sales is {\n group_by: product, aggregate: total\n nest: # pivot\n by_quarter is { group_by: quarter, aggregate: revenue }\n}\n```\n\n### `# transpose`\n\nSwap rows/columns. Good for period comparisons.\n\n```malloy\n# transpose\nview: comparison is {\n aggregate:\n # label="This Month"\n current_revenue\n # label="Last Month"\n prior_revenue\n}\n```\n\n### `# list` / `# list_detail`\n\nList renders as comma-separated values. List_detail shows `value (detail)` pairs.\n\n### `# flatten`\n\nCollapse nested record into parent table as columns. Use for side-by-side filtered aggregates:\n\n```malloy\nview: segments is {\n group_by: product, aggregate: total_revenue\n nest: # flatten\n enterprise is { where: segment = \'Enterprise\', aggregate: # label="Enterprise" revenue }\n nest: # flatten\n smb is { where: segment = \'SMB\', aggregate: # label="SMB" revenue }\n}\n```\n\n### `# table`\n\nDefault (implicit). Use explicitly for `.size=fill` property.\n\n\n## Field Formatting Tags\n\n| Tag | Use For | Shorthand |\n|-----|---------|-----------|\n| `# number` | Numeric formatting | `=auto` (K/M/B), `=id` (no commas), `=1k`, `=1m` |\n| `# percent` | Percentages | (none needed) |\n| `# currency` | Money | `=usd2m` (USD, 2 decimals, millions); scale only in views |\n| `# duration` | Time durations | `=seconds`, `=minutes`, `=hours`, `=days` |\n| `# data_volume` | Storage sizes | `=bytes`, `=kb`, `=mb`, `=gb` |\n| `# link` | Hyperlinks | `.url_template="https://example.com/$$"` |\n| `# image` | Inline images | `.height=40px`, `.width=100px` |\n\n**Currency codes:** `usd` ($), `eur`, `gbp`. **Scale:** K/M/B/T/Q or `auto`.\n**Number suffix styles:** `word` ("42.5 million"), `letter` ("42.5M"), `scientific`.\n\n## Utility Tags\n\n| Tag | Purpose |\n|-----|---------|\n| `# hidden` | Hide from output (still usable for sorting/references) |\n| `# label="..."` | Override display name |\n| `# description="..."` | Tooltip text |\n| `# tooltip` | Include nested view in chart tooltip |\n| `# break` | Force new dashboard row |\n| `# column { width=sm }` | Table column width |\n\n## Model-Level Defaults\n\n```malloy\n## viz.line_chart.defaults.y.independent=true\n## viz.bar_chart.defaults.stack\n```\n\n## Theming\n\nPublisher styles charts and tables from one structured theme. The instance sets it (in `publisher.config.json`\'s `theme` block or the **Settings, then Theme** editor); a model overrides it per result with `# theme.*` annotations, or model-wide with `## theme.*`. Per-chart annotations use the same nested `palette.*` / `font.*` vocabulary as the config, not flat key names, and they win over the instance theme for the keys they set. The forms:\n\n| Annotation | Controls | Modes |\n|-----------|----------|-------|\n| `# theme.palette.series` | Categorical series colors (array) | shared |\n| `# theme.palette.background.{light,dark}` | Chart canvas + table background | per-mode |\n| `# theme.palette.tableHeader.{light,dark}` | Table header text color | per-mode |\n| `# theme.palette.tableHeaderBackground.{light,dark}` | Table header row background | per-mode |\n| `# theme.palette.tableBody.{light,dark}` | Table body text color | per-mode |\n| `# theme.palette.tile.{light,dark}` | Dashboard tile background | per-mode |\n| `# theme.palette.tileTitle.{light,dark}` | Dashboard tile title color | per-mode |\n| `# theme.palette.mapColor.{light,dark}` | Choropleth gradient (`# shape_map` / `# segment_map`) | per-mode |\n| `# theme.font.family` | Font for all rendered text | shared |\n| `# theme.font.size` | Table font size (px) | shared |\n\nThe seven `palette.*` color keys each take a `.light` and/or `.dark` variant so dark mode gets its own value. `palette.series`, `font.family`, and `font.size` are single values shared across modes.\n\n```malloy\n// Model-wide defaults (## applies to every view in the model):\n## theme.palette.series = ["#14b3cb", "#e47404", "#1474a4"]\n## theme.font.family = "Inter, sans-serif"\n\n// Per-view override (# applies to this result only; beats the instance theme):\n# theme.palette.background.light = "#fafafa"\n# theme.palette.background.dark = "#111111"\n# theme.palette.tableHeader.dark = "#94a3b8"\nview: revenue_by_month is {\n group_by: month\n aggregate: revenue\n}\n```\n\n**Precedence**, highest to lowest, per key: `# theme.*` on the view, then `## theme.*` model default, then the instance theme, then Publisher\'s built-in defaults. A per-chart annotation overrides the instance for the keys it sets; unset keys fall through to the instance. (This is the reverse of a bare `@malloydata/render` embed, where the embedder wins: Publisher reads the annotation itself and layers it on top.)\n\nQuote values that contain spaces or a leading `#`. The light/dark default (`defaultMode`) and the toggle lock (`allowUserToggle`) are instance-only: set them in the config `theme` block or the editor, not as annotations. The malloy-gotchas-rendering skill lists the annotation forms that look valid but do nothing.\n\n\n## Advanced Patterns\n\n### Sparklines in KPI Cards\n\n```malloy\n# big_value { sparkline=trend }\nview: revenue_kpi is {\n aggregate: # label="Revenue" # currency revenue\n nest: # line_chart { size=spark } # hidden\n trend is { group_by: order_date, aggregate: revenue, order_by: order_date }\n}\n```\n\n### KPIs with Comparison Deltas\n\n```malloy\n# big_value { comparison_field=prior_month comparison_label="vs Last Month" }\nview: rev_delta is {\n aggregate: # label="Revenue" # currency revenue, # hidden prior_month\n}\n```\n\nUse `down_is_good=true` for metrics where decrease is positive (churn, defects).\n\n### Inline Mini-Charts in Table Rows\n\n```malloy\nview: carriers is {\n group_by: carrier, aggregate: flight_count\n nest: # line_chart { size=spark }\n trend is { group_by: month, aggregate: flight_count, order_by: month }\n}\n```\n\n### Multi-Measure Series\n\n```malloy\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost }\n```\n\n### Hierarchical Drill-Down\n\n```malloy\n# list_detail\nview: explorer is {\n group_by: region, aggregate: revenue\n nest: # bar_chart\n by_category is { group_by: category, aggregate: revenue, order_by: revenue desc, limit: 10 }\n}\n```\n\n### Distribution (Histogram)\n\nThere is no auto-binning function: `autobin(...)` does not exist and fails with `Unknown function \'autobin\'`. Bin by arithmetic, choosing the width from the column\'s actual range: query `min`, `max` and a few percentiles first, and say in the view\'s doc where the width came from. A bin width nobody derived is a business decision in disguise.\n\n```malloy\n# bar_chart\nview: price_dist is {\n group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed range\n aggregate: order_count is count()\n order_by: bucket\n}\n```\n\n\n## Patterns for Missing Chart Types\n\n| Desired | Malloy Approximation |\n|---------|---------------------|\n| Pie/donut | `# bar_chart` sorted by value |\n| Treemap | Nested table with `order_by: desc` |\n| Heatmap | `# pivot` with color values |\n| Stacked area | `# line_chart` with series (overlaid lines) |\n| Funnel | `# bar_chart` with ordered stages |\n| Gauge/bullet | `# big_value` with `.comparison_field` |\n\n\n## Chart Annotations on Queries with `nest:`\n\nA top-level chart tag (e.g., `# bar_chart`) renders only the outer query; any `nest:` views are silently hidden from the rendering (still in raw data). To show nests, use `# dashboard` on the outer query with chart tags on each nest. Otherwise, drop the `nest:`.\n\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Two aggregates in chart | ONE aggregate, or use `y=[\'a\',\'b\']` |\n| `# currency=usd0m` on measure | `# currency` (no scale) on defs; scale only in views |\n| Chart annotation on `nest:` line | Put on the **view definition** |\n| Tags on same line | One tag per line |\n| Sparkline not showing | Add `# hidden` to nested view AND reference in `.sparkline=` |\n| Pivot > 30 columns | Filter/limit the nested group_by |\n\nNOTE: The term \'constructor\' is a reserved term in Vega-Lite. If the word \'constructor\' appears in the query, it will cause the rendering to fail. Never use it in a query and avoid using it as a dimension in a model.\n\nFor more patterns, call `search_malloy_docs` with topics like "bar charts", "line charts", "dashboards", "histograms", "percent of total", "comparing timeframes", or "pivots".\n\n## Further Reading\n\n- [Visualizations Overview](https://docs.malloydata.dev/documentation/visualizations/overview) - Official docs\n- [Bar Charts](https://docs.malloydata.dev/documentation/visualizations/bar_charts) - Stacked, grouped, series\n- [Bump Charts Blog](https://docs.malloydata.dev/blog/2023-10-26-malloy-bump-chart/) - Ranking over time\n- [Dataviz is Hierarchical](https://docs.malloydata.dev/blog/2024-02-29-hierarchical-viz/) - Nested data visualization philosophy'
325710
325830
  },
325711
325831
  {
325712
325832
  name: "malloy-dashboards",
@@ -326205,6 +326325,700 @@ the warning list.
326205
326325
 
326206
326326
  - \`docs/dashboards.md\`: the full guide this skill condenses.
326207
326327
  - \`docs/givens.md\`: the givens the controls are generated from.`
326328
+ },
326329
+ {
326330
+ name: "malloy-data-app-design",
326331
+ description: "Decide what a data app should be before building it - who opens it, the decision it serves, which archetype it is, the forms that answer its questions, and the depth (drill-down, shared scope, linkable state) that makes it a tool rather than a page of charts. Read at the START of any data-app request, before scaffolding a package or writing a tile, and when an existing app feels flat, generic, or like a list of every view in the model.",
326332
+ body: `# Designing a Data App
326333
+
326334
+ > An app that replaces a BI dashboard is shaped by a **job**, not by a model. The model tells you what is *available*; the job tells you what to build. Enumerating a model produces the same KPI-row-plus-chart-grid every time, which is the single most common failure of a generated data app.
326335
+
326336
+ > **Tool names** are written bare here - \`get_context\`, \`execute_query\`. The exact prefixed name depends on the host surface; match each against the tools you actually have.
326337
+
326338
+ This skill produces a **design brief**: the audience, the decision, the archetype, the tile forms, and the depth plan. That brief is the input to building the app. It does not cover wiring, queries, or the runtime.
326339
+
326340
+ ## Why this comes first
326341
+
326342
+ A data app is asked for in one line ("build a dashboard for X") and the model is right there, so the tempting move is to read the model and turn each view into a tile. That path is what makes every app look alike, and the sameness is structural, not cosmetic: a tile per view yields a KPI row, a line chart, a bar chart, and a table, in sections named after the model's subject areas, whatever the domain.
326343
+
326344
+ The apps that hold up against Looker or Sigma are not better-decorated versions of that. They are shaped differently because they answer a specific recurring question for a specific person. So spend the first few minutes on the brief. It is cheap, and it is the difference between a page someone screenshots once and a tool they open on Monday.
326345
+
326346
+ **The anti-pattern, stated plainly:** a tile per view is enumeration, not design. If your tile list can be derived mechanically from the model's view list, you have not designed anything yet.
326347
+
326348
+ ## 1. Establish the job
326349
+
326350
+ Answer these four before choosing anything. Ask the user what you cannot infer - this is a **decision**, not a rule: propose what you believe from the model and the request, and confirm the parts that change the build.
326351
+
326352
+ | Question | Why it changes the app |
326353
+ |---|---|
326354
+ | **Who opens this, and how often?** | An exec opening it monthly wants a verdict. An analyst in it daily wants pivots and exports. The same numbers, two different apps. |
326355
+ | **What decision does it serve?** | "Which accounts do I call this week", "do we ship or hold", "where did margin go". A named decision tells you what belongs on the first screen. |
326356
+ | **What 2-3 numbers settle that decision?** | These lead. Everything else is supporting evidence or a drill target. An app with fifteen equal-weight tiles has no answer. |
326357
+ | **What does the user do next, in the app?** | The answer is the depth plan (§4). "Nothing" is a legitimate answer only for a true monitor. |
326358
+
326359
+ If the user cannot name the decision, that is worth surfacing rather than papering over: an app without a decision behind it becomes a data dump, and no amount of design rescues it. Offer to build the most defensible general view for the audience and say plainly that is what you are doing.
326360
+
326361
+ ### If the app already exists
326362
+
326363
+ Most requests are "make this better", not "build me one". The brief is the same, but an existing app carries constraints a new one does not, and you find them by looking rather than by asking:
326364
+
326365
+ - **Find the tests and docs that describe the current app first.** A test that asserts exact label text, a README that names a section, a screenshot in a doc: any of these turns a wording change into a broken build. Search for the app's strings before you change them.
326366
+ - **Read the app against the brief you just wrote, not against your taste.** Say which tiles serve the decision, which do not, and which are missing. That list is the change; anything else is redecoration.
326367
+ - **Say what you are keeping.** A redesign that silently drops a tile someone relies on is worse than one that keeps it and explains why.
326368
+
326369
+ ### When nobody can answer
326370
+
326371
+ You may be running unattended, or the person who asked is not reachable. Do not stall, and do not quietly skip the brief: **write it down, state the assumption it rests on, and build.** Put the brief where the work lands (a comment at the top of the tile definitions, a note in the app, the handover message), so the first reviewer sees what you assumed and can correct it in one reply. An unanswered question is a reason to record the assumption, not to abandon the step.
326372
+
326373
+ ## 2. Pick the archetype
326374
+
326375
+ Archetype drives layout, navigation, and how much depth is warranted. Pick one deliberately; do not default.
326376
+
326377
+ | Archetype | The job | Shape | Depth |
326378
+ |---|---|---|---|
326379
+ | **Monitor** | Is anything wrong right now? | One screen, scannable, status-forward. Thresholds and deltas, not raw levels. | Shallow by design. Drill only into an anomaly. |
326380
+ | **Scorecard** | Are we hitting the target? | Metric per row: actual, target, variance, trend. Period selector. | Drill from a miss to its drivers. |
326381
+ | **Explorer** | What is going on in this population? | Ranked lists and distributions, an entity detail view, comparison. | Deep. Drill-down and compare are the point. |
326382
+ | **Workbench** | Do the recurring work of a role. | Multi-page behind nav, one page per subject. Shared period/unit controls. | Deep. Cross-page state, entity drawers, export. |
326383
+ | **Briefing** | Explain what happened and why. | Narrative order, prose between the numbers, few tiles, each carrying an argument. | Shallow. Links out rather than drills in. |
326384
+
326385
+ Notes that matter in practice:
326386
+
326387
+ - **A monitor is not a small workbench.** Do not grow one into the other by adding sections. If the user needs both, they are two apps, or two pages with different designs.
326388
+ - **Explorer and workbench are where BI tools are actually replaced.** They are also where the archetype gets skipped and a flat one-pager appears instead. If the job is "explore the population" or "do my job here", commit to the depth.
326389
+ - **Briefing is legitimate and under-used.** When the ask is "explain the quarter", prose with four well-chosen numbers beats twenty tiles.
326390
+
326391
+ \`reference/archetypes.md\` has a layout sketch for each, what leads the page, the depth that belongs, the failure mode that turns it back into a generic dashboard, and a table for deciding between two that seem close. Read the entry for the one you picked before laying anything out - "multi-page behind nav" and "narrative order" are easy to nod at and hard to produce from the phrase alone, and an archetype that stays abstract collapses back into a card grid.
326392
+
326393
+ Say which archetype you picked and why, in one line, before you build. It is the sentence the user can most usefully disagree with.
326394
+
326395
+ ## 3. Choose forms by question, not by data shape
326396
+
326397
+ A form is chosen by the **question it answers**. Reaching for line/bar/table by default is how a rich model comes out flat. The table below is the vocabulary to choose from; pick the row whose question matches, then check the misuse column before committing.
326398
+
326399
+ ### First, inventory the model. The table alone will not change what you build.
326400
+
326401
+ A vocabulary list is a lookup, and nobody looks up what they think they already know. Measured on a real build: an agent that had this exact table in context still shipped an app with a scatter, a sparkline and a table - three forms out of eighteen. It reached for more only when told to, and then found them immediately, because **the forms were sitting in fields it had not enumerated**.
326402
+
326403
+ So before you choose a single tile, write the inventory down:
326404
+
326405
+ 1. **List every dimension and measure the model exposes - and then look at the underlying table columns too.** Not the named views: views are someone else's earlier answer to a different question. And a model's \`extend\` block is a curated subset, so the columns it did not promote are exactly where an unused form hides. On a real build the single best tile came from a \`release_date\` column the model never declared as a dimension; a model-first reading would have missed it entirely. Where your host gives you a schema tool, run it; otherwise \`run: <source> -> { select: *; limit: 1 }\` shows you the columns.
326406
+ 2. **Check the model's own measures against the raw values before you trust one.** A declared measure is someone's earlier reading of the data, and it can be wrong in a way that inverts your headline. Profile the column a measure depends on - its distinct values, its null count - and confirm the measure means what its name says. Pay particular attention to any predicate of the form \`!= null\`, \`is not null\` or a boolean derived from one: a column whose "absent" case is a **sentinel string** rather than a null breaks them silently. On a real build, \`cleared_count is count() { where: clearance_status != null }\` counted every row whose status was the literal string \`'Not cleared'\`, reporting 95.6% of cases cleared where the truth was 16.6%. Nothing in the app would have looked broken. If a measure is wrong, do not use it: define what you need in the app's queries, say in the app that you did, and tell whoever owns the model.
326407
+ 3. **Mark the ones no line, bar or table can show well.** A part-to-whole breakdown (several measures that sum to a total), a paired value per entity (home/away, before/after, actual/target), a per-row distribution, a second entity reference on the same row (an opponent, a referrer, a parent), a date that supports a calendar or a cohort, a flag that splits a population.
326408
+ 4. **For each, name the question it answers** and take the form from the table below.
326409
+
326410
+ That is the step that changes the output. A field like \`points_second_chance\` next to three sibling scoring columns *is* a composition bar; \`home_wins\` and \`away_wins\` on one row *is* a dumbbell; a margin column *is* a distribution. None of these are visible if you start from the view list.
326411
+
326412
+ **The test:** if your app has three or fewer distinct forms and the model exposes more than about fifteen fields, you enumerated rather than designed. Go back to step 3.
326413
+
326414
+ These forms assume the app draws its own charts with a vendored chart library, where any form is available. An app that renders through the Malloy renderer's chart tags instead is working with a narrower vocabulary - it has no native treemap, funnel, or bullet, and a chart-tag skill will offer approximations for them. Design to whichever vocabulary the app actually uses, and do not carry the renderer's substitutions into an app that could draw the real form.
326415
+
326416
+ | The question | Form | Misused when |
326417
+ |---|---|---|
326418
+ | How did this move over time? | Line, or area for one cumulative series | Categories on the x-axis. That is a bar chart. |
326419
+ | Which categories are biggest? | Ranked horizontal bars | More than ~15 bars, or a category axis with an inherent order (use a line). |
326420
+ | How does one entity score across many criteria? | Radar | Axes on different scales. Normalize, or do not use it. |
326421
+ | Where did the total go, step by step? | Waterfall | Steps that are not additive to the total. |
326422
+ | How does actual sit against target? | Bullet, or bar with target marker | A second bar for target, which reads as a category rather than a threshold. |
326423
+ | What is the distribution, not the average? | Histogram, box, or strip | Small n, where individual points are more honest. |
326424
+ | How do two measures relate, and who are the outliers? | Scatter, bubble for a third | Dense overplotting with no encoding of density. |
326425
+ | How does a cohort behave over its life? | Retention/cohort heatmap | Cohorts too small to read as rates. |
326426
+ | Where do people drop out of a sequence? | Funnel | Steps that are not strictly sequential or not a subset of the prior. |
326427
+ | How did rank change between two points? | Slope, or bump for many periods | More than ~20 entities, which becomes spaghetti. |
326428
+ | How does a total break into parts, with magnitude? | Treemap | Parts that do not sum to a meaningful whole. |
326429
+ | Where does volume flow between stages? | Sankey | Cycles, or more than a few stages. |
326430
+ | How do two groups compare on one axis? | Diverging bars | **Disjoint category vocabularies.** See the trap below. |
326431
+ | Composition as a share of a countable whole | Waffle | Continuous quantities, where it implies false discreteness. |
326432
+ | How much do two or three sets overlap? | Venn / upset | More than three sets, where upset is the only readable option. |
326433
+ | The same small chart across many facets | Small multiples | Facets with wildly different y-ranges and a shared axis. |
326434
+ | One number, in context | Big value with sparkline and delta | A number with no comparison, which carries no information. |
326435
+ | The records themselves | Table, sortable, with inline bars | Used as the default because no form was chosen. |
326436
+
326437
+ Two traps worth naming, because they produce charts that are *readable and wrong*:
326438
+
326439
+ - **Diverging bars on disjoint vocabularies.** If the positive and negative sides are drawn from different taxonomies (an NLP model whose praise labels and complaint labels do not overlap), a diverging chart keyed on a shared label renders nearly every row one-sided and implies a balance the data does not contain. Use two ranked lists side by side.
326440
+ - **A shared axis across different scales.** Mixing a 0-7 rating with a 0-10 rating on one radar, or two measures with different units on one y-axis, silently lets the larger scale dominate. Normalize, use a second axis explicitly labeled, or split the chart.
326441
+
326442
+ Pie and donut are absent from that table on purpose. They answer "what is the composition" worse than a ranked bar or a waffle at almost every n. Use one only when parts-of-whole is the entire message and there are two or three parts.
326443
+
326444
+ ## 4. Plan the depth
326445
+
326446
+ This is what separates a tool from a page. Decide which of these the app gets, from the "what does the user do next" answer in §1. A monitor may take none; an explorer or workbench should take most.
326447
+
326448
+ | Pattern | What it gives the user | Decide it when |
326449
+ |---|---|---|
326450
+ | **Drill-down** | A number leads to the rows behind it | Any tile where "why is that number that?" is the obvious next question |
326451
+ | **Shared scope** | Clicking a category filters the other tiles | The app has a dimension the whole page should agree on |
326452
+ | **Linkable state** | A view someone can paste into Slack | Always, unless the app has no state at all |
326453
+ | **Entity detail** | One row's profile, trend, and actions | The population has entities a user cares about individually |
326454
+ | **Comparison** | Two or more entities side by side, same definitions | Users pick between things, rather than just monitoring them |
326455
+ | **Provenance** | The query behind a number, on demand | The number will be challenged by someone who did not build the app |
326456
+
326457
+ The one that shapes the model rather than the page is **drill-down**. A drill target is a parameterised source that extends the spine, so every inherited measure comes back already filtered to that entity and the panel names a *view* rather than recomputing anything. That keeps one definition of every number, so a drawer and a question asked of an agent answer the same way, and it keeps drill logic out of hand-built query strings in the page, which is where drift starts. Plan it while you are still deciding the tiles: retrofitting drill onto an app whose numbers were computed in JavaScript means rewriting both.
326458
+
326459
+ \`reference/depth-patterns.md\` carries the mechanics for all six - the state bus, URL-hash rules, the parameterised-source traps, the five parts of a correct drawer, and safe cross-filtering. Read it before building any of them: each has a failure that is quiet rather than obvious, and the two that bite hardest (a stale drill response painting one entity's numbers under another's title, and a filtered dashboard with no visible chips) look like bad data rather than bugs.
326460
+
326461
+ Also decide what the app does **not** do. An explorer that cannot export, or a monitor with no link to the runbook, is usually a gap rather than a choice.
326462
+
326463
+ ## 5. Design for consistency without a shared kit
326464
+
326465
+ There is no shipped component library. Consistency comes from a **token contract**: define the tokens in CSS, and have every chart read them at runtime rather than hardcoding color. That is what lets one app carry a client's identity while still looking like it was built on purpose.
326466
+
326467
+ Three rules do the load-bearing work here:
326468
+
326469
+ - **Chart code reads tokens at runtime; never a hex literal in a chart config.** \`getComputedStyle(document.documentElement).getPropertyValue('--c1')\` and friends. The payoff is that a client re-skin or a theme toggle is a token swap and the charts follow. If you do support a theme toggle, re-push chart options after the swap rather than reloading the page, since most chart libraries bake resolved colors at construction time and will otherwise keep the old palette.
326470
+ - **The same rule covers type, spacing and motion, not just color.** A font-size, spacing value, radius or duration that is not a token is a bug, exactly like a hex literal in a chart config. This is the half of the contract that rots when nobody states it: measured across three shipped data apps, each had 12 to 17 distinct ad-hoc font sizes and no type tokens at all. Sizes a half-point apart (10.5px, 11.5px, 13.5px) are the symptom - nobody designs those, you arrive at them by nudging one label at a time. Define a **closed**, ratio-based scale of eight type steps and pick the nearest one. Unlike a stray hex literal, a 12.5px is invisible on sight, so this rule only holds if you actually check it - grep the stylesheet for raw \`font-size:\` values before you call the app done, and expect the answer to be zero. Grep the JavaScript too: \`style.fontSize\`, \`style.padding\` and a hex literal in a chart config are the same violation wearing different clothes, and a chart-heavy app drifts there first because the stylesheet stays clean.
326471
+ - **Hierarchy through size and weight, not boxes.** The 2-3 numbers from §1 should be visibly primary, and the gap between them and everything else should come from the type scale rather than from per-tile judgment. If every tile is the same card at the same size, you have built a grid, not a hierarchy, and the reader has no way to tell what the app is for.
326472
+
326473
+ \`reference/token-contract.md\` has the full list of tokens to define, a worked example, and the craft rules (domain conventions, formatting at the edge, tile captions) to follow when writing the stylesheet.
326474
+
326475
+ **The contract is the floor, not the ceiling.** It buys consistency, and an app can satisfy every rule in it and still look like a generated admin panel: one surface colour used for every tile, a uniform grid, library-default charts, an accent that never appears at scale. That has been measured on a real build - every token rule green, and the page still read as generic. \`reference/visual-craft.md\` is the difference between consistent and designed: surface elevation rather than one flat plane, an accent that carries something structural, charts with direct labelling and annotation instead of library defaults, density that earns its space, motion as feedback, and type with enough contrast to separate the answer from the interface. Read it whenever the app is meant to feel like a product someone chose rather than a dashboard someone generated - which, for anything replacing a BI tool, is always.
326476
+
326477
+ ## What "done" means for the design
326478
+
326479
+ Before building, you should be able to state, in a few lines:
326480
+
326481
+ - who opens it, how often, and the decision it serves
326482
+ - the archetype, and why that one
326483
+ - the 2-3 numbers that lead, and what is supporting
326484
+ - for each tile: the question it answers and the form that answers it, with any default form you rejected and why
326485
+ - the depth plan: what drills into what, what shares scope, what goes in the URL
326486
+ - the token contract the app will use
326487
+
326488
+ Hand that brief over before scaffolding. It is short, the user can correct it in one message, and correcting it then is far cheaper than rebuilding a finished app that answered the wrong question.
326489
+
326490
+ ## Reference files over MCP
326491
+
326492
+ This skill's \`reference/\` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read \`reference/<name>.md\`, get the prompt named \`malloy-data-app-design/<name>\` instead.
326493
+
326494
+ Available: archetypes, depth-patterns, token-contract, visual-craft.`
326495
+ },
326496
+ {
326497
+ name: "malloy-data-app-design/archetypes",
326498
+ description: "The five archetypes. Reference detail for the malloy-data-app-design skill.",
326499
+ body: `# The five archetypes
326500
+
326501
+ > Expanded from \`SKILL.md\` §2. Read when you have picked an archetype and need to know what it actually looks like, or when you are deciding between two that seem close.
326502
+
326503
+ Each entry below gives the job, what leads the page, the depth that belongs, and the failure mode that turns it back into a generic dashboard. Where the arrangement itself is the point, there is a layout sketch: those are shapes, not templates, so copy the hierarchy rather than the box count.
326504
+
326505
+ Anything below that says "drawer", "drill", or "cross-filter" has its mechanics in \`reference/depth-patterns.md\`; read that before building one.
326506
+
326507
+ ## Monitor
326508
+
326509
+ **Job:** is anything wrong right now? Opened often, read in seconds, usually not read at all when things are fine.
326510
+
326511
+ **Leads with:** state, not level. "3 breaching" beats a chart of the metric. Deltas and thresholds, not raw numbers.
326512
+
326513
+ **Depth:** shallow on purpose. Drill into an anomaly and nothing else. Linkable state so an alert can point at the exact view.
326514
+
326515
+ **Fails when** it grows sections. Each added section costs scan time, which is the only thing a monitor sells. If the reader has to look for the problem, it has stopped being a monitor.
326516
+
326517
+ ## Scorecard
326518
+
326519
+ **Job:** are we hitting the target? Read on a cadence, by someone accountable for the number.
326520
+
326521
+ **Leads with:** variance to target, sorted so the misses are at the top. An alphabetical metric list buries the thing the reader came for.
326522
+
326523
+ **Depth:** drill from a miss to its drivers - the split by the one dimension that explains it. Name that dimension per metric while designing; if there is no useful split, say so rather than inventing one.
326524
+
326525
+ **Fails when** every metric gets equal weight and the reader has to compute variance themselves. A scorecard whose rows are just current values is a KPI grid.
326526
+
326527
+ ## Explorer
326528
+
326529
+ **Job:** what is going on in this population, and which entities deserve attention? Used by an analyst who arrives with a question the app's author did not anticipate.
326530
+
326531
+ \`\`\`
326532
+ [ scope bar: active filters as removable chips ]
326533
+ [ ranked list: the entities, by the measure that matters ]
326534
+ [ distribution: where this entity sits among the rest ]
326535
+ [ click any row -> entity drawer: profile, trend, ]
326536
+ [ composition, add-to-comparison ]
326537
+ [ comparison tray: collected entities, side by side ]
326538
+ \`\`\`
326539
+
326540
+ **Leads with:** the ranked population, not a total. A total is the one number an explorer's user already knows.
326541
+
326542
+ **Depth:** deep, and this is where it is the point. Drill-down, entity detail, comparison, cross-filtering, all backed by parameterised detail sources so every panel inherits one definition.
326543
+
326544
+ **Fails when** it is built as a flat page of aggregate charts. If a user cannot get from "this bar is tall" to the rows behind it, they will export to a spreadsheet and you have lost.
326545
+
326546
+ ## Workbench
326547
+
326548
+ **Job:** do the recurring work of a role. Opened daily by the same person, who knows exactly where everything is.
326549
+
326550
+ \`\`\`
326551
+ [ sidebar nav ][ shared controls: period, unit, as-of ]
326552
+ [ one page ][ page content, dense, tables welcome ]
326553
+ [ per subject ][ every page obeys the shared controls ]
326554
+ [ area ][ drill panel over the top for one entity ]
326555
+ \`\`\`
326556
+
326557
+ **Leads with:** whatever that page's subject leads with. A workbench has no single front page; the overview is one page among several, not the app.
326558
+
326559
+ **Depth:** deep, and specifically *cross-page*: state shared across pages, an entity drawer reachable from anywhere, export, and linkable state so a colleague can be sent one exact view. Density is a feature here - this user wants more on screen, not less.
326560
+
326561
+ **Fails when** it is one long scrolling page with headings instead of real navigation, or when each page invents its own period control. Shared controls that do not apply everywhere are worse than no shared controls.
326562
+
326563
+ ## Briefing
326564
+
326565
+ **Job:** explain what happened and why, to someone who was not watching. Read once, start to finish.
326566
+
326567
+ \`\`\`
326568
+ [ the headline finding, in a sentence ]
326569
+ [ the one number that carries it, large ]
326570
+ [ prose: what happened ]
326571
+ [ chart: the evidence for that claim ]
326572
+ [ prose: why, and what was ruled out ]
326573
+ [ chart: the driver ]
326574
+ [ what to do about it / what to watch ]
326575
+ \`\`\`
326576
+
326577
+ **Leads with:** the conclusion. A briefing that opens with methodology has buried its point.
326578
+
326579
+ **Depth:** shallow. Link out to an explorer or workbench for anyone who wants to dig; do not build the digging into the briefing itself.
326580
+
326581
+ **Fails when** it becomes a dashboard with captions. The test: remove the prose. If the page still makes the same argument, the prose was decoration and the form should have been something else; if the argument collapses, the prose is doing its job.
326582
+
326583
+ ## Choosing between two that seem close
326584
+
326585
+ | If you are torn | Ask | Decide by |
326586
+ |---|---|---|
326587
+ | Monitor vs scorecard | Is the reader checking for problems, or reporting against a commitment? | Problems -> monitor. Commitment -> scorecard. |
326588
+ | Scorecard vs explorer | Is the metric list fixed and agreed, or is the question open? | Fixed -> scorecard. Open -> explorer. |
326589
+ | Explorer vs workbench | One population, or several subject areas? | One -> explorer. Several -> workbench. |
326590
+ | Workbench vs several apps | Do the pages share controls and a user, or just a data source? | Share a user -> one workbench. Share only data -> separate apps. |
326591
+ | Anything vs briefing | Will this be read once, or repeatedly? | Once -> briefing. Repeatedly -> the other one. |
326592
+
326593
+ When two genuinely apply to different audiences, that is two apps, not one app with a mode switch. The compromise app serves neither, and it is the most common way a good brief still produces a generic result.`
326594
+ },
326595
+ {
326596
+ name: "malloy-data-app-design/depth-patterns",
326597
+ description: "Depth patterns. Reference detail for the malloy-data-app-design skill.",
326598
+ body: `# Depth patterns
326599
+
326600
+ > The mechanics behind the depth plan in \`SKILL.md\` §4. Read when building drill-down, cross-filtering, linkable state, or an entity drawer. Each pattern names the bug you get by skipping a part, because every one of these fails quietly rather than loudly.
326601
+
326602
+ ## Shared state with a subscriber bus
326603
+
326604
+ Any app with a control that more than one tile obeys (a period selector, a unit toggle, a cross-filter) needs one place to hold that state and one way to tell tiles it changed. Without it, each tile grows its own copy and they drift out of sync.
326605
+
326606
+ \`\`\`js
326607
+ const state = { month: null, period: "m", unit: "k" };
326608
+ const subs = [];
326609
+ const emit = () => { subs.forEach((fn) => fn()); syncControls(); writeHash(); };
326610
+
326611
+ // Each tile registers its own redraw and never reads another tile's state.
326612
+ subs.push(() => redrawRevenue());
326613
+ \`\`\`
326614
+
326615
+ Three rules keep this from turning into a tangle:
326616
+
326617
+ - **Tiles subscribe; they do not push.** A tile reads \`state\` and redraws. Only a control calls \`emit()\`.
326618
+ - **\`emit()\` also writes the URL.** State that is not in the URL is state a user cannot share (see below).
326619
+ - **A tile that is expensive to redraw should check whether its slice of state actually changed.** A unit toggle does not need a refetch, only a reformat, and refetching on every keystroke of a filter is how an app becomes slow.
326620
+
326621
+ ## Linkable state
326622
+
326623
+ Put page, period, and filters in the URL hash and read them on load. This is the single cheapest thing that gets an app adopted: a view someone can paste into Slack beats one the recipient has to re-navigate.
326624
+
326625
+ \`\`\`js
326626
+ // #pnl?m=2026-06&p=q&u=eur
326627
+ function writeHash() {
326628
+ if (!state.month) return;
326629
+ const p = new URLSearchParams({ m: state.month });
326630
+ if (state.period !== "m") p.set("p", state.period); // omit defaults: shorter, and
326631
+ if (state.unit === "eur") p.set("u", "eur"); // the default can change later
326632
+ history.replaceState(null, "", \`#\${currentPage}?\${p}\`);
326633
+ }
326634
+ \`\`\`
326635
+
326636
+ Use \`replaceState\`, not \`pushState\`, for filter changes: every tweak of a dropdown becoming a back-button step makes the back button useless. Reserve \`pushState\` for navigation the user would expect to undo, like opening a different page.
326637
+
326638
+ **Key the URL on a name, not a position.** A row index or a group number is only stable if the query that produced it has a total order, and "sort by size" is not one: two groups of equal size come back in whichever order the warehouse returns them, so the index in the URL points at a different thing on each load. That failure is invisible in testing, because it needs a tie. Either key on something stable (the entity's name or id) or give the query a tie-breaker (\`order_by: size desc, name\`). Validating the value on read does not catch this, because the value is perfectly valid and simply means something else.
326639
+
326640
+ **Validate on read.** A hash is user-editable, so treat it as untrusted input: check a page id against the known list and a period against the known set, and fall back to the default rather than trusting it into a query.
326641
+
326642
+ ## Drill-down: the model side
326643
+
326644
+ A drill target is a **parameterised source that extends the spine**, so every inherited measure and view comes back already filtered to that entity. The drill panel then names a *view* and recomputes nothing.
326645
+
326646
+ \`\`\`malloy
326647
+ ##! experimental.parameters
326648
+
326649
+ source: product_detail(pid::number is 0) is products extend {
326650
+ where: product_id = pid
326651
+ view: profile is { ... }
326652
+ }
326653
+ \`\`\`
326654
+
326655
+ The flag is per-file and required: without it the parameter list is a parse error, and a \`.malloy\` file that fails to parse fails the **whole package load**, so every tile goes blank rather than just the drill.
326656
+
326657
+ Two traps worth knowing before you build several of these:
326658
+
326659
+ - **A persisted rollup is still read through a wrapper.** Extending or wrapping a persisted source keeps the persist annotation and still reads the built table, so a parameterised detail source over a rollup does not silently recompute it. The pattern is safe, and a slow drill is almost never the wrapper: look first at a missing filter or a grain that is not actually aggregated.
326660
+ - **An inherited view can be meaningless under a parameter.** A source scoped to one entity inherits views that only make sense across many: a sentiment split on a single-sentiment theme collapses to 0% or 100%, a share-of-total becomes 100%. Declare purpose-built views on the detail source for those, and say in the source's doc comment which inherited views not to use.
326661
+
326662
+ ### When you cannot change the model
326663
+
326664
+ Often you can't: the model is owned by another team, synced from somewhere else, or the brief scopes you to the app. The parameterised source is still the right answer to *propose* - say so, because it is a small model change that removes a whole class of drift - but you may have to ship without it.
326665
+
326666
+ Falling back to query strings built in the app is legitimate. What makes it safe is keeping the drill queries under the same discipline as the tile queries, not treating them as one-off strings:
326667
+
326668
+ - **Put them in the same module** as the tile queries, next to the tile they drill from. A drill query that lives in the render code is the drift the pattern warns about.
326669
+ - **Share one filter builder and one definition of every derived measure.** If a rate is pooled in a tile, the same helper must pool it in the drawer. Two spellings of the same measure is how a drawer quietly disagrees with the row that opened it.
326670
+ - **Assert it.** A unit test over the query strings - every one filters to the entity, every rate uses the shared helper - costs a few lines and catches the drift that code review will not.
326671
+
326672
+ Write down that you did this and why. A reader who later sees hand-built drill queries should find the reason, not assume the pattern was unknown.
326673
+
326674
+ ## Drill-down: the panel side
326675
+
326676
+ An entity drawer has six parts. Skipping any one produces a bug that only shows up in use, not in a screenshot.
326677
+
326678
+ \`\`\`js
326679
+ const drill = { entity: null, charts: [], returnFocus: null };
326680
+
326681
+ function openDrill(entity) {
326682
+ drill.entity = entity;
326683
+ drill.returnFocus = document.activeElement; // 1. remember where focus came from
326684
+ panel.hidden = false;
326685
+ requestAnimationFrame(() => {
326686
+ panel.classList.add("is-open");
326687
+ document.addEventListener("keydown", onTab); // 2. trap Tab inside the panel
326688
+ renderDrill();
326689
+ });
326690
+ }
326691
+
326692
+ function renderDrill() {
326693
+ const entity = drill.entity;
326694
+ loadRows(entity).then((rows) => {
326695
+ if (drill.entity !== entity) return; // 3. stale-response guard
326696
+ paint(rows);
326697
+ });
326698
+ }
326699
+
326700
+ function closeDrill() {
326701
+ drill.entity = null;
326702
+ panel.classList.remove("is-open");
326703
+ document.removeEventListener("keydown", onTab);
326704
+ drill.charts.forEach((c) => c.destroy()); // 4. tear charts down
326705
+ drill.charts = [];
326706
+ drill.returnFocus?.focus(); // 5. give focus back
326707
+ }
326708
+ \`\`\`
326709
+
326710
+ What each part prevents:
326711
+
326712
+ 1. **Return focus** - without it, closing the drawer drops a keyboard user at the top of the document, losing their place in a long table.
326713
+ 2. **Focus trap** - without it, Tab walks out of the open drawer into the page behind it, which is invisible to a mouse user and completely breaks a keyboard one. Close on Escape too.
326714
+ 3. **Stale-response guard** - the real bug. Click entity A, then quickly click entity B: A's slower query resolves last and paints A's numbers under B's title. It is intermittent, looks like bad data rather than a race, and is close to impossible to diagnose from a screenshot. Compare the entity you are about to paint against the one currently open, and drop the response if they differ.
326715
+ 4. **A closed panel that still takes clicks.** If the drawer's own CSS sets \`display\` (a \`grid\` or \`flex\` layout on the panel itself), that wins over the \`hidden\` attribute, so the panel stays in the layout at zero opacity and swallows every click aimed at the page behind it. Nothing looks wrong; the app simply stops responding in that region. Guard it once, globally: \`[hidden] { display: none !important }\`.
326716
+ 5. **Chart teardown** - most chart libraries keep a registry and attach resize listeners; reopening a drawer twenty times without destroying leaks memory and can leave ghost tooltips from the old instance.
326717
+ 6. Also re-render the drawer when the global filter changes underneath it, or it silently shows numbers for a period the rest of the page has moved off.
326718
+
326719
+ ## Cross-filtering (shared scope)
326720
+
326721
+ Clicking a category filters the other tiles. Express scope as a \`where:\` refinement on the **same named view** each tile already runs, so a filtered tile and an unfiltered one still share one definition.
326722
+
326723
+ \`\`\`js
326724
+ function scopeWhere(ignore) {
326725
+ const parts = Object.entries(SCOPE)
326726
+ .filter(([k]) => !ignore?.includes(k))
326727
+ .map(([k, v]) => \`\${k} = \${lit(v.value)}\`);
326728
+ return parts.length ? \` + { where: \${parts.join(" and ")} }\` : "";
326729
+ }
326730
+ \`\`\`
326731
+
326732
+ Three requirements, in order of how badly they bite:
326733
+
326734
+ - **Every active filter is visible and removable.** A chip per filter, with an X, and a clear-all. A filtered dashboard with no visible chips silently lies to the next person who looks at it - they read filtered numbers as totals. This is the one non-negotiable part of cross-filtering.
326735
+ - **A tile ignores its own dimension.** The tile you clicked to set \`category = X\` should not then filter itself to X, or it collapses to a single bar. That is what the \`ignore\` argument is for.
326736
+ - **Escape interpolated values correctly, or avoid interpolating.** Malloy escapes with a backslash, not by doubling quotes - \`''\` closes the string and opens a new one, so a value like \`Land O'Lakes\` produces a malformed query. Prefer binding the value as a typed runtime parameter (a \`given:\`, declared behind \`##! experimental.givens\`, and passed by the runtime rather than pasted into the string) over building query text at all; where you must interpolate, constrain values to a known set and backslash-escape quotes and backslashes.
326737
+
326738
+ ## Comparison
326739
+
326740
+ Collect entities into a tray, then compare them side by side. Back it with a source parameterised on the **pair**, returning one row per entity, so both sides use identical definitions rather than two separately-written queries that might not agree.
326741
+
326742
+ \`\`\`malloy
326743
+ ##! experimental.parameters
326744
+
326745
+ source: head_to_head(a::number is 0, b::number is 0) is products extend {
326746
+ where: product_id = a or product_id = b
326747
+ view: scorecard is { group_by: product_id, ... }
326748
+ }
326749
+ \`\`\`
326750
+
326751
+ Persist the tray across navigation (session storage is enough), or a user who found three interesting entities on three different pages cannot actually compare them - which is the whole point of collecting.
326752
+
326753
+ ## Provenance
326754
+
326755
+ Let a tile show the query behind its number, pulled from the tile's own definition rather than retyped. It costs one chip per tile and it is what makes a number defensible to someone who did not build the app. It also pays off when handing context to an agent: the exact query string the tile ran is far more useful than the name of the source it came from.`
326756
+ },
326757
+ {
326758
+ name: "malloy-data-app-design/token-contract",
326759
+ description: "The token contract. Reference detail for the malloy-data-app-design skill.",
326760
+ body: `# The token contract
326761
+
326762
+ Read when setting up an app's stylesheet, or when reskinning one for a client.
326763
+
326764
+ There is no shipped component library, so visual consistency across apps comes from every app defining the same *kinds* of token and every chart reading them at runtime. The values are free - that is what carries a client's identity. The contract is the shape.
326765
+
326766
+ ## What to define on \`:root\`
326767
+
326768
+ | Group | Tokens |
326769
+ |---|---|
326770
+ | Surfaces | page background, card surface, a raised and a sunken variant, border, strong border |
326771
+ | Text | strong, default, muted, soft - a real ramp, not one color at three opacities |
326772
+ | Semantic | positive, negative, warning, each with a soft background variant for fills and badges |
326773
+ | Chart palette | a categorical ramp of 6-8, ordered so the first is the brand accent, plus grid and axis-tick colors |
326774
+ | **Type** | a **closed** size scale of exactly 8 steps, plus the weights, line-heights and letter-spacings you actually use |
326775
+ | **Spacing** | a scale (4 / 8 / 12 / 16 / 24 / 32 / 48), not a single step that gets multiplied and divided in place |
326776
+ | Structure | a radius scale (small, default, large), a shadow for cards and a deeper one for popovers |
326777
+ | **Motion** | two transition durations (fast, base), one easing, and a separate duration for any looping animation |
326778
+
326779
+ **Every one of these groups needs the same mechanical rule, or half the contract quietly rots.** The colour half of this contract holds up in practice because it carries a rule you can check by grep - never a hex literal in a chart config. Type, spacing and motion have no such rule in most apps, and they are exactly where real apps drift: measured across three shipped data apps, each had 12 to 17 distinct ad-hoc font sizes and zero type tokens. The giveaway is half-point sizes (10.5px, 11.5px, 13.5px) - nobody designs those, you land on them by nudging one label at a time. So state the rule the same way for every group:
326780
+
326781
+ > A font-size, spacing value, radius, duration or colour that is not a token is a bug, the same as a hex literal in a chart config.
326782
+
326783
+ The scale being **closed** is what does the work. Eight type steps you must choose from produces hierarchy; an open-ended "define a type ramp" produces fourteen sizes a half-point apart. Pick the step nearest your intent and move on - if two things want to differ by half a point, they are the same step.
326784
+
326785
+ Build the scale on a **ratio**, not by listing sizes that feel right. Four steps inside a 3px range is the same crowding as half-points, one notch coarser: they invite the same nudging, and nothing in the scale tells you which to reach for. Roughly 1.2x between steps at the small end, widening toward the display sizes, gives steps that are visibly different from each other.
326786
+
326787
+ ## The values are a decision, and the default is the problem
326788
+
326789
+ The contract fixes the *shape* of the palette. It does not choose the colours, and left unchosen an app lands on the same one every time: a cold blue-grey ground somewhere around \`#0e1015\`, slate surfaces, and a mid-blue accent near \`#56a7e8\`. That combination is the house style of every admin panel ever generated. It is not wrong; it is *unchosen*, and a reader can tell the difference immediately.
326790
+
326791
+ Measured on two real builds: both agents produced almost exactly that palette unprompted, and both apps read as generic until the hue was repitched deliberately.
326792
+
326793
+ So make two decisions on purpose and write down the reason:
326794
+
326795
+ - **Pick a temperature for the neutrals.** Warm greys (a paper-toned light ground, a warm charcoal dark one) read differently from the default cool slate and cost nothing. A data page is read for a long time; warmth is easier to sit with, and it stops the page looking like a framework default.
326796
+ - **Derive the accent from something.** The domain, the client's brand, the material the data is about. An accent chosen for a reason is defensible and usually more distinctive than a stock blue. If nothing suggests one, a warm accent against cool neutrals (or the reverse) at least reads as a choice.
326797
+
326798
+ The test is whether you can say *why* this hue and not another. "It is the default" is the answer that produces the generic app.
326799
+
326800
+ A worked example. The neutrals here are deliberately **warm** and the accent is a burnt orange rather than the default indigo, to show what "chosen" looks like - swap both for whatever your own reason produces; what you must not do is leave them at the framework default:
326801
+
326802
+ \`\`\`css
326803
+ :root {
326804
+ --bg: #f4f1ee; /* warm paper, not cool #f4f5f8 */
326805
+ --surface: #fefdfc;
326806
+ --surface-2: #f7f4f1;
326807
+ --border: #e6e0da;
326808
+
326809
+ --text-strong: #17130f; /* warm ink, so text sits on the paper */
326810
+ --text: #4a423b;
326811
+ --text-muted: #7d746b;
326812
+
326813
+ --positive: #0e9f6e; --positive-soft: #e3f6ef;
326814
+ --negative: #dc2626; --negative-soft: #fdeaea;
326815
+ --warn: #d97706;
326816
+
326817
+ --c1: #d95a12; --c2: #2f7ec9; --c3: #12805a; --c4: #8250d4;
326818
+ --c5: #a76d0a; --c6: #c73641; --c7: #17909e; --c8: #7d746b;
326819
+ --grid: #efeae4;
326820
+ --tick: #9c9289;
326821
+
326822
+ /* Type: a closed scale, ratio-based. Pick the nearest step; never invent one between two. */
326823
+ --fs-xs: 10px; --fs-sm: 12px; --fs-md: 14px; --fs-base: 16px;
326824
+ --fs-lg: 20px; --fs-xl: 26px; --fs-2xl: 34px; --fs-3xl: 46px;
326825
+ --fw-normal: 400; --fw-medium: 500; --fw-semi: 600; --fw-bold: 700;
326826
+ --fw-display: 800; /* the headline numbers; see visual-craft.md on type contrast */
326827
+ --lh-tight: 1.2; --lh-body: 1.5;
326828
+ /* Tracking, or the nudging just migrates here from font-size. */
326829
+ --tr-tight: -0.01em; --tr-display: -0.02em; --tr-caps: 0.05em;
326830
+
326831
+ /* Spacing: a scale, not one step multiplied in place. */
326832
+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
326833
+ --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;
326834
+
326835
+ --radius: 10px;
326836
+ /* Warm-toned shadows too, or they grey the paper. */
326837
+ --shadow-card: 0 1px 2px rgba(60, 44, 32, 0.05);
326838
+ --shadow-pop: 0 8px 28px rgba(60, 44, 32, 0.12);
326839
+
326840
+ --dur-fast: 120ms; --dur-base: 200ms; --dur-loop: 1400ms;
326841
+ --ease: cubic-bezier(0.32, 0.72, 0, 1);
326842
+
326843
+ /* Component dimensions. The spacing scale governs gaps and padding; an
326844
+ intrinsic size like a table row height is its own token, not a violation. */
326845
+ --row-h: 34px;
326846
+ }
326847
+ \`\`\`
326848
+
326849
+ The spacing scale covers **gaps and padding**. A component's intrinsic dimension - a table row height, a control height, a sidebar width - will not always land on that ladder, and forcing it there makes rows cramped or loose. Give each such dimension its own named token instead, and use it everywhere that component appears; the rule is one source of truth per value, not that every value divides by four.
326850
+
326851
+ Honour the viewer's motion preference wherever you use those durations:
326852
+
326853
+ \`\`\`css
326854
+ @media (prefers-reduced-motion: reduce) {
326855
+ *, *::before, *::after {
326856
+ animation-duration: 0.01ms !important;
326857
+ animation-iteration-count: 1 !important; /* or an infinite loop still loops, just faster */
326858
+ transition-duration: 0.01ms !important;
326859
+ scroll-behavior: auto !important;
326860
+ }
326861
+ }
326862
+ \`\`\`
326863
+
326864
+ Charts then resolve their colors from the contract rather than carrying literals:
326865
+
326866
+ \`\`\`js
326867
+ const token = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
326868
+ const palette = () => ["--c1","--c2","--c3","--c4","--c5","--c6","--c7","--c8"].map(token);
326869
+ \`\`\`
326870
+
326871
+ ## Craft rules that carry most of the visual quality
326872
+
326873
+ - **Respect domain convention.** Finance shows negatives in parentheses, zero as a dash, and usually wants a unit toggle (units vs thousands vs millions); ops wants thresholds, staleness, and a clear "as of" time. Getting these wrong is what marks an app as generic to the people who live in the domain, regardless of how good the layout is.
326874
+ - **Format at the edge, once.** Currency, percent, compact notation, and date labels belong in one shared formatter module, not re-derived per tile. Divergent rounding between two tiles showing the same measure is a bug users notice and lose trust over.
326875
+ - **Every tile states what it is.** A title, plus a subtitle giving the definition or the window ("trailing 8 weeks", "excludes internal users"). A number whose definition is not on screen invites the wrong conclusion, and the person drawing it is rarely the person who built the model.
326876
+ - **Reserve space for async content.** Give each tile its height before its query resolves, or the page reflows as tiles land and anything the user was reading jumps.`
326877
+ },
326878
+ {
326879
+ name: "malloy-data-app-design/visual-craft",
326880
+ description: "Visual craft. Reference detail for the malloy-data-app-design skill.",
326881
+ body: `# Visual craft
326882
+
326883
+ > \`token-contract.md\` is the floor: it buys **consistency**, and an app can satisfy every rule in it and still look like an internal admin tool. This file is the ceiling. Read it when the app should feel like a product someone chose, not a dashboard someone generated.
326884
+
326885
+ The failure this prevents is specific and it is easy to miss, because nothing about it is *wrong*: every tile is the same rounded rectangle, on the same surface, with the same border, laid out in a uniform grid. Tokens are all correct. Hierarchy exists on paper - the lead tiles are bigger - but they are the same object bigger, so the page still reads flat. Measured on a real build, that app cleared every token rule and still read as generic.
326886
+
326887
+ Seven things separate that from an app that feels designed. None of them is decoration; each is doing a job.
326888
+
326889
+ ## 1. Fewer containers, then elevation
326890
+
326891
+ **Start by deleting boxes.** One \`--surface\` used eleven times is a grid of boxes, and the fix is not a better box or a taller ladder: it is that most content does not want a container at all. Sections separated by a hairline and generous whitespace on a single canvas read as a designed page; the same sections wrapped in eleven bordered, shadowed rectangles read as a generated one, no matter how good the tokens are.
326892
+
326893
+ This is the correction that mattered most on a real build. An app that satisfied every rule in this file, with a full four-level ladder and no \`.card\` class anywhere, still read as generic because every tile sat in its own container. Redrawing it as one sheet divided by hairlines, with exactly one lifted surface (the drawer), was the single biggest change.
326894
+
326895
+ So the order is:
326896
+
326897
+ 1. **Default to no container.** A heading, a subtitle and the content, on the page ground. Let whitespace and a hairline do the separating.
326898
+ 2. **Add a container only when something must read as a separate plane** - something that floats over the page (a drawer, a popover, a tray), or one region that genuinely leads (a hero band).
326899
+ 3. **Then** give the few surfaces you kept a real elevation difference, using the ladder below.
326900
+
326901
+ A useful check: count the bordered rectangles on your first screen. More than two or three and you are almost certainly boxing things that would read better as bare sections.
326902
+
326903
+ Where you do keep a surface, a designed page has a small, deliberate **elevation ladder**, and a level says what a thing is.
326904
+
326905
+ | Level | What sits here | How it reads |
326906
+ |---|---|---|
326907
+ | 0 | page ground | the darkest (or lightest) plane, nothing floats on it directly |
326908
+ | 1 | ordinary tiles | the working surface, subtle border, minimal shadow |
326909
+ | 2 | the lead tiles, an open drawer | lifted: lighter surface, a real shadow, often a soft gradient |
326910
+ | 3 | popovers, menus, tooltips | clearly above everything, strongest shadow, no border needed |
326911
+
326912
+ Three things make a level read as lifted, and you generally want at least two:
326913
+
326914
+ - **A lighter surface**, not a different hue. A gradient of 2-4% over the base surface is enough, and it beats a flat fill because the top edge catches light.
326915
+ - **A shadow that matches the level.** A 1px shadow at level 2 is indistinguishable from level 1, which is why the "shadow-card / shadow-pop" pair in the contract is a minimum, not a palette.
326916
+ - **A hairline highlight on the top edge** (\`inset 0 1px 0 rgba(255,255,255,.06)\` on dark). This is the cheapest trick in the file and it does more than a shadow: it is what makes a surface look like a physical panel rather than a coloured rectangle.
326917
+
326918
+ Borders are for *separation*, not elevation. A tile that is lifted needs less border, not more.
326919
+
326920
+ Elevation has consequences for what sits *inside* a panel, and both of these bite immediately: a sticky table header inside a tile needs its own opaque surface background, or rows scroll visibly underneath it, and it needs a stacking context, or in-cell bars and chips draw over it.
326921
+
326922
+ ## 2. The accent must appear at scale, or the app is monochrome
326923
+
326924
+ A palette with a brand accent that only ever appears on a 2px hairline and a sort arrow reads as grey. The accent has to carry something structural: the selected row, the primary series in every chart, the focused control, the active nav item, the one number that matters most on the page.
326925
+
326926
+ The discipline that keeps this from turning gaudy: **one accent moment per region**, where a region is the unit the eye scans as a group - a row of lead tiles is one region, not three; a table is one; a chart is one. The eye should be able to find "the thing this section is about" without reading. If two things in a region are accented, neither is.
326927
+
326928
+ Reserve the semantic colours (positive/negative) for *values*, never for chrome. A green button and a green number in the same view means green stopped meaning anything.
326929
+
326930
+ ## 3. Charts need craft, not just a correct type
326931
+
326932
+ A correct chart type drawn with library defaults is the single biggest tell of a generated app.
326933
+
326934
+ **This section applies to a chart you draw by hand as well.** A small SVG written directly is often the right call for a sparkline, a gauge or a single annotated shape, and it does not escape any of the rules below: hand-drawn text still collides with the marks it labels, still needs a measured gutter, and still needs the grid turned down. The only thing that changes is that there is no library default to turn down, which makes it easier to get right and easier to forget. The chart is usually the largest object on the page and it is where the least design attention goes.
326935
+
326936
+ - **Label the data directly.** A legend makes the reader look away from the marks and back again. Put the series name at the end of its line, the category on its bar, the notable points on the scatter. A legend is a fallback for when direct labelling genuinely will not fit. Expect to hand-write this: most chart libraries have no direct-label or annotation layer, so it is a small custom plugin drawing on the canvas. Two traps make the first attempt look broken - text drawn at the plot's right edge prints *over* the marks, so reserve a gutter in the layout and draw past the edge into it, and that gutter has to be **measured** with the same font or the longest label clips.
326937
+ - **A scatter where you cannot identify a point is a texture, not a chart.** Label the outliers and the extremes, at minimum. If the user has selected an entity, that point is accented and every other point recedes.
326938
+ - **Recede the grid.** Gridlines should be the faintest thing in the frame - they are scaffolding, not data. Kill the ones you do not need entirely, and never draw a border around the whole plot.
326939
+ - **Annotate the meaning.** A quadrant chart with unlabelled quadrants makes every reader derive the same insight from scratch. "Good offence, poor defence" in the corner costs nothing and is the actual content.
326940
+ - **Turn the library's defaults down before anything else.** Every chart library ships weights tuned to be visible in a demo: thick bars in a saturated primary, 1px gridlines at full opacity, heavy axis lines, large points. A chart left at those defaults is recognisable as a default from across the room, however good the form choice was. Concretely: bars and lines take the palette token rather than the library colour, gridlines drop to the faintest token you have (or disappear), axis lines and tick marks go off, and point radius comes down until the marks stop competing with each other. This is usually a dozen lines of options and it is the difference between "a chart" and "a designed chart".
326941
+ - **Ink goes to data.** Axis lines, tick marks, and borders are almost always too heavy at library defaults. Turn them down until the data is the only confident thing in the frame.
326942
+
326943
+ ## 4. Density: a number earns its space
326944
+
326945
+ A 44px number with a large empty region under it is not emphasis, it is unfinished layout. Emphasis comes from *contrast with its neighbours*, not from absolute size, so a big number in a sparse card reads as smaller than a medium number in a dense one.
326946
+
326947
+ Two rules of thumb:
326948
+
326949
+ **Three equal KPI cards across the top is the most recognisable generated-app tell there is.** Every generated dashboard opens with it, and it is wrong twice over: three identical containers repeated side by side is repetition rather than a headline, and three equal-weight numbers mean the page has no subject. Build **one hero band** instead - a single region, panels divided by hairlines rather than gaps, and the leading number given a genuinely larger type step than its two supporting neighbours. The reader should be able to tell which number the page is about without reading a word.
326950
+
326951
+ - **A lead tile should carry its supporting evidence,** not just its headline: the number, a comparison (versus last period, versus the field), and a small trend. That is what fills the space honestly, and it is more useful than the number alone.
326952
+ - **Give the hero at most a third of the first screen.** If the three lead tiles push the actual content below the fold, the page has led with its title rather than its substance. An explorer especially should show the population immediately.
326953
+
326954
+ The most common structural cause of a sparse-looking page is not typographic at all, and it is invisible in a stylesheet diff: **a card whose content has a fixed height, sitting in a stretch-aligned grid row next to a taller sibling.** The card stretches, the content does not, and the difference becomes dead space under a chart. Either let the content flex to fill its card, or stop the row from stretching. Check this first when a page looks empty but every tile is correct.
326955
+
326956
+ **An in-cell encoding needs its own column, not the cell behind the number.** A magnitude bar drawn *behind* a value is the muddiness problem again - a translucent wash under text that reads as a rendering artefact - and a short rule *under* each number is thirty unrelated stubs. What works is a narrow column of its own holding a diverging rule anchored at a shared zero: it becomes one scale you can scan down the table, and the number stays clean. Drop the encoding only if you cannot give it that column.
326957
+
326958
+ Tables are where density pays. A stat table with generous row height wastes the one format that can show twenty things at once - tighten the rows, right-align the numbers, use tabular figures, and let the table be dense on purpose.
326959
+
326960
+ ## 5. Motion is feedback, not decoration
326961
+
326962
+ Every state change the user causes should be acknowledged, and nothing else should move.
326963
+
326964
+ - **Hover states on everything interactive**, and they must be visible: a surface shift, not a 2% opacity change nobody perceives. A row that opens a drawer on click must look clickable before the click.
326965
+ - **Transitions belong on state, not on load.** Animating *content* in on first paint delays the thing the reader came for. Animating a drawer open, a filter chip appearing, a sort re-ordering: those are feedback, and they are still right when the state came from the URL on first load.
326966
+ - **Focus states are not optional and not the browser default.** A keyboard user needs a visible ring drawn from the accent.
326967
+ - Keep it fast. Anything over ~250ms on a UI transition feels sluggish; the contract's \`--dur-fast\` / \`--dur-base\` pair exists so these stay consistent.
326968
+
326969
+ ## 6. Type carries personality
326970
+
326971
+ The default stack at 400/600/700 is the house style of every admin panel. Two cheap decisions move an app a long way:
326972
+
326973
+ - **Pair a display face with the UI face.** Numbers are usually the subject in a data app: setting the headline figures in something with character (a tighter grotesque, a condensed face, or the same family at a much heavier weight with tight tracking) separates "the answer" from "the interface" before anyone reads a word. **Bundle the face with the app** - a font file in the package, never a CDN or a hosted-fonts stylesheet, for the same reason chart libraries are vendored: the page may be served somewhere with no network egress, and a font that silently fails to load takes the whole typographic idea with it. A *variable* font is the cheap win here, since one file buys the heavy display weight the contract's regular ceiling otherwise blocks.
326974
+ - **Use tabular figures everywhere numbers align**, and proportional ones inside prose and names. Mixed tabular figures in a proper noun look broken.
326975
+
326976
+ If the app uses one family, get the contrast from weight and tracking instead: display numbers at the heaviest weight with negative tracking, labels small at uppercase with positive tracking, body in the middle. The gap between the extremes is what reads as designed.
326977
+
326978
+ **Vendor assets, and inline the small ones.** A chart library gets copied into the package; a logo or icon mark should be **inlined into the markup** rather than linked, for two reasons. A linked file fails silently to a blank square when the page is served from somewhere with no network egress, which is exactly where these apps run. And an inlined SVG can carry \`fill="currentColor"\` instead of a baked hex, so the mark follows a theme swap like everything else. Strip any XML prolog, DOCTYPE and editor metadata first, and keep the source file and its licence alongside the vendored library.
326979
+
326980
+ ## 7. It has to survive a narrower window
326981
+
326982
+ A data app is a wide layout full of the two things that refuse to shrink: tables and charts. It will be opened in a half-screen window, an embedded panel, and a laptop narrower than yours, and the failure is not subtle - text prints on top of a plot.
326983
+
326984
+ **Collisions do not show up as page overflow.** This is the trap. The obvious check is \`document.documentElement.scrollWidth > clientWidth\`, and on a real build that came back clean at every width *while a standings table was visibly overlapping the scatter chart beside it*. A grid column clips nothing by default, so its content happily draws over the neighbour without widening the document.
326985
+
326986
+ The obvious next idea is to compare bounding rects instead, and **that lies too, in exactly the case the fix below creates**. Once you cap a table's container and let it scroll inside itself, the table's own rect is legitimately wider than its container while being visually clipped - so a naive rect comparison reports a collision on every scrollable table on the page. Measured on a real build: false positives at four widths, all of them fine. If you automate this at all, walk the ancestors and ignore overflow inside a **scrollable** box (\`overflow-x: auto | scroll\`), where the content is reachable. Do not extend that to a clipping box (\`hidden\`, \`contain\`): content lost inside one of those is a real bug, and suppressing it is how you stop seeing the thing you were looking for.
326987
+
326988
+ **The only check that does not lie is rendering at several widths and looking at the screenshots.** Automate the capture, not the judgement.
326989
+
326990
+ Three rules carry most of it:
326991
+
326992
+ - **A table will exceed its grid column.** Its min-content width is the sum of its columns, so \`grid-template-columns: minmax(0, 1fr)\` is not enough on its own: the track can shrink while the table inside it does not. Cap the table's container explicitly and let it scroll inside itself. This is the single most common collision in a data app.
326993
+ - **Give up the least useful thing first, in stages.** Two breakpoints that collapse every two-column band at once turn a wide page into a very tall one - three full-width headline panels, each with a sparkline stretched across the sheet. A workable progression: narrow the chrome first, then let the headline drop a column, then stack the content bands, then turn a side rail into a top bar, and only then let wide tables scroll.
326994
+ - **A chart's minimum useful width is a design decision, not a default.** A sparkline stretched over a full sheet flattens into a horizon and stops reading as a trend; cap it. A scatter below roughly 320px cannot carry axis labels and marks at once; stack it instead.
326995
+
326996
+ Also check the things that live outside the normal flow, because they are easy to forget and they are where the real breakage hides: an off-canvas drawer (does it become full-width, and does its internal grid drop to two columns?), a fixed bottom tray (does it wrap?), a sticky header (does it still clear the content?), and any element whose width is set in JavaScript rather than CSS.
326997
+
326998
+ ## The check
326999
+
327000
+ Before calling the visual pass done, look at a screenshot of the first screen and answer honestly:
327001
+
327002
+ - Can you tell what the page is *about* in one second, without reading?
327003
+ - Is there anything on screen that a reader would call the focal point?
327004
+ - How many bordered rectangles are on the first screen? More than two or three means you are boxing things that should be bare sections.
327005
+ - Does the page open with three equal KPI cards? If so it has no subject yet.
327006
+ - Could you tell the palette was chosen, or is it the default cool slate and mid-blue?
327007
+ - Do the charts look like the library's defaults - thick saturated bars, full-opacity gridlines, visible axis lines?
327008
+ - Could you identify a specific entity in each chart, or only see a shape?
327009
+ - Does the accent appear anywhere that matters?
327010
+ - Would you describe the page as dense, or as large elements with gaps between them?
327011
+ - Does it still hold together at 1200px and at 900px? (Render it and look. An overflow check will not catch a collision.)
327012
+
327013
+ A "no" to most of these describes an app that satisfies the token contract and nothing more, which is exactly the app this file exists to prevent.
327014
+
327015
+ ## When restyling is not working, build a mockup instead
327016
+
327017
+ If two passes over an existing app have produced "better, but still generic", stop restyling it. Editing inside the app's own markup keeps reproducing the app's structure: the cards are already there, the interactions constrain the layout, and every change becomes a local correction to something that is wrong globally.
327018
+
327019
+ Build a **static mockup** instead - one self-contained HTML file, real numbers pasted in from a handful of queries, no framework, no live data, no interaction. It costs an hour and it removes every constraint that was keeping the design where it was. On a real build this is what surfaced a side rail replacing the top bar and a single canvas replacing eleven cards; neither was reachable by editing the running app, and both ported back cleanly afterwards because the app's render targets were stable ids.
327020
+
327021
+ Two things make the port work: **use real numbers in the mockup** (fake data hides the widths that will actually break), and **agree the mockup before porting**, because the port is mechanical and the design decisions are not.`
326208
327022
  },
326209
327023
  {
326210
327024
  name: "malloy-debug",
@@ -326713,7 +327527,7 @@ Do not rely on \`body { min-height: 100vh }\` to drive the frame height. The run
326713
327527
  {
326714
327528
  name: "malloy-html-data-app-runtime",
326715
327529
  description: "The JavaScript that drives an in-package HTML data app - calling Publisher.query, building queries from filter state, handling results and errors. For writing the page's data code.",
326716
- body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName` (`queryName` runs a saved query, with `sourceName` qualifying the source a view hangs off; `sourceName` on its own is a 400), `givens` (a `{ name: value }` map bound to the model\'s Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by Publisher server-side, not string-interpolated), `filterParams` (values for the model\'s legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.\n\n```html\n<!-- Globals first: the runtime, then any vendored chart library. -->\n<script src="/sdk/publisher.js"></script>\n<script src="./vendor/chart.umd.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese assume a `subscriptions.malloy` model whose `subscriptions` source defines the views `plan_mix`, `mrr_by_industry`, and `kpis`. The names are illustrative; swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.plan) parts.push(`plan = \'${q(state.plan)}\'`);\n if (state.industry) parts.push(`industry = \'${q(state.industry)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "subscriptions.malloy",\n `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: Publisher binds those values server-side as typed parameters, so they are never concatenated into query text and can\'t inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a trusted tier upstream sets it from verified identity. Publisher has no per-package control that strips or finalizes one; identity-bound givens are a planned milestone, not a shipped feature. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");\nel.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [planMix, byIndustry, kpisRows] = await Promise.all([\n Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The `storefront` example draws with Chart.js and has no `<malloy-render>` element in its pages, so the component itself is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn\'t fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |\n| 403 on a query that should be allowed, when passing givens to a gated source | On a gated source, a bad given (unknown name or wrong-typed value) fails closed, so it looks like access denied rather than validation. A `#(authorize)` lock also answers 403 for a caller it simply does not admit. Check the given names and values against the model. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |'
327530
+ body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName` (`queryName` runs a saved query, with `sourceName` qualifying the source a view hangs off; `sourceName` on its own is a 400), `givens` (a `{ name: value }` map bound to the model\'s Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by Publisher server-side, not string-interpolated), `filterParams` (values for the model\'s legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.\n\n```html\n<!-- Globals first: the runtime, then any vendored chart library. -->\n<script src="/sdk/publisher.js"></script>\n<script src="./vendor/chart.umd.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese assume a `subscriptions.malloy` model whose `subscriptions` source defines the views `plan_mix`, `mrr_by_industry`, and `kpis`. The names are illustrative; swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.plan) parts.push(`plan = \'${q(state.plan)}\'`);\n if (state.industry) parts.push(`industry = \'${q(state.industry)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "subscriptions.malloy",\n `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: Publisher binds those values server-side as typed parameters, so they are never concatenated into query text and can\'t inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a trusted tier upstream sets it from verified identity. Publisher has no per-package control that strips or finalizes one; identity-bound givens are a planned milestone, not a shipped feature. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");\nel.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [planMix, byIndustry, kpisRows] = await Promise.all([\n Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The `storefront` example draws with Chart.js and has no `<malloy-render>` element in its pages, so the component itself is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn\'t fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |\n| 403 on a query that should be allowed, when passing givens to a gated source | On a gated source, a bad given (unknown name or wrong-typed value) fails closed, so it looks like access denied rather than validation. A `#(authorize)` lock also answers 403 for a caller it simply does not admit. Check the given names and values against the model. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| A NEW file under `public/` 404s while the rest of the app serves | The server loaded the package **before** `public/` existed, and it is not watching. Without `--watch-env` the package is COPIED into `publisher_data/<env>/<pkg>/` at load and static files are served from that copy, so a file you add afterwards is never seen. Restart with `--watch-env <env>`, or mirror the file into the served copy. Verified by writing a probe file into the real package and getting a 404 while the copy served fine - and note the existing row below sends you looking under `public/`, which is exactly the wrong place for this one. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |'
326717
327531
  },
326718
327532
  {
326719
327533
  name: "malloy-html-data-apps",
@@ -326753,35 +327567,37 @@ Only \`public/\` is reachable over the web, at \`/environments/<env>/packages/<p
326753
327567
 
326754
327568
  The agent orchestrates these. Each query and chart step hands off to a focused skill.
326755
327569
 
326756
- 1. READ THE MODEL FIRST. Get the model's real source and view names, through your environment's context tool if it has one, or by opening the \`.malloy\` file directly. Never guess field or view names.
326757
- 2. SCAFFOLD the package (template below).
326758
- 3. WRITE THE QUERIES with \`skill:malloy-html-data-app-runtime\`. Validate each before pasting it into the page, using whatever query tool your environment provides or a running Publisher (see \`skill:malloy-html-data-app-runtime\`). Malloy syntax questions go to \`skill:malloy-queries\`.
326759
- 4. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The \`storefront\` example ships its chart library in \`public/vendor/\` and loads it from \`public/index.html\` as \`./vendor/chart.umd.js\`. Copy that, but resolve the path against the page's own directory: a page in a subdirectory (\`public/reports/index.html\`) needs \`../vendor/chart.umd.js\`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid.
326760
- 5. EMBED (optional) with \`skill:malloy-html-data-app-embedding\`.
326761
- 6. PREVIEW with the local authoring loop (below).
326762
- 7. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
327570
+ 1. DESIGN IT FIRST with \`skill:malloy-data-app-design\`: who opens it, the decision it serves, the archetype, the form each tile takes, and the depth plan. Skipping this is what produces an app that enumerates the model - a KPI row and a chart grid, whatever the domain - instead of one that answers a question. Hand the brief to the user before scaffolding.
327571
+ 2. READ THE MODEL. Get the model's real source and view names, through your environment's context tool if it has one, or by opening the \`.malloy\` file directly. Never guess field or view names.
327572
+ 3. SCAFFOLD the package (template below).
327573
+ 4. WRITE THE QUERIES with \`skill:malloy-html-data-app-runtime\`. Validate each before pasting it into the page, using whatever query tool your environment provides or a running Publisher (see \`skill:malloy-html-data-app-runtime\`). Malloy syntax questions go to \`skill:malloy-queries\`.
327574
+ 5. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The \`storefront\` example ships its chart library in \`public/vendor/\` and loads it from \`public/index.html\` as \`./vendor/chart.umd.js\`. Copy that, but resolve the path against the page's own directory: a page in a subdirectory (\`public/reports/index.html\`) needs \`../vendor/chart.umd.js\`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid.
327575
+ 6. EMBED (optional) with \`skill:malloy-html-data-app-embedding\`.
327576
+ 7. PREVIEW with the local authoring loop (below).
327577
+ 8. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
326763
327578
 
326764
- The scaffold in step 2 only proves the wiring. It is the start, not the deliverable. What you ship is a production app that meets the recipe below.
327579
+ The scaffold in step 3 only proves the wiring. It is the start, not the deliverable. What you ship is a production app that meets the recipe below.
326765
327580
 
326766
327581
  ## What "done" means (production recipe)
326767
327582
 
326768
327583
  A data app you can defend has all of these. Build to this list, not to the scaffold.
326769
327584
 
326770
- - **Real names, never guessed.** Every source, view, and field name comes from the model you read in step 1. A name you derived or assumed is a bug waiting to surface as an empty tile.
327585
+ - **Real names, never guessed.** Every source, view, and field name comes from the model you read in step 2. A name you derived or assumed is a bug waiting to surface as an empty tile.
327586
+ - **A real name is not the same as a correct measure.** Using the model's declared measures is right by default, but a wrong one fails far more quietly than a wrong name: an empty tile is obvious, a plausible wrong number is not. Before a measure reaches a headline, confirm it against the raw column values (\`skill:malloy-data-app-design\` step 2 of the inventory). The case that bites is a predicate like \`!= null\` over a column whose "absent" value is a sentinel string - measured on a real build, that reported 95.6% of police cases cleared where the true figure was 16.6%, and every check in this skill still passed.
326771
327587
  - **DOM-only - never \`innerHTML\` with interpolated values.** Build every element with \`createElement\` + \`textContent\`; do not assign \`innerHTML\` (or \`insertAdjacentHTML\`, \`document.write\`) with any string that contains a model value. Query results render any markup they contain - an XSS vector, and blocked outright under a Trusted-Types CSP. This is a hard build rule, not a lint suggestion: an app that interpolates a model value into \`innerHTML\` is not done.
326772
327588
  - **Modular, not one inline blob.** Split the page into modules per \`skill:malloy-html-data-app-runtime\` (pure formatting helpers, a chart layer, your tile/query definitions as data, a thin entry point). One source of truth for each tile's model/source/view, no parallel maps that drift.
326773
327589
  - **Every tile handles loading, empty, and error on its own.** One failing query must not blank the page. (\`skill:malloy-html-data-app-runtime\`.)
326774
327590
  - **Defensible numbers.** Missing ≠ zero (omit the point, don't plot a fake 0); show the latest non-null value for "current"; guard division with \`nullif\`; convert units explicitly. (\`skill:malloy-html-data-app-runtime\`.)
326775
327591
  - **Visible assumptions.** When you assume something (two sources joined by month, an in-month proxy that differs from a certified definition) or a metric is incomplete, say so *in the app*: a caption, a footnote, a placeholder card with the reason. The non-technical user cannot see your reasoning; bury a caveat and you have misled them. Don't silently drop a metric you couldn't model. Show a placeholder that names what's missing and why.
326776
- - **Looks decent.** Give it real layout, type, and color: a styled card grid with a clear hierarchy, not raw unstyled tables.
326777
- - **Vendored libraries.** Chart and helper libraries live in \`public/\`, loaded locally (step 4).
326778
- - **Lazy-load below the fold (once there are many tiles).** Don't fire every tile's query on load. \`reference/lazy-load.md\` is the recipe: \`IntersectionObserver\` (rootMargin ~240px) + a small concurrency cap + reserve each tile's height so lazy tiles don't reflow. Includes the verification trap - on a short/tall-default viewport all tiles intersect at once and you get a false "everything deferred" pass, so test on a deliberately small viewport.
327592
+ - **Built to the design brief, not to the model's shape.** The app matches the archetype, forms, and depth plan from step 1 (\`skill:malloy-data-app-design\`), carries a real token contract (surfaces, text ramp, semantic colors, a categorical chart palette) that the chart code reads at runtime rather than hardcoding, and puts visible weight on the two or three numbers that lead. A uniform grid of same-size cards, or a tile per view, means the brief was skipped.
327593
+ - **Vendored libraries.** Chart and helper libraries live in \`public/\`, loaded locally (step 5).
327594
+ - **Lazy-load below the fold, once the page has more than about eight query-backed tiles.** ("Many" is not decidable; that is the threshold.) Tiles sitting below the fold is *why* the technique works, not a second trigger for it - almost every real data app scrolls, so reading it as one makes the rule "always" and contradicts the count. Under that threshold, firing every tile at once is cheaper than the machinery. Don't fire every tile's query on load. \`reference/lazy-load.md\` is the recipe: \`IntersectionObserver\` (rootMargin ~240px) + a small concurrency cap + reserve each tile's height so lazy tiles don't reflow. Includes the verification trap - on a short/tall-default viewport all tiles intersect at once and you get a false "everything deferred" pass, so test on a deliberately small viewport.
326779
327595
 
326780
327596
  ### Verify before you call it done
326781
327597
 
326782
327598
  You are building for someone who cannot tell a correct dashboard from a broken one. Verification is your job, not theirs.
326783
327599
 
326784
- - **Validate every query against the model before wiring it in** (step 3): confirm it compiles and that the column names match what your render code reads.
327600
+ - **Validate every query against the model before wiring it in** (step 4): confirm it compiles and that the column names match what your render code reads.
326785
327601
  - **Load the finished page and confirm every tile shows real numbers**: not stuck on "Loading…", not an error, not an empty state you didn't intend. In a headless browser, wait on \`load\` plus a content selector, not network idle (\`publisher.js\` holds an SSE stream open; see \`skill:malloy-html-data-app-runtime\`). Don't hand-roll this each time - \`reference/verification-harness.md\` is a copy-adaptable recipe: a mock \`sdk/publisher.js\` returning canned rows keyed by \`(model, query)\`, a \`python3 -m http.server\` webroot, and Playwright assertions (KPIs non-null, no \`.is-error\`, no stuck \`.kit-skeleton\`, a chart/table present). It also documents the false-"stuck skeleton" trap (assert after the mock's async delay, never on \`networkidle\`).
326786
327602
  - **Unit-test any non-trivial pure logic** (a month-join, a de-cumulation, a unit conversion). Keep that logic in DOM-free helpers so \`node --test\` can cover it, and run it.
326787
327603
 
@@ -326856,6 +327672,10 @@ Run a local Publisher from the directory that holds your \`publisher.config.json
326856
327672
  npx @malloy-publisher/server --server_root . --port 4000 --watch-env <env>
326857
327673
  \`\`\`
326858
327674
 
327675
+ **\`--watch-env\` is not optional if you are adding \`public/\` to a package that is already loaded.** Without it the server COPIES each package into \`publisher_data/<env>/<pkg>/\` at load time and serves static files from that copy, so a \`public/\` directory you create afterwards is never seen: every file in it 404s while the rest of the app serves normally. This is the most expensive mistake in this skill, because it looks like your page is broken rather than like the server is stale,.
327676
+
327677
+ If you did not start the server (someone handed you a running one), **probe before you build**: write a throwaway file into the package's \`public/\`, request it, and see whether you get 200 or 404. If it 404s, either restart with \`--watch-env <env>\` or mirror your files into the served copy after every edit.
327678
+
326859
327679
  \`--watch-env <env>\` (or \`PUBLISHER_WATCH=<env>\`) mounts that environment's local-dir packages in place (a symlink, not a copy) and watches them: editing a \`.malloy\` recompiles the package, and editing a \`public/\` file live-reloads any open page over an SSE stream. Nothing to wire in the page. The app is served at \`http://localhost:4000/environments/<env>/packages/<pkg>/index.html\`.
326860
327680
 
326861
327681
  \`publisher.config.json\` (at \`--server_root\`) declares the environment, its packages, and its connections:
@@ -326980,9 +327800,11 @@ await page.waitForFunction((n) => document.querySelectorAll(".tile .value").leng
326980
327800
 
326981
327801
  > Scaffolding to verify a finished data app end-to-end without a live warehouse. Referenced from \`SKILL.md\` ("Verify before you call it done"). You are building for someone who cannot tell a correct dashboard from a broken one; this harness is how you check, so they don't have to.
326982
327802
 
326983
- The app talks to the world through exactly one seam: \`window.Publisher.query(modelPath, malloy)\`. Mock that seam and you can load the real page in a real browser with canned data, then assert on what actually rendered.
327803
+ **If you have a live Publisher serving real data, verify against that first.** It is strictly the stronger check: it exercises the real queries, the real column names and the real edge cases (empty results, nulls, a season with no data) that canned fixtures are exactly the thing to miss. Mock only what the live server cannot give you.
327804
+
327805
+ The mock below is for when there is no live server, or when you need a specific shape on demand - an empty result, a failing query, a tile whose data does not exist yet. The app talks to the world through exactly one seam: \`window.Publisher.query(modelPath, malloy)\`. Mock that seam and you can load the real page in a real browser with canned data, then assert on what actually rendered.
326984
327806
 
326985
- ## 1. Mock \`sdk/publisher.js\`
327807
+ ## 1. Mock \`sdk/publisher.js\` (when there is no live server)
326986
327808
 
326987
327809
  Serve a stand-in at the same root-relative path the page loads (\`/sdk/publisher.js\`). It returns canned rows keyed by \`(modelPath, query)\`, and (this is the part that bites) it reproduces the **async delay** of the real runtime, so your assertions exercise the loading→loaded transition instead of racing a synchronous stub.
326988
327810
 
@@ -327028,7 +327850,14 @@ server=$!
327028
327850
  trap 'kill "$server" 2>/dev/null; rm -rf "$webroot"' EXIT # always tear the server down
327029
327851
  \`\`\`
327030
327852
 
327031
- ## 2. Drive it with Playwright and assert on the rendered DOM
327853
+ ## 2. Drive it in a REAL browser and assert on the rendered DOM
327854
+
327855
+ **It must be a real browser engine. jsdom cannot verify this app, and it fails in the most dangerous possible way.** jsdom does not execute \`<script type="module">\` at all, and it reports no error for it - so a run comes back with no console errors, no error tiles and no stuck skeletons, which looks like a clean pass while *none of your code ran*. That is exactly the false green this document exists to prevent, and it is worse because \`skill:malloy-html-data-app-runtime\` requires the ES-module entry point that breaks it. jsdom also lacks \`fetch\` (the query promise rejects and boot hangs), \`ResizeObserver\` (chart libraries throw, and it surfaces as your own error state), and a 2D canvas.
327856
+
327857
+ Playwright is the example below. If it is not installed and cannot be fetched - which is common in agent sandboxes - use the CDP harness at the end of this file instead. It needs nothing but a browser you already have and a WebSocket.
327858
+
327859
+
327860
+ > **If your page lazy-loads (and \`SKILL.md\` says it should past about eight tiles), a whole-page "no skeletons anywhere" predicate will time out and report a failure that is not real.** Below-fold tiles are *supposed* to still be skeletons. The wait below is therefore scoped to tiles currently in the viewport, which is correct for both a lazy and a non-lazy page. The trade runs the dangerous way, so be explicit about it: **this predicate cannot fail on a tile it cannot see**, so a below-fold tile that never resolves passes silently. On a lazy page the viewport wait is necessary but **not sufficient** - the scroll-and-assert loop in \`reference/lazy-load.md\` is what actually covers the deferred tiles, and a lazy page is not verified without it.
327032
327861
 
327033
327862
  \`\`\`js
327034
327863
  // verify.mjs: node verify.mjs (assumes the server above is on :4173)
@@ -327047,35 +327876,46 @@ await page.goto("http://localhost:4173/index.html", { waitUntil: "load" });
327047
327876
  // false "stuck skeleton" / "empty value" FAIL. "Resolved" = every tile has a
327048
327877
  // value and no skeleton remains. This single wait subsumes the DELAY_MS delay
327049
327878
  // AND the stuck-skeleton check; if it times out, a tile really is stuck.
327050
- // NOTE: assumes a NON-lazy page, every tile loads on open. For a lazy-loaded
327051
- // page (reference/lazy-load.md) below-fold tiles legitimately keep their
327052
- // skeletons until scrolled in, so this wait would (correctly) time out. Test a
327053
- // lazy page with the scroll-and-assert loop in lazy-load.md instead.
327879
+ // SCOPED TO THE VIEWPORT on purpose - see the warning above this block.
327054
327880
  await page.waitForFunction(() => {
327055
- const tiles = [...document.querySelectorAll(".tile")];
327881
+ // Only tiles that are actually on screen are expected to have resolved. On a
327882
+ // non-lazy page that is every tile; on a lazy page it is the ones above the
327883
+ // fold, and the rest resolve as you scroll. Filtering here means the SAME
327884
+ // predicate works for both, instead of timing out on the lazy page that
327885
+ // SKILL.md tells you to build.
327886
+ const onScreen = (el) => {
327887
+ const r = el.getBoundingClientRect();
327888
+ return r.bottom > 0 && r.top < innerHeight;
327889
+ };
327890
+ const tiles = [...document.querySelectorAll(".tile")].filter(onScreen);
327056
327891
  // Assert PER TILE, not global counts. \`.tile .value\` count >= tile count
327057
327892
  // false-FAILs a chart/table tile (it has no .value) and false-PASSes when one
327058
327893
  // multi-.value KPI tile inflates the total enough to mask a silently-empty
327059
327894
  // sibling. "Resolved" = each tile shows its OWN content: a value, a chart, a
327060
327895
  // table row, or its error state.
327061
327896
  return tiles.length > 0 &&
327062
- document.querySelectorAll(".kit-skeleton").length === 0 &&
327897
+ tiles.every((t) => !t.querySelector(".kit-skeleton")) &&
327063
327898
  tiles.every((t) => t.querySelector(".value, canvas, table tbody tr, .is-error"));
327064
327899
  }, null, { timeout: 5000 });
327065
327900
 
327066
327901
  // Per tile: flag any tile that is content-empty (nothing rendered at all) or
327067
327902
  // whose KPI values are blank/"NaN". A global .value sweep would let a valueless
327068
327903
  // tile vanish; walking tiles keeps every one accountable.
327069
- const tileReport = await page.$$eval(".tile", (tiles) =>
327070
- tiles.map((t, i) => {
327071
- const vals = [...t.querySelectorAll(".value")].map((e) => e.textContent.trim());
327072
- return {
327073
- i,
327074
- empty: !t.querySelector(".value, canvas, table tbody tr, .is-error"),
327075
- errored: !!t.querySelector(".is-error"),
327076
- badVals: vals.filter((v) => !v || /^(loading|nan|undefined|null)$/i.test(v)),
327077
- };
327078
- }));
327904
+ const tileReport = await page.$$eval(".tile", (all) =>
327905
+ // Scoped to the viewport, exactly like the wait above. A whole-page sweep
327906
+ // reports every below-fold tile as empty on a lazy page, which just moves
327907
+ // the false failure from the wait into the assertion.
327908
+ all
327909
+ .filter((t) => { const r = t.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight; })
327910
+ .map((t, i) => {
327911
+ const vals = [...t.querySelectorAll(".value")].map((e) => e.textContent.trim());
327912
+ return {
327913
+ i,
327914
+ empty: !t.querySelector(".value, canvas, table tbody tr, .is-error"),
327915
+ errored: !!t.querySelector(".is-error"),
327916
+ badVals: vals.filter((v) => !v || /^(loading|nan|undefined|null)$/i.test(v)),
327917
+ };
327918
+ }));
327079
327919
 
327080
327920
  const problems = [];
327081
327921
  const emptyTiles = tileReport.filter((t) => t.empty);
@@ -327088,9 +327928,114 @@ if (errors.length) problems.push(\`console/page errors: \${errors.join(" | ")}\`
327088
327928
 
327089
327929
  await browser.close();
327090
327930
  if (problems.length) { console.error("FAIL:\\n- " + problems.join("\\n- ")); process.exit(1); }
327091
- console.log(\`OK: all \${tileReport.length} tiles rendered content\`);
327931
+ console.log(\`OK: all \${tileReport.length} on-screen tiles rendered content\`);
327092
327932
  \`\`\`
327093
327933
 
327934
+ ## 3. When Playwright is not available: drive Chrome over CDP
327935
+
327936
+ Agent sandboxes frequently have a browser but no Playwright and no way to install one. The DevTools Protocol needs only a WebSocket, and **Node 22+ has one built in**, so this path installs nothing. The snippet below was run end to end against a real app before it was written down.
327937
+
327938
+ \`\`\`sh
327939
+ # Launch once. Pick a port nobody else is on, and your own profile directory so
327940
+ # you never collide with a browser the user is already running.
327941
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \\
327942
+ --headless=new --remote-debugging-port=9333 --no-first-run \\
327943
+ --user-data-dir=/tmp/verify-profile about:blank &
327944
+ \`\`\`
327945
+
327946
+ \`\`\`js
327947
+ // verify.mjs - node verify.mjs <app-url> [port]. No dependencies on Node 22+.
327948
+ const APP_URL = process.argv[2] ?? "http://localhost:4173/index.html";
327949
+ const PORT = process.argv[3] ?? 9333;
327950
+
327951
+ const targets = await (await fetch(\`http://localhost:\${PORT}/json/list\`)).json();
327952
+ const page = targets.find((t) => t.type === "page");
327953
+ const sock = new WebSocket(page.webSocketDebuggerUrl);
327954
+
327955
+ let id = 0;
327956
+ const pending = new Map();
327957
+ const errors = [];
327958
+ const send = (method, params = {}) =>
327959
+ new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej });
327960
+ sock.send(JSON.stringify({ id: i, method, params })); });
327961
+
327962
+ sock.addEventListener("message", (ev) => {
327963
+ const msg = JSON.parse(ev.data);
327964
+ if (msg.id && pending.has(msg.id)) {
327965
+ const { res, rej } = pending.get(msg.id);
327966
+ pending.delete(msg.id);
327967
+ // REJECT on a protocol error. Resolving with undefined turns a mistyped
327968
+ // method into a predicate that is merely false, and then waitFor burns its
327969
+ // whole timeout and blames the page.
327970
+ if (msg.error) rej(new Error(\`CDP: \${msg.error.message}\`));
327971
+ else res(msg.result);
327972
+ return;
327973
+ }
327974
+ if (msg.method === "Runtime.exceptionThrown") errors.push(msg.params?.exceptionDetails?.text ?? "exception");
327975
+ if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") errors.push("console.error");
327976
+ });
327977
+
327978
+ await new Promise((r) => sock.addEventListener("open", r, { once: true }));
327979
+ await send("Page.enable");
327980
+ await send("Runtime.enable");
327981
+ await send("Emulation.setDeviceMetricsOverride",
327982
+ { width: 1512, height: 982, deviceScaleFactor: 2, mobile: false });
327983
+ await send("Page.navigate", { url: APP_URL });
327984
+
327985
+ const evaluate = async (expr) =>
327986
+ (await send("Runtime.evaluate", { expression: expr, returnByValue: true, awaitPromise: true }))
327987
+ .result?.value;
327988
+
327989
+ // There is no waitForFunction here; poll your own predicate.
327990
+ async function waitFor(expr, timeoutMs = 30000) {
327991
+ const deadline = Date.now() + timeoutMs;
327992
+ while (Date.now() < deadline) {
327993
+ if (await evaluate(expr)) return true;
327994
+ await new Promise((r) => setTimeout(r, 250));
327995
+ }
327996
+ throw new Error(\`timed out waiting for: \${expr}\`);
327997
+ }
327998
+
327999
+ const ONSCREEN = \`(el) => { const r = el.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight; }\`;
328000
+
328001
+ await waitFor(\`document.querySelectorAll('.tile').length > 0
328002
+ && [...document.querySelectorAll('.tile')].filter(\${ONSCREEN})
328003
+ .every((t) => t.querySelector('.value, canvas, table tbody tr, .is-error'))\`);
328004
+
328005
+ console.log(await evaluate(\`(() => {
328006
+ const onScreen = \${ONSCREEN};
328007
+ const tiles = [...document.querySelectorAll('.tile')].filter(onScreen);
328008
+ // A canvas reports width 300 untouched, so "has a width" proves nothing.
328009
+ // Read the pixels: a blank chart is the failure this is looking for.
328010
+ const canvases = [...document.querySelectorAll('canvas')].filter(onScreen);
328011
+ const painted = canvases.filter((c) => {
328012
+ try { return c.getContext('2d').getImageData(0, 0, c.width, c.height).data.some((v) => v !== 0); }
328013
+ catch { return false; }
328014
+ }).length;
328015
+ return JSON.stringify({
328016
+ onScreenTiles: tiles.length,
328017
+ errorTiles: tiles.filter((t) => t.querySelector('.is-error')).length,
328018
+ stuck: tiles.filter((t) => t.querySelector('.kit-skeleton')).length,
328019
+ // Word boundaries on every alternation, or legitimate copy containing
328020
+ // "undefined" trips the check.
328021
+ badValues: /\\\\bNaN\\\\b|\\\\bundefined\\\\b|\\\\bInfinity\\\\b/.test(document.body.innerText),
328022
+ onScreenCanvases: canvases.length,
328023
+ canvasesPainted: painted,
328024
+ });
328025
+ })()\`));
328026
+ console.log("runtime errors:", errors.length ? errors : "none");
328027
+
328028
+ sock.close();
328029
+ \`\`\`
328030
+
328031
+ Everything here is scoped to the viewport, for the reason in section 2: a whole-page sweep hard-fails a lazy page. Scroll and re-run the report to cover the deferred tiles.
328032
+
328033
+ Three things bite on this path, and each makes a correct page look broken:
328034
+
328035
+ - **\`captureBeyondViewport: true\` rasterises canvases at the wrong scale.** A full-page capture squashes every chart into a corner and you will "fix" a bug that does not exist. Use viewport-only \`Page.captureScreenshot\` and scroll between shots.
328036
+ - **Switch themes by clicking the app's own control**, not by setting an attribute on the root. Setting the attribute skips whatever re-resolves chart colours, so the charts keep their construction-time palette and you get a screenshot that looks like a real theming bug.
328037
+ - **Kill the browser when you finish** and use a port nobody else is on. A stray headless Chrome holding a profile directory is a confusing thing to debug later.
328038
+
327094
328039
  ## Gotchas (each cost a real debugging cycle)
327095
328040
 
327096
328041
  - **Wait out the mock's async delay before asserting.** Asserting immediately after \`load\` reads the skeleton, not the resolved tile, and reports a false "stuck skeleton." Wait until every tile shows its own resolved content (the per-tile \`waitForFunction\` above), never on \`networkidle\`, because \`publisher.js\` keeps the live-reload SSE stream open, so the page never reaches network idle.
@@ -328600,7 +329545,7 @@ Steps to follow when the user asks a question:
328600
329545
  {
328601
329546
  name: "malloy-notebooks",
328602
329547
  description: 'Create Malloy notebooks (.malloynb) for interactive dashboards and data stories. Use when user asks to "create a notebook", "make a dashboard notebook", "write a malloynb", "data story", or needs to build reports/visualizations.',
328603
- body: "# Malloy Notebooks\n\nFiles: `.malloynb`\n\n## Scope: when to use this skill vs `skill:malloy-analysis-report`\n\nThis skill is for **curated `.malloynb` notebooks**. Interactive parameter widgets render automatically from the `given:` declarations on the models it imports (or, in older models, from deprecated `#(filter)` annotations). The notebook itself never declares parameters or filters.\n\nFor **ad-hoc reports** generated by an analysis agent, combining queries into a quick narrative artifact, use `skill:malloy-analysis-report` instead. Ad-hoc reports also inherit whatever the source declares.\n\n## Important Limitations\n\n**Compile errors in `.malloynb` files are NOT shown in the IDE linter.** You will only see errors when cells are executed. Be extra careful with syntax and test queries in the model first if unsure.\n\n## Multiple Models & Cross-Model Joins\n\nA published `.malloynb` runs each `run:` cell against the whole notebook, which compiles with **all** its `import`s in scope. So a notebook can import several `.malloy` models, and a single cell can reference, and **join**, sources from different imported files. Import each model you need in a setup cell at the top; no \"re-export\" wrapper model is required.\n\n**When the package root holds an `index.malloy`, cells read only what it exports.** A cell over a source `index.malloy` does not export answers 404, even when the notebook imports that source's file. A source an earlier cell derives from an exported one (`source: mine is customers extend { ... }`) still works. A cell's own source over a raw table (`duckdb.table(...)`) is refused. To use a source in a notebook, add it to the `export { ... }` in `index.malloy`. A package with no `index.malloy` and no legacy `explores` has no such limit.\n\n```\n>>>malloy\nimport \"flights.malloy\"\nimport \"carriers.malloy\"\n\n>>>malloy\n// joins `flights` (from flights.malloy) to `carriers` (from carriers.malloy)\nrun: flights extend {\n join_one: carriers on carrier = carriers.code\n} -> {\n group_by: carriers.nickname\n aggregate: flight_count\n}\n```\n\nTwo things still hold:\n\n- **Imports are notebook-wide, declare them at the top, never inside a `run:` cell.** A cell's query runs as a *restricted query* against the already-compiled notebook, so its imports must already be in place. You can't `import` a model inside a query cell to scope it there; an in-query import is rejected (`file imports are not permitted in a restricted query`).\n- **Compile errors surface only at publish/render, not in the IDE linter** (see above). Verify a multi-model notebook by publishing to a scratch environment and loading it, rather than relying only on single-model query checks.\n\n## No Data-Specific Insights in Markdown\n\n**Notebooks must NOT contain findings about current data values.** Data refreshes will make these stale. Use markdown for framing questions and structural narrative, not for stating results.\n\n```\n>>>markdown\n## WRONG: will become stale when data refreshes\nRevenue spiked 23% in March. Electronics drove 60% of the spike.\n\n>>>markdown\n## RIGHT: frames the question, lets the query answer it\nHow is revenue trending? Which categories are driving changes?\n```\n\n## Cell Syntax\n\nCells delimited by `>>>markdown` or `>>>malloy`:\n\n```\n>>>markdown\n# Sales Analysis\nOverview of sales trends.\n\n>>>malloy\nimport \"sales_model.malloy\"\n\n>>>markdown\n## Summary\n\n>>>malloy\nrun: orders -> summary\n\n>>>markdown\n## Monthly Trend\n\n>>>malloy\n# line_chart\nrun: orders -> by_month\n```\n\n**NEVER create `>>>malloysql` cells.** Only use `>>>malloy` and `>>>markdown`.\n\n## Interactive Parameters (`given:`)\n\nInteractive parameters are common and important. Add them to most notebooks. **Use `given:` for anything new.** The older `#(filter)` mechanism still works and is documented below, but it is deprecated: do not reach for it just because you saw it in an existing model.\n\nA notebook's parameter surface comes from the `given:` declarations on the models it imports, not from anything declared in the `.malloynb` itself. When an imported model declares givens, Publisher's notebook UI automatically renders a Parameters panel above the notebook: declared givens become parameter inputs, the values a user sets are forwarded to Malloy's runtime, and every cell re-executes against the model with those values applied.\n\nThe widget for each parameter follows the given's declared Malloy type (`string`, `string[]`, `number`, `boolean`, `date`/`timestamp`, `filter<T>`); see `docs/givens.md` for the full type table. A `#(description=\"...\")` annotation on the given renders as helper text under its input.\n\n```malloy\n##! experimental.givens\n\n#(description=\"Two-letter IATA carrier code to spotlight\")\ngiven: carrier :: string is 'WN'\n\nsource: spotlight_carrier is duckdb.table('data/carriers.parquet') extend {\n view: by_name is {\n where: code = $carrier\n select: code, name, nickname\n limit: 1\n }\n}\n```\n\nDeclare givens at the **top of the model file**, before the source that uses them, not in the notebook. `malloy-model` § Access Control covers the syntax and the `#(authorize)`/`#(access_filter)` gating story built on top of givens.\n\n## Legacy: `#(filter)` annotations\n\n> **Deprecated. Read this before copying the example below.** `#(filter)` is server-side `where:` injection, superseded by native Malloy `given:` above. Do NOT add new `#(filter)` annotations; declare runtime parameters as givens instead. `docs/givens.md` has the migration recipes. What follows is here so you can read and maintain models that already use it.\n\nFilters are declared **once, on the model's source**. The notebook itself does not declare or list filters. Publisher reads the source's filter metadata and renders the widgets above the notebook automatically, then injects `where:` clauses server-side on every cell.\n\nDeclarations live in the `.malloy` model file, above the source, using key=value parameters. The full reference (syntax, filter types, `required` / `implicit` flags) lives with the `#(filter)` guidance in the `malloy-model` skill's Parameterizable Filters section. Short example:\n\n```malloy\n#(filter) name=Manufacturer dimension=Manufacturer type=in\n#(filter) name=OrderStatus dimension=order_status type=in\n#(filter) name=Recall_After dimension=\"Report Received Date\" type=greater_than\n#(filter) name=Recall_Before dimension=\"Report Received Date\" type=less_than\nsource: recalls is duckdb.table('data/auto_recalls.csv') extend {\n measure:\n recall_count is count()\n}\n```\n\n`#(filter)` lines go **above** the `source:` declaration and reference dimensions by name (use `dimension=`). `name` defaults to the dimension name and only needs to be set when two filters target the same dimension (e.g. a date range). `type` is the comparator: `equal`, `in`, `like`, `greater_than`, `less_than`.\n\n## Notebook Style: Iterative Analysis (Default)\n\n**Build a story.** Each query should reveal something that motivates the next query. Start broad, then drill into what's interesting. Frame questions in markdown, let queries answer them.\n\n**Pattern:** Question, Query, Next Question, Drill\n\n```\n>>>markdown\n## How is revenue trending?\n\n>>>malloy\n# line_chart\nrun: orders -> { group_by: order_month; aggregate: revenue }\n\n>>>markdown\n## What's driving the biggest changes?\n\n>>>malloy\n# bar_chart\nrun: orders -> { group_by: category; aggregate: revenue; order_by: revenue desc; limit: 10 }\n\n>>>markdown\n## How does the top category break down?\n\n>>>malloy\nrun: orders -> { aggregate: order_count, avg_order_value; where: category = 'Electronics' }\n```\n\n**When to use other styles:**\n- **Dashboard with filters:** User asks for \"filterable dashboard\". Show key KPIs and breakdowns with interactive filters.\n- **EDA/summary:** User asks for \"overview\". Run views like `summary`, `by_month`, `by_category` in sequence.\n\n## View Refinement\n\nTo add `limit`, `where`, or other options to an existing view, use `+`:\n\n```malloy\nrun: source -> my_view + { limit: 15 }\nrun: source -> my_view + { where: status = 'active', limit: 10 }\n```\n\n## Template\n\n```\n>>>markdown\n# [Title]\n[Framing question: what are we trying to understand?]\n\n>>>malloy\nimport \"model.malloy\"\n\n>>>markdown\n## [First question: start broad]\n\n>>>malloy\nrun: main_source -> [broad query]\n\n>>>markdown\n## [Next question: motivated by what the first query reveals]\n\n>>>malloy\nrun: main_source -> [drill into finding]\n\n>>>markdown\n## [Deeper question]\n\n>>>malloy\nrun: main_source -> [further breakdown]\n```\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| `view_name { limit: 10 }` | Use `+`: `view_name + { limit: 10 }` |\n| `# currency` on non-money | Only use `# currency` for monetary values |\n| `#(filter) {\"type\": \"Star\"}` (JSON-blob form, on a dimension) | Unsupported legacy syntax. `#(filter)` goes **above the source** with key=value parameters (`name=`, `dimension=`, `type=`). See the `malloy-model` skill's Parameterizable Filters section. |\n| `##(filters) [...]` annotation in the notebook | Unsupported. The notebook does not declare or list filters. Publisher renders widgets automatically from the source's `#(filter)` annotations. |\n| Creating MalloySQL cells | **NEVER use `>>>malloysql`**, only `>>>malloy` and `>>>markdown` |\n| Data-specific insights in markdown | Don't write \"Revenue grew 23%.\" Frame questions instead. Data refreshes will make findings stale. |\n| `import` inside a `run:` cell (to scope a model to that cell) | Imports are notebook-wide, put them in a setup cell at the top. An in-query import is rejected: `file imports are not permitted in a restricted query` (see Multiple Models & Cross-Model Joins). |\n| Cross-model join inside a single ad-hoc report cell | A `skill:malloy-analysis-report` cell renders against a single model, so a cross-model join there won't render. Build a published `.malloynb` (which runs against the whole notebook) for a cross-model join. |\n\n## Best Practices\n\n1. Start with markdown framing the question\n2. Import each model the notebook needs in a setup cell at the top, cells can reference and join sources across all imports (see Multiple Models & Cross-Model Joins); verify a multi-model notebook by publishing to a scratch environment\n3. Each query should follow from what the previous one could reveal\n4. One query per cell\n5. Charts render only the FIRST aggregate\n6. Add interactive filters to most notebooks by declaring `#(filter)` on the model's source (see Interactive Filters), or prefer `given:` runtime parameters for new models (see Runtime Parameters)\n7. Never include data-specific findings in markdown. Frame questions, let queries answer\n\n## Done\n\nStep complete. Output: `.malloynb` notebook file."
329548
+ body: "# Malloy Notebooks\n\nFiles: `.malloynb`\n\n## Scope: when to use this skill vs `skill:malloy-analysis-report`\n\nThis skill is for **curated `.malloynb` notebooks**. Interactive parameter widgets render automatically from the `given:` declarations on the models it imports (or, in older models, from deprecated `#(filter)` annotations). The notebook itself never declares parameters or filters.\n\nFor **ad-hoc reports** generated by an analysis agent, combining queries into a quick narrative artifact, use `skill:malloy-analysis-report` instead. Ad-hoc reports also inherit whatever the source declares.\n\n## Important Limitations\n\n**Compile errors in `.malloynb` files are NOT shown in the IDE linter.** You will only see errors when cells are executed. Be extra careful with syntax and test queries in the model first if unsure.\n\n## Multiple Models & Cross-Model Joins\n\nA published `.malloynb` runs each `run:` cell against the whole notebook, which compiles with **all** its `import`s in scope. So a notebook can import several `.malloy` models, and a single cell can reference, and **join**, sources from different imported files. Import each model you need in a setup cell at the top; no \"re-export\" wrapper model is required.\n\n**When the package root holds an `index.malloy`, cells read only what it exports.** A cell over a source `index.malloy` does not export answers 404, even when the notebook imports that source's file. A source an earlier cell derives from an exported one (`source: mine is customers extend { ... }`) still works. A cell's own source over a raw table (`duckdb.table(...)`) is refused. To use a source in a notebook, add it to the `export { ... }` in `index.malloy`. A package with no `index.malloy` and no legacy `explores` has no such limit.\n\n```\n>>>malloy\nimport \"flights.malloy\"\nimport \"carriers.malloy\"\n\n>>>malloy\n// joins `flights` (from flights.malloy) to `carriers` (from carriers.malloy)\nrun: flights extend {\n join_one: carriers on carrier = carriers.code\n} -> {\n group_by: carriers.nickname\n aggregate: flight_count\n}\n```\n\nTwo things still hold:\n\n- **Imports are notebook-wide, declare them at the top, never inside a `run:` cell.** A cell's query runs as a *restricted query* against the already-compiled notebook, so its imports must already be in place. You can't `import` a model inside a query cell to scope it there; an in-query import is rejected (`file imports are not permitted in a restricted query`).\n- **Compile errors surface only at publish/render, not in the IDE linter** (see above). Verify a multi-model notebook by publishing to a scratch environment and loading it, rather than relying only on single-model query checks.\n\n## No Data-Specific Insights in Markdown\n\n**Notebooks must NOT contain findings about current data values.** Data refreshes will make these stale. Use markdown for framing questions and structural narrative, not for stating results.\n\n```\n>>>markdown\n## WRONG: will become stale when data refreshes\nRevenue spiked 23% in March. Electronics drove 60% of the spike.\n\n>>>markdown\n## RIGHT: frames the question, lets the query answer it\nHow is revenue trending? Which categories are driving changes?\n```\n\n## Cell Syntax\n\nCells delimited by `>>>markdown` or `>>>malloy`:\n\n```\n>>>markdown\n# Sales Analysis\nOverview of sales trends.\n\n>>>malloy\nimport \"sales_model.malloy\"\n\n>>>markdown\n## Summary\n\n>>>malloy\nrun: orders -> summary\n\n>>>markdown\n## Monthly Trend\n\n>>>malloy\n# line_chart\nrun: orders -> by_month\n```\n\n**NEVER create `>>>malloysql` cells.** Only use `>>>malloy` and `>>>markdown`.\n\n## Interactive Parameters (`given:`)\n\nInteractive parameters are common and important. Add them to most notebooks. **Use `given:` for anything new.** The older `#(filter)` mechanism still works and is documented below, but it is deprecated: do not reach for it just because you saw it in an existing model.\n\nA notebook's parameter surface comes from the `given:` declarations on the models it imports, not from anything declared in the `.malloynb` itself. When an imported model declares givens, Publisher's notebook UI automatically renders a Parameters panel above the notebook: declared givens become parameter inputs, the values a user sets are forwarded to Malloy's runtime, and every cell re-executes against the model with those values applied. A cell that runs before the notebook's `import` of a given ignores that given's value.\n\nThe widget for each parameter follows the given's declared Malloy type (`string`, `string[]`, `number`, `boolean`, `date`/`timestamp`, `filter<T>`); see `docs/givens.md` for the full type table. A `#(description=\"...\")` annotation on the given renders as helper text under its input.\n\n```malloy\n##! experimental.givens\n\n#(description=\"Two-letter IATA carrier code to spotlight\")\ngiven: carrier :: string is 'WN'\n\nsource: spotlight_carrier is duckdb.table('data/carriers.parquet') extend {\n view: by_name is {\n where: code = $carrier\n select: code, name, nickname\n limit: 1\n }\n}\n```\n\nDeclare givens at the **top of the model file**, before the source that uses them, not in the notebook. `malloy-model` § Access Control covers the syntax and the `#(authorize)`/`#(access_filter)` gating story built on top of givens.\n\n## Legacy: `#(filter)` annotations\n\n> **Deprecated. Read this before copying the example below.** `#(filter)` is server-side `where:` injection, superseded by native Malloy `given:` above. Do NOT add new `#(filter)` annotations; declare runtime parameters as givens instead. `docs/givens.md` has the migration recipes. What follows is here so you can read and maintain models that already use it.\n\nFilters are declared **once, on the model's source**. The notebook itself does not declare or list filters. Publisher reads the source's filter metadata and renders the widgets above the notebook automatically, then injects `where:` clauses server-side on every cell.\n\nDeclarations live in the `.malloy` model file, above the source, using key=value parameters. The full reference (syntax, filter types, `required` / `implicit` flags) lives with the `#(filter)` guidance in the `malloy-model` skill's Parameterizable Filters section. Short example:\n\n```malloy\n#(filter) name=Manufacturer dimension=Manufacturer type=in\n#(filter) name=OrderStatus dimension=order_status type=in\n#(filter) name=Recall_After dimension=\"Report Received Date\" type=greater_than\n#(filter) name=Recall_Before dimension=\"Report Received Date\" type=less_than\nsource: recalls is duckdb.table('data/auto_recalls.csv') extend {\n measure:\n recall_count is count()\n}\n```\n\n`#(filter)` lines go **above** the `source:` declaration and reference dimensions by name (use `dimension=`). `name` defaults to the dimension name and only needs to be set when two filters target the same dimension (e.g. a date range). `type` is the comparator: `equal`, `in`, `like`, `greater_than`, `less_than`.\n\n## Notebook Style: Iterative Analysis (Default)\n\n**Build a story.** Each query should reveal something that motivates the next query. Start broad, then drill into what's interesting. Frame questions in markdown, let queries answer them.\n\n**Pattern:** Question, Query, Next Question, Drill\n\n```\n>>>markdown\n## How is revenue trending?\n\n>>>malloy\n# line_chart\nrun: orders -> { group_by: order_month; aggregate: revenue }\n\n>>>markdown\n## What's driving the biggest changes?\n\n>>>malloy\n# bar_chart\nrun: orders -> { group_by: category; aggregate: revenue; order_by: revenue desc; limit: 10 }\n\n>>>markdown\n## How does the top category break down?\n\n>>>malloy\nrun: orders -> { aggregate: order_count, avg_order_value; where: category = 'Electronics' }\n```\n\n**When to use other styles:**\n- **Dashboard with filters:** User asks for \"filterable dashboard\". Show key KPIs and breakdowns with interactive filters.\n- **EDA/summary:** User asks for \"overview\". Run views like `summary`, `by_month`, `by_category` in sequence.\n\n## View Refinement\n\nTo add `limit`, `where`, or other options to an existing view, use `+`:\n\n```malloy\nrun: source -> my_view + { limit: 15 }\nrun: source -> my_view + { where: status = 'active', limit: 10 }\n```\n\n## Template\n\n```\n>>>markdown\n# [Title]\n[Framing question: what are we trying to understand?]\n\n>>>malloy\nimport \"model.malloy\"\n\n>>>markdown\n## [First question: start broad]\n\n>>>malloy\nrun: main_source -> [broad query]\n\n>>>markdown\n## [Next question: motivated by what the first query reveals]\n\n>>>malloy\nrun: main_source -> [drill into finding]\n\n>>>markdown\n## [Deeper question]\n\n>>>malloy\nrun: main_source -> [further breakdown]\n```\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| `view_name { limit: 10 }` | Use `+`: `view_name + { limit: 10 }` |\n| `# currency` on non-money | Only use `# currency` for monetary values |\n| `#(filter) {\"type\": \"Star\"}` (JSON-blob form, on a dimension) | Unsupported legacy syntax. `#(filter)` goes **above the source** with key=value parameters (`name=`, `dimension=`, `type=`). See the `malloy-model` skill's Parameterizable Filters section. |\n| `##(filters) [...]` annotation in the notebook | Unsupported. The notebook does not declare or list filters. Publisher renders widgets automatically from the source's `#(filter)` annotations. |\n| Creating MalloySQL cells | **NEVER use `>>>malloysql`**, only `>>>malloy` and `>>>markdown` |\n| Data-specific insights in markdown | Don't write \"Revenue grew 23%.\" Frame questions instead. Data refreshes will make findings stale. |\n| `import` inside a `run:` cell (to scope a model to that cell) | Imports are notebook-wide, put them in a setup cell at the top. An in-query import is rejected: `file imports are not permitted in a restricted query` (see Multiple Models & Cross-Model Joins). |\n| Cross-model join inside a single ad-hoc report cell | A `skill:malloy-analysis-report` cell renders against a single model, so a cross-model join there won't render. Build a published `.malloynb` (which runs against the whole notebook) for a cross-model join. |\n\n## Best Practices\n\n1. Start with markdown framing the question\n2. Import each model the notebook needs in a setup cell at the top, cells can reference and join sources across all imports (see Multiple Models & Cross-Model Joins); verify a multi-model notebook by publishing to a scratch environment\n3. Each query should follow from what the previous one could reveal\n4. One query per cell\n5. Charts render only the FIRST aggregate\n6. Add interactive filters to most notebooks by declaring `#(filter)` on the model's source (see Interactive Filters), or prefer `given:` runtime parameters for new models (see Runtime Parameters)\n7. Never include data-specific findings in markdown. Frame questions, let queries answer\n\n## Done\n\nStep complete. Output: `.malloynb` notebook file."
328604
329549
  },
328605
329550
  {
328606
329551
  name: "malloy-patterns",
@@ -328931,7 +329876,7 @@ having: count() > 20
328931
329876
  Wrong: \`group_by: total_sales\` (where \`total_sales\` is \`sum(price)\`)
328932
329877
  Right: \`group_by: category; aggregate: total_sales\`
328933
329878
 
328934
- **Scalar functions are not aggregates.** \`concat()\`, \`substring()\`, arithmetic on raw fields, etc. belong in \`group_by:\` or \`select:\`, never \`aggregate:\`.
329879
+ **Scalar functions are not aggregates.** \`concat()\`, \`substr()\`, arithmetic on raw fields, etc. belong in \`group_by:\` or \`select:\`, never \`aggregate:\`.
328935
329880
 
328936
329881
  Wrong: \`aggregate: full_name is concat(first_name, ' ', last_name)\`
328937
329882
  Right: \`group_by: full_name is concat(first_name, ' ', last_name)\`
@@ -329014,6 +329959,10 @@ Read the error against the tables above and below. Most failures match a known p
329014
329959
  |---|---|
329015
329960
  | \`Cannot compare a timestamp to a number\` | Comparing \`date.year\` to an integer. Use \`date >= @2020-01-01\` instead. |
329016
329961
  | \`no viable alternative at input '<word>'\` | Often a \`;\` between fields within one clause - fields under one \`aggregate:\`/\`group_by:\` are comma- or newline-separated (the error points at the field right after the \`;\`). |
329962
+ | \`unexpected '<field>', expected 'not' or 'null'\` | A **reserved word used as a name**, in a multi-line \`aggregate:\` / \`group_by:\` list. \`second\`, \`minute\`, \`hour\`, \`day\`, \`week\`, \`month\`, \`quarter\`, \`year\` are reserved. On its own line the compiler says so plainly (\`'second' is a reserved word, so to use it as a name you must quote it\`), but as a later entry in a multi-line list the parser has already committed, and the error points at the NEXT field instead - so you read it as a problem with the line below. Rename the field or backtick it. Verified against the compiler both ways. |
329963
+ | \`'logical operator' Can't use type <string\\|date\\|...>\` | **Any \`?\` apply combined with \`and\` needs parentheses**, not just the alternation form. \`where: t ? 'a' | 'b' and flag\` swallows \`and flag\` into the alternation list, and \`where: d ? @2015 and x = 'y'\` fails the same way with \`Can't use type date\`. Wrap the apply: \`where: (d ? @2015) and x = 'y'\`. The error names the *type on the left of the apply*, so it points at a clause that is perfectly fine and tells you nothing about the missing parentheses; on a date column it can also surface a spurious second error about \`!= null\` on an unrelated line. |
329964
+ | \`Circular reference to '<name>' in definition\` | A **measure aliased to its own name** (\`aggregate: games is games\`), which compiles fine until a \`having:\` references it. Alias to a different name, or drop the alias entirely. |
329965
+ | \`Unknown function '<name>'. Use '<name>!(...)' to call a SQL function directly.\` | The function does not exist in Malloy (\`substring\` is \`substr\`; there is no \`median\`). **In an ad-hoc query the suggested fix is a dead end**: a query sent to a server is compiled in restricted mode, so \`median!(...)\` then fails with "direct SQL function calls are not permitted" and following the error message costs two round trips. A saved model file is not compiled that way, so the same call is allowed there and fails only on its own merits. Use the Malloy spelling, or express it another way (an ordered \`limit\` for a median-like value); reach for \`!(...)\` only in a model file, and only knowing it pins you to one dialect. |
329017
329966
  | \`'<field_name>' is not defined\` | Field doesn't exist in the source. Re-check against the model definition; you may have stripped a join prefix. |
329018
329967
  | \`field is a bar chart, but is not a repeated record\` | Chart annotation placed inside \`{ }\`. Move \`# bar_chart\` above \`run:\` / \`view:\` / \`nest:\`. |
329019
329968
  | \`Parser enountered unexpected statement\` | Spelled that way by the compiler. Most often a chart annotation left as the last line inside \`{ }\` - move it above \`run:\`. Also syntax Malloy doesn't allow in that position (e.g., \`pick\` inside a nested view). |