@malloy-publisher/server 0.8.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/README.docker.md +86 -2
  2. package/dist/app/api-doc.yaml +17 -1
  3. package/dist/app/assets/{EnvironmentPage-D0689eMH.js → EnvironmentPage-UMS7Py4Q.js} +1 -1
  4. package/dist/app/assets/{HomePage-CQwxmRju.js → HomePage-D3MvQaZn.js} +1 -1
  5. package/dist/app/assets/{LightMode-DzUuDGQu.js → LightMode-e1lNBfpL.js} +1 -1
  6. package/dist/app/assets/{MainPage-CWebuoie.js → MainPage-B0ozqtQO.js} +1 -1
  7. package/dist/app/assets/{ModelPage-DddcQtR_.js → ModelPage-lQj4Axqu.js} +2 -2
  8. package/dist/app/assets/{PackagePage-B_6x-Kp1.js → PackagePage-BT0YKNKU.js} +1 -1
  9. package/dist/app/assets/{RenderedResult-BRgIgxWh.es-B6GsiQaB.js → RenderedResult-BRgIgxWh.es-DiE7kPNX.js} +2 -2
  10. package/dist/app/assets/{RouteError-D85c0IXR.js → RouteError-BQnzPJvb.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-DPFZnuXS.js → ThemeEditorPage-D6t7w9Js.js} +1 -1
  12. package/dist/app/assets/{ToggleButtonGroup-B2KkD7kC.js → ToggleButtonGroup-CJT2JKTC.js} +1 -1
  13. package/dist/app/assets/index-ByieDZlo.js +23 -0
  14. package/dist/app/assets/{index-BbrJd6fQ.js → index-CCNqF0-G.js} +1 -1
  15. package/dist/app/assets/{index-125-Lr5Z.js → index-CKmZ4HG7.js} +388 -388
  16. package/dist/app/assets/{index-mS-LEGFh.js → index-CdExa3Pk.js} +1 -1
  17. package/dist/app/assets/{index-BSBGZfIc.js → index-TiWjmdoW.js} +4 -4
  18. package/dist/app/assets/{index-DSoCUF-f.js → index-ireHUsv_.js} +419 -422
  19. package/dist/app/assets/{index-BK_RS5eN.js → index-ufFZR_K-.js} +1 -1
  20. package/dist/app/assets/{index.es-Dixkq2YH.js → index.es-iUV0gCYk.js} +4 -4
  21. package/dist/app/index.html +1 -1
  22. package/dist/package_load_worker.mjs +147 -118
  23. package/dist/server.mjs +742 -915
  24. package/package.json +14 -14
  25. package/dist/app/assets/index-w_0OQJgZ.js +0 -23
package/dist/server.mjs CHANGED
@@ -205143,9 +205143,54 @@ function logInternalFailure(summary, error2, level = "error") {
205143
205143
  }
205144
205144
  });
205145
205145
  }
205146
- function internalErrorToHttpError(error2) {
205146
+ function filesystemAccessFailure(error2) {
205147
+ let current = error2;
205148
+ for (let depth = 0;depth < 8 && current instanceof Error; depth++) {
205149
+ const errno = current;
205150
+ if (typeof errno.code === "string" && errno.code in FILESYSTEM_ACCESS_DESCRIPTIONS && typeof errno.syscall === "string") {
205151
+ return errno;
205152
+ }
205153
+ current = errno.cause;
205154
+ }
205155
+ return;
205156
+ }
205157
+ function describeFilesystemAccessFailure(errno) {
205158
+ const code = errno.code ?? "";
205159
+ const target = errno.path ? ` '${errno.path}'` : "";
205160
+ return `${code}: ${FILESYSTEM_ACCESS_DESCRIPTIONS[code] ?? code}, ${errno.syscall}${target}`;
205161
+ }
205162
+ function messageWithFilesystemCause(error2) {
205163
+ const message = error2 instanceof Error ? error2.message : String(error2);
205164
+ const access = filesystemAccessFailure(error2);
205165
+ if (!access || message.includes(access.code ?? ""))
205166
+ return message;
205167
+ return `${message}: ${describeFilesystemAccessFailure(access)}`;
205168
+ }
205169
+ function errnoWireFields(error2) {
205170
+ const { code, syscall, path } = error2;
205171
+ if (typeof code !== "string")
205172
+ return;
205173
+ return {
205174
+ code,
205175
+ ...typeof syscall === "string" ? { syscall } : {},
205176
+ ...typeof path === "string" ? { path } : {}
205177
+ };
205178
+ }
205179
+ function internalErrorToHttpError(error2, { log = true } = {}) {
205180
+ const logInternal = (...args) => {
205181
+ if (log)
205182
+ logInternalFailure(...args);
205183
+ };
205184
+ const access = filesystemAccessFailure(error2);
205185
+ if (access) {
205186
+ logInternal("Filesystem access refused", error2, "warn");
205187
+ return httpError(500, `The server cannot access a path it needs (${describeFilesystemAccessFailure(access)}). ` + `Give the user the server runs as access to it.`);
205188
+ }
205147
205189
  if (error2 instanceof BadRequestError) {
205148
205190
  return httpError(400, error2.message);
205191
+ } else if (error2 instanceof ServerConfigurationError) {
205192
+ logInternal("Server configuration error", error2, "warn");
205193
+ return httpError(500, error2.message);
205149
205194
  } else if (error2 instanceof FrozenConfigError) {
205150
205195
  return httpError(403, error2.message);
205151
205196
  } else if (error2 instanceof AccessDeniedError) {
@@ -205191,7 +205236,7 @@ function internalErrorToHttpError(error2) {
205191
205236
  if (error2.callerSafe) {
205192
205237
  return httpError(502, error2.message);
205193
205238
  }
205194
- logInternalFailure("Upstream connection error", error2, "warn");
205239
+ logInternal("Upstream connection error", error2, "warn");
205195
205240
  return httpError(502, GENERIC_UPSTREAM_MESSAGE);
205196
205241
  } else if (error2 instanceof MaterializationNotFoundError) {
205197
205242
  return httpError(404, error2.message);
@@ -205202,7 +205247,7 @@ function internalErrorToHttpError(error2) {
205202
205247
  } else if (error2 instanceof WriteConflictError) {
205203
205248
  return httpError(409, error2.message);
205204
205249
  } else if (error2 instanceof WriteRolledBackError) {
205205
- logInternalFailure("Dashboard write rolled back", error2, "warn");
205250
+ logInternal("Dashboard write rolled back", error2, "warn");
205206
205251
  return httpError(500, error2.message);
205207
205252
  } else if (error2 instanceof ServiceUnavailableError) {
205208
205253
  return httpError(503, error2.message);
@@ -205213,7 +205258,7 @@ function internalErrorToHttpError(error2) {
205213
205258
  } else if (error2 instanceof NotImplementedError) {
205214
205259
  return httpError(501, error2.message);
205215
205260
  } else {
205216
- logInternalFailure("Unhandled internal error", error2);
205261
+ logInternal("Unhandled internal error", error2);
205217
205262
  return httpError(500, GENERIC_INTERNAL_MESSAGE);
205218
205263
  }
205219
205264
  }
@@ -205235,10 +205280,15 @@ function toQueryTextProblem(problem) {
205235
205280
  ...problem.at ? { at: { range: problem.at.range } } : {}
205236
205281
  };
205237
205282
  }
205238
- 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;
205283
+ var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, FILESYSTEM_ACCESS_DESCRIPTIONS, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, PublisherConfigError, ServerConfigurationError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
205239
205284
  var init_errors = __esm(() => {
205240
205285
  init_constants();
205241
205286
  init_logger();
205287
+ FILESYSTEM_ACCESS_DESCRIPTIONS = {
205288
+ EACCES: "permission denied",
205289
+ EPERM: "operation not permitted",
205290
+ EROFS: "read-only file system"
205291
+ };
205242
205292
  NotImplementedError = class NotImplementedError extends Error {
205243
205293
  constructor(message) {
205244
205294
  super(message);
@@ -205259,8 +205309,8 @@ var init_errors = __esm(() => {
205259
205309
  }
205260
205310
  };
205261
205311
  PackageNotFoundError = class PackageNotFoundError extends Error {
205262
- constructor(message) {
205263
- super(message);
205312
+ constructor(message, options) {
205313
+ super(message, options);
205264
205314
  }
205265
205315
  };
205266
205316
  ModelNotFoundError = class ModelNotFoundError extends Error {
@@ -205331,6 +205381,12 @@ var init_errors = __esm(() => {
205331
205381
  this.cause = cause;
205332
205382
  }
205333
205383
  };
205384
+ ServerConfigurationError = class ServerConfigurationError extends Error {
205385
+ constructor(message) {
205386
+ super(message);
205387
+ this.name = "ServerConfigurationError";
205388
+ }
205389
+ };
205334
205390
  FrozenConfigError = class FrozenConfigError extends Error {
205335
205391
  constructor(message = `Publisher config can't be updated when ${PUBLISHER_CONFIG_NAME} has { "frozenConfig": true }`) {
205336
205392
  super(message);
@@ -205388,8 +205444,8 @@ var init_errors = __esm(() => {
205388
205444
  }
205389
205445
  };
205390
205446
  ServiceUnavailableError = class ServiceUnavailableError extends Error {
205391
- constructor(message) {
205392
- super(message);
205447
+ constructor(message, options) {
205448
+ super(message, options);
205393
205449
  }
205394
205450
  };
205395
205451
  PayloadTooLargeError = class PayloadTooLargeError extends Error {
@@ -206074,13 +206130,69 @@ var init_path_safety = __esm(() => {
206074
206130
 
206075
206131
  // src/pg_helpers.ts
206076
206132
  function redactPgSecrets(s2) {
206077
- return s2.replace(/([a-z][a-z0-9+.-]*:\/\/[^:/?#\s]*):([^/\s]+)@/gi, "$1:***@").replace(/((?:postgres|postgresql):\/\/[^:/?#\s]*):([^@\s]+)@/gi, "$1:***@").replace(/password=('(?:\\.|[^'\\])*'|"[^"]*"|\S+)/gi, "password=***");
206133
+ return redactUserinfoPasswords(redactUserinfoPasswords(s2, ANY_SCHEME_LAST_AT), PG_SCHEME_FIRST_AT).replace(/password=('(?:\\.|[^'\\])*'|"[^"]*"|\S+)/gi, "password=***");
206134
+ }
206135
+ function runEnd(run, s2, from) {
206136
+ run.lastIndex = from;
206137
+ run.exec(s2);
206138
+ return run.lastIndex;
206139
+ }
206140
+ function redactUserinfoPasswords(s2, pass) {
206141
+ let out = "";
206142
+ let copied = 0;
206143
+ let stop = -1;
206144
+ let sep2 = s2.indexOf("://");
206145
+ while (sep2 !== -1) {
206146
+ const userEnd = runEnd(USERNAME_RUN, s2, sep2 + 3);
206147
+ let at = -1;
206148
+ if (s2[userEnd] === ":" && pass.hasScheme(s2, sep2, copied)) {
206149
+ const from = userEnd + 1;
206150
+ if (stop < from)
206151
+ stop = runEnd(pass.passwordRun, s2, from);
206152
+ at = pass.passwordEnd(s2, from, stop);
206153
+ }
206154
+ if (at === -1) {
206155
+ sep2 = s2.indexOf("://", sep2 + 1);
206156
+ continue;
206157
+ }
206158
+ out += s2.slice(copied, userEnd + 1) + "***@";
206159
+ copied = at + 1;
206160
+ sep2 = s2.indexOf("://", copied);
206161
+ }
206162
+ return out + s2.slice(copied);
206078
206163
  }
206079
206164
  function redactConnectionSecretShapes(s2) {
206080
206165
  return redactPgSecrets(s2.replace(PEM_TERMINATED_PATTERN, "***").replace(PEM_UNTERMINATED_PATTERN, "***").replace(SECRET_QUOTED_PATTERN, "$1***").replace(SECRET_BARE_PATTERN, "$1***"));
206081
206166
  }
206082
- var SECRET_FIELD_NAMES, NAME_ALTERNATION, SECRET_QUOTED_PATTERN, SECRET_BARE_PATTERN, PEM_TERMINATED_PATTERN, PEM_UNTERMINATED_PATTERN;
206167
+ var USERNAME_RUN, SCHEME_CHAR, SCHEME_LETTER, PG_SCHEMES, ANY_SCHEME_LAST_AT, PG_SCHEME_FIRST_AT, SECRET_FIELD_NAMES, NAME_ALTERNATION, SECRET_QUOTED_PATTERN, SECRET_BARE_PATTERN, PEM_TERMINATED_PATTERN, PEM_UNTERMINATED_PATTERN;
206083
206168
  var init_pg_helpers = __esm(() => {
206169
+ USERNAME_RUN = /[^:/?#\s]*/y;
206170
+ SCHEME_CHAR = /[a-z0-9+.-]/i;
206171
+ SCHEME_LETTER = /[a-z]/i;
206172
+ PG_SCHEMES = ["postgres", "postgresql"];
206173
+ ANY_SCHEME_LAST_AT = {
206174
+ hasScheme(s2, sep2, floor) {
206175
+ for (let i2 = sep2 - 1;i2 >= floor && SCHEME_CHAR.test(s2[i2]); i2--) {
206176
+ if (SCHEME_LETTER.test(s2[i2]))
206177
+ return true;
206178
+ }
206179
+ return false;
206180
+ },
206181
+ passwordRun: /[^/\s]*/y,
206182
+ passwordEnd(s2, from, stop) {
206183
+ const at = s2.slice(from, stop).lastIndexOf("@");
206184
+ return at > 0 ? from + at : -1;
206185
+ }
206186
+ };
206187
+ PG_SCHEME_FIRST_AT = {
206188
+ hasScheme(s2, sep2, floor) {
206189
+ return PG_SCHEMES.some((scheme) => sep2 - scheme.length >= floor && s2.slice(sep2 - scheme.length, sep2).toLowerCase() === scheme);
206190
+ },
206191
+ passwordRun: /[^@\s]*/y,
206192
+ passwordEnd(s2, from, stop) {
206193
+ return stop > from && s2[stop] === "@" ? stop : -1;
206194
+ }
206195
+ };
206084
206196
  SECRET_FIELD_NAMES = [
206085
206197
  "password",
206086
206198
  "privateKey",
@@ -293813,6 +293925,26 @@ var init_gcp_impersonation = __esm(() => {
293813
293925
  impersonatedClients = new Map;
293814
293926
  });
293815
293927
 
293928
+ // src/service/google_credentials.ts
293929
+ import * as fs4 from "fs";
293930
+ function assertGoogleCredentialsIsNotADirectory() {
293931
+ const keyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS;
293932
+ if (!keyFile)
293933
+ return;
293934
+ let isDirectory = false;
293935
+ try {
293936
+ isDirectory = fs4.statSync(keyFile).isDirectory();
293937
+ } catch {
293938
+ return;
293939
+ }
293940
+ if (isDirectory) {
293941
+ throw new ServerConfigurationError(`GOOGLE_APPLICATION_CREDENTIALS names a directory, not a key file: '${keyFile}'. ` + `A bind mount whose source path does not exist creates a directory there; ` + `mount the key file itself.`);
293942
+ }
293943
+ }
293944
+ var init_google_credentials = __esm(() => {
293945
+ init_errors();
293946
+ });
293947
+
293816
293948
  // src/service/quoting.ts
293817
293949
  function bareTableName(tableName) {
293818
293950
  const lastDot = tableName.lastIndexOf(".");
@@ -293867,7 +293999,7 @@ import {
293867
293999
  } from "@malloydata/malloy";
293868
294000
  import { createHash } from "crypto";
293869
294001
  import { mkdtempSync, readFileSync, writeFileSync } from "fs";
293870
- import fs4 from "fs/promises";
294002
+ import fs5 from "fs/promises";
293871
294003
  import os2 from "os";
293872
294004
  import path4 from "path";
293873
294005
  import tls from "tls";
@@ -294706,8 +294838,8 @@ async function deleteDuckLakeConnectionFile(connectionName, environmentPath) {
294706
294838
  assertSafeEnvironmentPath(environmentPath);
294707
294839
  const ducklakePath = safeJoinUnderRoot(environmentPath, `${connectionName}_ducklake.duckdb`);
294708
294840
  try {
294709
- await fs4.access(ducklakePath);
294710
- await fs4.rm(ducklakePath);
294841
+ await fs5.access(ducklakePath);
294842
+ await fs5.rm(ducklakePath);
294711
294843
  logger.info(`Removed DuckLake connection file ${connectionName}_ducklake.duckdb from ${environmentPath}`);
294712
294844
  } catch (error2) {
294713
294845
  if (error2.code === "ENOENT") {
@@ -295098,7 +295230,10 @@ async function testConnectionConfig(connectionConfig) {
295098
295230
  if (connectionConfig.type === "duckdb" || connectionConfig.type === "ducklake") {
295099
295231
  assertSafePackageName(connectionConfig.name);
295100
295232
  }
295101
- testRoot = await fs4.mkdtemp(path4.join(os2.tmpdir(), "publisher-conn-test-"));
295233
+ if (connectionConfig.type === "bigquery" && !connectionConfig.bigqueryConnection?.serviceAccountKeyJson) {
295234
+ assertGoogleCredentialsIsNotADirectory();
295235
+ }
295236
+ testRoot = await fs5.mkdtemp(path4.join(os2.tmpdir(), "publisher-conn-test-"));
295102
295237
  environmentConfig = buildEnvironmentMalloyConfig([connectionConfig], testRoot);
295103
295238
  const connection = await environmentConfig.malloyConfig.connections.lookupConnection(connectionConfig.name);
295104
295239
  if (connectionConfig.type === "duckdb") {
@@ -295142,7 +295277,7 @@ async function testConnectionConfig(connectionConfig) {
295142
295277
  }
295143
295278
  if (testRoot) {
295144
295279
  try {
295145
- await fs4.rm(testRoot, { recursive: true, force: true });
295280
+ await fs5.rm(testRoot, { recursive: true, force: true });
295146
295281
  } catch (cleanupError) {
295147
295282
  logger.warn("Error cleaning up connection test directory", {
295148
295283
  error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
@@ -295162,6 +295297,7 @@ var init_connection = __esm(() => {
295162
295297
  init_path_safety();
295163
295298
  init_connection_config();
295164
295299
  init_gcp_impersonation();
295300
+ init_google_credentials();
295165
295301
  init_gcs_s3_utils();
295166
295302
  init_proxy();
295167
295303
  init_quoting();
@@ -302669,7 +302805,7 @@ var require_table = __commonJS((exports) => {
302669
302805
  var Big = require_big();
302670
302806
  var extend3 = require_extend();
302671
302807
  var events_1 = __require("events");
302672
- var fs5 = __require("fs");
302808
+ var fs6 = __require("fs");
302673
302809
  var is = require_is();
302674
302810
  var path5 = __require("path");
302675
302811
  var streamEvents = require_stream_events();
@@ -302979,7 +303115,7 @@ var require_table = __commonJS((exports) => {
302979
303115
  if (!metadata.sourceFormat && detectedFormat) {
302980
303116
  metadata.sourceFormat = detectedFormat;
302981
303117
  }
302982
- const jobWritable = fs5.createReadStream(source).pipe(this.createWriteStream_(metadata));
303118
+ const jobWritable = fs6.createReadStream(source).pipe(this.createWriteStream_(metadata));
302983
303119
  const [jobResponse] = await (0, events_1.once)(jobWritable, "job");
302984
303120
  return [jobResponse, jobResponse.metadata];
302985
303121
  }
@@ -308322,72 +308458,21 @@ function onMotlyRoute(text) {
308322
308458
  function hasEnvReference(annotation) {
308323
308459
  return annotation.includes("@env.");
308324
308460
  }
308325
- function pollutionTargets() {
308326
- const targets = [Object.prototype];
308327
- for (const key of Object.getOwnPropertyNames(Object.prototype)) {
308328
- const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, key);
308329
- if (!descriptor)
308330
- continue;
308331
- let value;
308332
- if ("value" in descriptor) {
308333
- value = descriptor.value;
308334
- } else {
308335
- if (BUILT_IN_PROTO_GETTER === undefined || descriptor.get !== BUILT_IN_PROTO_GETTER) {
308336
- return;
308337
- }
308338
- continue;
308339
- }
308340
- if ((typeof value === "object" || typeof value === "function") && value !== null && !targets.includes(value)) {
308341
- targets.push(value);
308342
- }
308343
- }
308344
- return targets;
308345
- }
308346
- function parseGuarded(texts) {
308347
- const targets = pollutionTargets();
308348
- if (targets === undefined) {
308349
- return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
308350
- }
308351
- let unreadable = false;
308352
- const ownNames = (target) => {
308353
- try {
308354
- return Object.getOwnPropertyNames(target);
308355
- } catch {
308356
- unreadable = true;
308357
- return [];
308358
- }
308359
- };
308360
- const before = targets.map(ownNames);
308361
- if (unreadable)
308362
- return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
308363
- const undoPollution = () => {
308364
- let polluted = false;
308365
- targets.forEach((target, index) => {
308366
- for (const key of ownNames(target)) {
308367
- if (before[index].includes(key))
308368
- continue;
308369
- polluted = true;
308370
- try {
308371
- delete target[key];
308372
- } catch {}
308373
- }
308374
- });
308375
- return polluted || unreadable;
308376
- };
308461
+ function parseBounded(texts) {
308377
308462
  if (texts.some((text) => text.length > MAX_ANNOTATION_CHARS)) {
308378
308463
  return { tag: undefined, messages: [ANNOTATION_TOO_LONG] };
308379
308464
  }
308380
308465
  try {
308381
308466
  const result = parseAnnotation([...texts]);
308382
- if (undoPollution())
308383
- return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
308384
308467
  return {
308385
308468
  tag: result.tag,
308386
308469
  messages: result.log.map((error2) => error2.message)
308387
308470
  };
308388
- } catch {
308389
- undoPollution();
308390
- return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
308471
+ } catch (error2) {
308472
+ return {
308473
+ tag: undefined,
308474
+ messages: [error2 instanceof Error ? error2.message : String(error2)]
308475
+ };
308391
308476
  }
308392
308477
  }
308393
308478
  function docCommentText(texts) {
@@ -308519,16 +308604,16 @@ function parseMotly(texts) {
308519
308604
  const envErrors = envDropped > 0 ? [ENV_REFERENCE_DROPPED] : [];
308520
308605
  if (motly.length === 0)
308521
308606
  return { tag: undefined, errors: envErrors };
308522
- const direct = parseGuarded(motly);
308607
+ const direct = parseBounded(motly);
308523
308608
  if (direct.messages.length === 0)
308524
308609
  return { tag: direct.tag, errors: envErrors };
308525
308610
  const rescued = motly.map((text) => {
308526
- if (parseGuarded([text]).messages.length === 0)
308611
+ if (parseBounded([text]).messages.length === 0)
308527
308612
  return text;
308528
308613
  const rewritten = quoteFilterLiterals(text);
308529
- return parseGuarded([rewritten]).messages.length === 0 ? rewritten : text;
308614
+ return parseBounded([rewritten]).messages.length === 0 ? rewritten : text;
308530
308615
  });
308531
- const after = parseGuarded(rescued);
308616
+ const after = parseBounded(rescued);
308532
308617
  return { tag: after.tag, errors: [...envErrors, ...after.messages] };
308533
308618
  }
308534
308619
  function tagText(tag, ...path7) {
@@ -308594,9 +308679,8 @@ function readStartingGivens(tag, declaredType) {
308594
308679
  }
308595
308680
  return Object.keys(collected).length > 0 ? collected : undefined;
308596
308681
  }
308597
- var BUILT_IN_PROTO_GETTER, MAX_ANNOTATION_CHARS = 8192, ANNOTATION_TOO_LONG, ENV_REFERENCE_DROPPED = "annotation dropped for carrying an @env. reference", UNSAFE_TO_PARSE = "annotation could not be parsed safely";
308682
+ var MAX_ANNOTATION_CHARS = 8192, ANNOTATION_TOO_LONG, ENV_REFERENCE_DROPPED = "annotation dropped for carrying an @env. reference";
308598
308683
  var init_motly = __esm(() => {
308599
- BUILT_IN_PROTO_GETTER = Object.getOwnPropertyDescriptor(Object.prototype, "__proto__")?.get;
308600
308684
  ANNOTATION_TOO_LONG = `annotation exceeds ${MAX_ANNOTATION_CHARS} characters and was not parsed`;
308601
308685
  });
308602
308686
 
@@ -316329,9 +316413,6 @@ function lintDashboard(facts, manifest) {
316329
316413
  return findings;
316330
316414
  }
316331
316415
  function describeParseFailure(message) {
316332
- if (message === UNSAFE_TO_PARSE) {
316333
- return `was refused rather than parsed (${message}), which can be caused by something outside this file`;
316334
- }
316335
316416
  if (message === ENV_REFERENCE_DROPPED) {
316336
316417
  return `was dropped rather than parsed (${message}), so nothing on that line took effect`;
316337
316418
  }
@@ -316475,9 +316556,9 @@ var init_dashboard = __esm(() => {
316475
316556
  var require_pump = __commonJS((exports, module) => {
316476
316557
  var once = require_once2();
316477
316558
  var eos = require_end_of_stream4();
316478
- var fs5;
316559
+ var fs6;
316479
316560
  try {
316480
- fs5 = __require("fs");
316561
+ fs6 = __require("fs");
316481
316562
  } catch (e2) {}
316482
316563
  var noop3 = function() {};
316483
316564
  var ancient = typeof process === "undefined" ? false : /^v?\.0/.test(process.version);
@@ -316487,9 +316568,9 @@ var require_pump = __commonJS((exports, module) => {
316487
316568
  var isFS = function(stream4) {
316488
316569
  if (!ancient)
316489
316570
  return false;
316490
- if (!fs5)
316571
+ if (!fs6)
316491
316572
  return false;
316492
- return (stream4 instanceof (fs5.ReadStream || noop3) || stream4 instanceof (fs5.WriteStream || noop3)) && isFn(stream4.close);
316573
+ return (stream4 instanceof (fs6.ReadStream || noop3) || stream4 instanceof (fs6.WriteStream || noop3)) && isFn(stream4.close);
316493
316574
  };
316494
316575
  var isRequest3 = function(stream4) {
316495
316576
  return stream4.setHeader && isFn(stream4.abort);
@@ -316704,7 +316785,7 @@ var require_pend = __commonJS((exports, module) => {
316704
316785
 
316705
316786
  // ../../node_modules/fd-slicer/index.js
316706
316787
  var require_fd_slicer = __commonJS((exports) => {
316707
- var fs5 = __require("fs");
316788
+ var fs6 = __require("fs");
316708
316789
  var util6 = __require("util");
316709
316790
  var stream4 = __require("stream");
316710
316791
  var Readable4 = stream4.Readable;
@@ -316729,7 +316810,7 @@ var require_fd_slicer = __commonJS((exports) => {
316729
316810
  FdSlicer.prototype.read = function(buffer, offset, length, position, callback) {
316730
316811
  var self2 = this;
316731
316812
  self2.pend.go(function(cb) {
316732
- fs5.read(self2.fd, buffer, offset, length, position, function(err, bytesRead, buffer2) {
316813
+ fs6.read(self2.fd, buffer, offset, length, position, function(err, bytesRead, buffer2) {
316733
316814
  cb();
316734
316815
  callback(err, bytesRead, buffer2);
316735
316816
  });
@@ -316738,7 +316819,7 @@ var require_fd_slicer = __commonJS((exports) => {
316738
316819
  FdSlicer.prototype.write = function(buffer, offset, length, position, callback) {
316739
316820
  var self2 = this;
316740
316821
  self2.pend.go(function(cb) {
316741
- fs5.write(self2.fd, buffer, offset, length, position, function(err, written, buffer2) {
316822
+ fs6.write(self2.fd, buffer, offset, length, position, function(err, written, buffer2) {
316742
316823
  cb();
316743
316824
  callback(err, written, buffer2);
316744
316825
  });
@@ -316761,7 +316842,7 @@ var require_fd_slicer = __commonJS((exports) => {
316761
316842
  if (self2.refCount < 0)
316762
316843
  throw new Error("invalid unref");
316763
316844
  if (self2.autoClose) {
316764
- fs5.close(self2.fd, onCloseDone);
316845
+ fs6.close(self2.fd, onCloseDone);
316765
316846
  }
316766
316847
  function onCloseDone(err) {
316767
316848
  if (err) {
@@ -316800,7 +316881,7 @@ var require_fd_slicer = __commonJS((exports) => {
316800
316881
  if (self2.destroyed)
316801
316882
  return cb();
316802
316883
  var buffer = new Buffer(toRead);
316803
- fs5.read(self2.context.fd, buffer, 0, toRead, self2.pos, function(err, bytesRead) {
316884
+ fs6.read(self2.context.fd, buffer, 0, toRead, self2.pos, function(err, bytesRead) {
316804
316885
  if (err) {
316805
316886
  self2.destroy(err);
316806
316887
  } else if (bytesRead === 0) {
@@ -316850,7 +316931,7 @@ var require_fd_slicer = __commonJS((exports) => {
316850
316931
  self2.context.pend.go(function(cb) {
316851
316932
  if (self2.destroyed)
316852
316933
  return cb();
316853
- fs5.write(self2.context.fd, buffer, 0, buffer.length, self2.pos, function(err2, bytes) {
316934
+ fs6.write(self2.context.fd, buffer, 0, buffer.length, self2.pos, function(err2, bytes) {
316854
316935
  if (err2) {
316855
316936
  self2.destroy();
316856
316937
  cb();
@@ -317275,7 +317356,7 @@ var require_buffer_crc32 = __commonJS((exports, module) => {
317275
317356
 
317276
317357
  // ../../node_modules/yauzl/index.js
317277
317358
  var require_yauzl = __commonJS((exports) => {
317278
- var fs5 = __require("fs");
317359
+ var fs6 = __require("fs");
317279
317360
  var zlib3 = __require("zlib");
317280
317361
  var fd_slicer = require_fd_slicer();
317281
317362
  var crc32 = require_buffer_crc32();
@@ -317312,12 +317393,12 @@ var require_yauzl = __commonJS((exports) => {
317312
317393
  options.strictFileNames = false;
317313
317394
  if (callback == null)
317314
317395
  callback = defaultCallback;
317315
- fs5.open(path7, "r", function(err, fd) {
317396
+ fs6.open(path7, "r", function(err, fd) {
317316
317397
  if (err)
317317
317398
  return callback(err);
317318
317399
  fromFd(fd, options, function(err2, zipfile) {
317319
317400
  if (err2)
317320
- fs5.close(fd, defaultCallback);
317401
+ fs6.close(fd, defaultCallback);
317321
317402
  callback(err2, zipfile);
317322
317403
  });
317323
317404
  });
@@ -317341,7 +317422,7 @@ var require_yauzl = __commonJS((exports) => {
317341
317422
  options.strictFileNames = false;
317342
317423
  if (callback == null)
317343
317424
  callback = defaultCallback;
317344
- fs5.fstat(fd, function(err, stats) {
317425
+ fs6.fstat(fd, function(err, stats) {
317345
317426
  if (err)
317346
317427
  return callback(err);
317347
317428
  var reader = fd_slicer.createFromFd(fd, { autoClose: true });
@@ -317974,7 +318055,7 @@ var require_yauzl = __commonJS((exports) => {
317974
318055
  // ../../node_modules/extract-zip/index.js
317975
318056
  var require_extract_zip = __commonJS((exports, module) => {
317976
318057
  var debug = require_src5()("extract-zip");
317977
- var { createWriteStream, promises: fs5 } = __require("fs");
318058
+ var { createWriteStream, promises: fs6 } = __require("fs");
317978
318059
  var getStream = require_get_stream();
317979
318060
  var path7 = __require("path");
317980
318061
  var { promisify: promisify2 } = __require("util");
@@ -318016,8 +318097,8 @@ var require_extract_zip = __commonJS((exports, module) => {
318016
318097
  }
318017
318098
  const destDir = path7.dirname(path7.join(this.opts.dir, entry.fileName));
318018
318099
  try {
318019
- await fs5.mkdir(destDir, { recursive: true });
318020
- const canonicalDestDir = await fs5.realpath(destDir);
318100
+ await fs6.mkdir(destDir, { recursive: true });
318101
+ const canonicalDestDir = await fs6.realpath(destDir);
318021
318102
  const relativeDestDir = path7.relative(this.opts.dir, canonicalDestDir);
318022
318103
  if (relativeDestDir.split(path7.sep).includes("..")) {
318023
318104
  throw new Error(`Out of bound path "${canonicalDestDir}" found while processing file ${entry.fileName}`);
@@ -318062,7 +318143,7 @@ var require_extract_zip = __commonJS((exports, module) => {
318062
318143
  mkdirOptions.mode = procMode;
318063
318144
  }
318064
318145
  debug("mkdir", { dir: destDir, ...mkdirOptions });
318065
- await fs5.mkdir(destDir, mkdirOptions);
318146
+ await fs6.mkdir(destDir, mkdirOptions);
318066
318147
  if (isDir)
318067
318148
  return;
318068
318149
  debug("opening read stream", dest);
@@ -318070,7 +318151,7 @@ var require_extract_zip = __commonJS((exports, module) => {
318070
318151
  if (symlink) {
318071
318152
  const link = await getStream(readStream2);
318072
318153
  debug("creating symlink", link, dest);
318073
- await fs5.symlink(link, dest);
318154
+ await fs6.symlink(link, dest);
318074
318155
  } else {
318075
318156
  await pipeline2(readStream2, createWriteStream(dest, { mode: procMode }));
318076
318157
  }
@@ -318102,8 +318183,8 @@ var require_extract_zip = __commonJS((exports, module) => {
318102
318183
  if (!path7.isAbsolute(opts.dir)) {
318103
318184
  throw new Error("Target directory is expected to be absolute");
318104
318185
  }
318105
- await fs5.mkdir(opts.dir, { recursive: true });
318106
- opts.dir = await fs5.realpath(opts.dir);
318186
+ await fs6.mkdir(opts.dir, { recursive: true });
318187
+ opts.dir = await fs6.realpath(opts.dir);
318107
318188
  return new Extractor(zipPath, opts).extract();
318108
318189
  };
318109
318190
  });
@@ -318199,9 +318280,80 @@ var require_dist17 = __commonJS((exports) => {
318199
318280
  exports.default = deferred;
318200
318281
  });
318201
318282
 
318283
+ // src/package_load/error_wire.ts
318284
+ import { MalloyError as MalloyError3 } from "@malloydata/malloy";
318285
+ function serializeError(error2) {
318286
+ const serialized = serializeErrorShape(error2);
318287
+ const errno = error2 instanceof Error ? errnoWireFields(error2) : undefined;
318288
+ return errno ? { ...serialized, errno } : serialized;
318289
+ }
318290
+ function serializeErrorShape(error2) {
318291
+ if (error2 instanceof MalloyError3) {
318292
+ return {
318293
+ name: error2.name,
318294
+ message: error2.message,
318295
+ stack: error2.stack,
318296
+ malloyProblems: error2.problems,
318297
+ isCompilationError: true
318298
+ };
318299
+ }
318300
+ if (error2 instanceof ModelCompilationError) {
318301
+ return {
318302
+ name: error2.name,
318303
+ message: error2.message,
318304
+ stack: error2.stack,
318305
+ isCompilationError: true
318306
+ };
318307
+ }
318308
+ if (error2 instanceof PackageManifestError) {
318309
+ return {
318310
+ name: error2.name,
318311
+ message: error2.message,
318312
+ stack: error2.stack,
318313
+ isManifestError: true
318314
+ };
318315
+ }
318316
+ if (error2 instanceof Error) {
318317
+ return {
318318
+ name: error2.name,
318319
+ message: error2.message,
318320
+ stack: error2.stack
318321
+ };
318322
+ }
318323
+ return { name: "Error", message: String(error2) };
318324
+ }
318325
+ function deserializeError(serialized) {
318326
+ const err = new Error(serialized.message);
318327
+ err.name = serialized.name;
318328
+ if (serialized.stack)
318329
+ err.stack = serialized.stack;
318330
+ if (serialized.errno)
318331
+ Object.assign(err, serialized.errno);
318332
+ if (serialized.malloyProblems) {
318333
+ err.problems = serialized.malloyProblems;
318334
+ }
318335
+ if (serialized.isCompilationError) {
318336
+ const wrapped = new ModelCompilationError(err);
318337
+ if (serialized.stack)
318338
+ wrapped.stack = serialized.stack;
318339
+ return wrapped;
318340
+ }
318341
+ if (serialized.isManifestError) {
318342
+ const manifestError = new PackageManifestError(serialized.message);
318343
+ if (serialized.stack)
318344
+ manifestError.stack = serialized.stack;
318345
+ return manifestError;
318346
+ }
318347
+ return err;
318348
+ }
318349
+ var init_error_wire = __esm(() => {
318350
+ init_errors();
318351
+ });
318352
+
318202
318353
  // src/package_load/package_load_pool.ts
318203
318354
  var exports_package_load_pool = {};
318204
318355
  __export(exports_package_load_pool, {
318356
+ serializeError: () => serializeError,
318205
318357
  getPackageLoadWorkerCount: () => getPackageLoadWorkerCount,
318206
318358
  getPackageLoadPool: () => getPackageLoadPool,
318207
318359
  deserializeError: () => deserializeError,
@@ -318635,38 +318787,6 @@ function adaptResult(result) {
318635
318787
  timings: result.timings
318636
318788
  };
318637
318789
  }
318638
- function serializeError(error2) {
318639
- if (error2 instanceof Error) {
318640
- return {
318641
- name: error2.name,
318642
- message: error2.message,
318643
- stack: error2.stack
318644
- };
318645
- }
318646
- return { name: "Error", message: String(error2) };
318647
- }
318648
- function deserializeError(serialized) {
318649
- const err = new Error(serialized.message);
318650
- err.name = serialized.name;
318651
- if (serialized.stack)
318652
- err.stack = serialized.stack;
318653
- if (serialized.malloyProblems) {
318654
- err.problems = serialized.malloyProblems;
318655
- }
318656
- if (serialized.isCompilationError) {
318657
- const wrapped = new ModelCompilationError(err);
318658
- if (serialized.stack)
318659
- wrapped.stack = serialized.stack;
318660
- return wrapped;
318661
- }
318662
- if (serialized.isManifestError) {
318663
- const manifestError = new PackageManifestError(serialized.message);
318664
- if (serialized.stack)
318665
- manifestError.stack = serialized.stack;
318666
- return manifestError;
318667
- }
318668
- return err;
318669
- }
318670
318790
  function getPackageLoadPool() {
318671
318791
  if (singleton === null) {
318672
318792
  const n = getPackageLoadWorkerCount();
@@ -318683,8 +318803,9 @@ async function __setPackageLoadPoolForTests(pool) {
318683
318803
  }
318684
318804
  var PACKAGE_LOAD_JOB_TIMEOUT_MS, WORKER_SPAWN_TIMEOUT_MS = 30000, defaultUrlReader, singleton = null;
318685
318805
  var init_package_load_pool = __esm(() => {
318686
- init_errors();
318687
318806
  init_logger();
318807
+ init_error_wire();
318808
+ init_error_wire();
318688
318809
  PACKAGE_LOAD_JOB_TIMEOUT_MS = (() => {
318689
318810
  const raw = process.env.PACKAGE_LOAD_JOB_TIMEOUT_MS;
318690
318811
  if (raw === undefined)
@@ -318696,10 +318817,10 @@ var init_package_load_pool = __esm(() => {
318696
318817
  })();
318697
318818
  defaultUrlReader = {
318698
318819
  readURL: async (url2) => {
318699
- const { promises: fs5 } = await import("fs");
318820
+ const { promises: fs6 } = await import("fs");
318700
318821
  const { fileURLToPath: fileURLToPath4 } = await import("url");
318701
318822
  const filePath = url2.protocol === "file:" ? fileURLToPath4(url2) : url2.toString();
318702
- return fs5.readFile(filePath, "utf8");
318823
+ return fs6.readFile(filePath, "utf8");
318703
318824
  }
318704
318825
  };
318705
318826
  });
@@ -318872,7 +318993,7 @@ var init_materialization_metrics = __esm(() => {
318872
318993
  });
318873
318994
 
318874
318995
  // src/utils.ts
318875
- import * as fs5 from "fs";
318996
+ import * as fs6 from "fs";
318876
318997
  import * as path8 from "path";
318877
318998
  import { fileURLToPath as fileURLToPath4 } from "url";
318878
318999
  function ignoreDotfiles(file) {
@@ -318889,7 +319010,7 @@ var init_utils4 = __esm(() => {
318889
319010
  if (url2.protocol == "file:") {
318890
319011
  path9 = fileURLToPath4(url2);
318891
319012
  }
318892
- return fs5.promises.readFile(path9, "utf8");
319013
+ return fs6.promises.readFile(path9, "utf8");
318893
319014
  }
318894
319015
  };
318895
319016
  });
@@ -324018,7 +324139,7 @@ import {
324018
324139
  InMemoryURLReader as InMemoryURLReader2,
324019
324140
  isSourceDef as isSourceDef9,
324020
324141
  MalloyConfig as MalloyConfig2,
324021
- MalloyError as MalloyError4,
324142
+ MalloyError as MalloyError5,
324022
324143
  Annotations as Annotations6,
324023
324144
  modelDefToModelInfo as modelDefToModelInfo2,
324024
324145
  Runtime as Runtime2
@@ -324027,7 +324148,7 @@ import {
324027
324148
  MalloySQLParser,
324028
324149
  MalloySQLStatementType
324029
324150
  } from "@malloydata/malloy-sql";
324030
- import * as fs7 from "fs/promises";
324151
+ import * as fs8 from "fs/promises";
324031
324152
  import { readFileSync as readFileSync2 } from "fs";
324032
324153
  import { createRequire as createRequire2 } from "module";
324033
324154
  import * as path10 from "path";
@@ -324085,7 +324206,7 @@ async function compileErrorOf(runnable) {
324085
324206
  await runnable.getPreparedQuery();
324086
324207
  return;
324087
324208
  } catch (error2) {
324088
- return error2 instanceof MalloyError4 ? error2 : undefined;
324209
+ return error2 instanceof MalloyError5 ? error2 : undefined;
324089
324210
  }
324090
324211
  }
324091
324212
  function bindingsAllowDegradeToLive(bindings) {
@@ -325417,7 +325538,7 @@ var init_model = __esm(() => {
325417
325538
  if (error2 instanceof Error && error2.stack) {
325418
325539
  logger.error("Error stack", error2.stack);
325419
325540
  }
325420
- if (error2 instanceof MalloyError4) {
325541
+ if (error2 instanceof MalloyError5) {
325421
325542
  const problems = error2.problems;
325422
325543
  for (const problem of problems) {
325423
325544
  logger.error("Problem", problem);
@@ -325703,7 +325824,7 @@ var init_model = __esm(() => {
325703
325824
  await runnable.getPreparedQuery();
325704
325825
  compiledSource = await this.resolveAuthorizeSourceFromRunnable(runnable);
325705
325826
  } catch (error2) {
325706
- if (error2 instanceof MalloyError4 || error2 instanceof ModelCompilationError) {
325827
+ if (error2 instanceof MalloyError5 || error2 instanceof ModelCompilationError) {
325707
325828
  return;
325708
325829
  }
325709
325830
  throw error2;
@@ -326268,7 +326389,7 @@ var init_model = __esm(() => {
326268
326389
  async getQueryResults(sourceName, queryName, query, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput, responseShape = "full", bypassAuthorize = false, includeHiddenFilesAndSources = false) {
326269
326390
  const startTime = performance.now();
326270
326391
  if (this.compilationError) {
326271
- if (this.compilationError instanceof MalloyError4 || this.compilationError instanceof ModelCompilationError) {
326392
+ if (this.compilationError instanceof MalloyError5 || this.compilationError instanceof ModelCompilationError) {
326272
326393
  throw this.compilationError;
326273
326394
  }
326274
326395
  throw new BadRequestError(`Model compilation failed: ${this.compilationError.message}`);
@@ -326414,7 +326535,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
326414
326535
  if (error2 instanceof FilterValidationError) {
326415
326536
  throw new BadRequestError(error2.message);
326416
326537
  }
326417
- if (error2 instanceof MalloyError4) {
326538
+ if (error2 instanceof MalloyError5) {
326418
326539
  throw error2;
326419
326540
  }
326420
326541
  const errorMessage = error2 instanceof Error ? error2.message : String(error2);
@@ -326522,7 +326643,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
326522
326643
  });
326523
326644
  throw new BadRequestError(err instanceof Error ? err.message : String(err));
326524
326645
  }
326525
- if (err instanceof MalloyError4) {
326646
+ if (err instanceof MalloyError5) {
326526
326647
  throw err;
326527
326648
  }
326528
326649
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -326850,7 +326971,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
326850
326971
  recordNotebookCellExecution(this.notebookFormat(), kind, "ok", performance.now() - started);
326851
326972
  return result;
326852
326973
  } catch (error2) {
326853
- recordNotebookCellExecution(this.notebookFormat(), kind, error2 instanceof AccessDeniedError ? "denied" : error2 instanceof NotQueryableError ? "not_queryable" : error2 instanceof BadRequestError || error2 instanceof MalloyError4 ? "bad_request" : "error", performance.now() - started);
326974
+ recordNotebookCellExecution(this.notebookFormat(), kind, error2 instanceof AccessDeniedError ? "denied" : error2 instanceof NotQueryableError ? "not_queryable" : error2 instanceof BadRequestError || error2 instanceof MalloyError5 ? "bad_request" : "error", performance.now() - started);
326854
326975
  throw error2;
326855
326976
  }
326856
326977
  }
@@ -326962,7 +327083,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
326962
327083
  });
326963
327084
  throw new BadRequestError(error2 instanceof Error ? error2.message : String(error2));
326964
327085
  }
326965
- if (error2 instanceof MalloyError4) {
327086
+ if (error2 instanceof MalloyError5) {
326966
327087
  throw error2;
326967
327088
  }
326968
327089
  if (error2 instanceof PayloadTooLargeError) {
@@ -327006,7 +327127,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
327006
327127
  throw new ModelNotFoundError(`${modelPath} does not exist.`);
327007
327128
  }
327008
327129
  try {
327009
- if (!(await fs7.stat(fullModelPath)).isFile()) {
327130
+ if (!(await fs8.stat(fullModelPath)).isFile()) {
327010
327131
  throw new ModelNotFoundError(`${modelPath} is not a file.`);
327011
327132
  }
327012
327133
  } catch {
@@ -327110,7 +327231,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
327110
327231
  let fileContents = undefined;
327111
327232
  let parse5 = undefined;
327112
327233
  try {
327113
- fileContents = await fs7.readFile(modelURL, "utf8");
327234
+ fileContents = await fs8.readFile(modelURL, "utf8");
327114
327235
  } catch {
327115
327236
  throw new ModelNotFoundError("Model not found: " + modelPath);
327116
327237
  }
@@ -327204,7 +327325,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
327204
327325
  async getFileText(packagePath) {
327205
327326
  const fullPath = path10.join(packagePath, this.modelPath);
327206
327327
  try {
327207
- return await fs7.readFile(fullPath, "utf8");
327328
+ return await fs8.readFile(fullPath, "utf8");
327208
327329
  } catch {
327209
327330
  throw new ModelNotFoundError(`Model file not found: ${this.modelPath}`);
327210
327331
  }
@@ -328191,7 +328312,7 @@ var require_minimatch = __commonJS((exports, module) => {
328191
328312
 
328192
328313
  // ../../node_modules/recursive-readdir/index.js
328193
328314
  var require_recursive_readdir = __commonJS((exports, module) => {
328194
- var fs8 = __require("fs");
328315
+ var fs9 = __require("fs");
328195
328316
  var p2 = __require("path");
328196
328317
  var minimatch = require_minimatch();
328197
328318
  function patternMatcher(pattern) {
@@ -328225,7 +328346,7 @@ var require_recursive_readdir = __commonJS((exports, module) => {
328225
328346
  }
328226
328347
  ignores = ignores.map(toMatcherFunction);
328227
328348
  var list = [];
328228
- fs8.readdir(path11, function(err, files) {
328349
+ fs9.readdir(path11, function(err, files) {
328229
328350
  if (err) {
328230
328351
  return callback(err);
328231
328352
  }
@@ -328235,7 +328356,7 @@ var require_recursive_readdir = __commonJS((exports, module) => {
328235
328356
  }
328236
328357
  files.forEach(function(file) {
328237
328358
  var filePath = p2.join(path11, file);
328238
- fs8.stat(filePath, function(_err, stats) {
328359
+ fs9.stat(filePath, function(_err, stats) {
328239
328360
  if (_err) {
328240
328361
  return callback(_err);
328241
328362
  }
@@ -346984,12 +347105,12 @@ var require_dist19 = __commonJS((exports, module) => {
346984
347105
  throw new Error(`Unknown format "${name}"`);
346985
347106
  return f3;
346986
347107
  };
346987
- function addFormats(ajv, list, fs11, exportName) {
347108
+ function addFormats(ajv, list, fs12, exportName) {
346988
347109
  var _a2;
346989
347110
  var _b;
346990
347111
  (_a2 = (_b = ajv.opts.code).formats) !== null && _a2 !== undefined || (_b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`);
346991
347112
  for (const f3 of list)
346992
- ajv.addFormat(f3, fs11[f3]);
347113
+ ajv.addFormat(f3, fs12[f3]);
346993
347114
  }
346994
347115
  module.exports = exports = formatsPlugin;
346995
347116
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -356703,6 +356824,7 @@ init_errors();
356703
356824
  init_logger();
356704
356825
  init_gcs_s3_utils();
356705
356826
  init_gcp_impersonation();
356827
+ init_google_credentials();
356706
356828
  init_introspection_sql();
356707
356829
  var import_bigquery = __toESM(require_src144(), 1);
356708
356830
  import { ClientSecretCredential } from "@azure/identity";
@@ -356766,6 +356888,7 @@ async function createBigQueryClient(connection) {
356766
356888
  throw new Error("BigQuery project ID is required. Either set the defaultProjectId in the connection configuration or the project_id in the service account key JSON.");
356767
356889
  }
356768
356890
  } else if (Object.keys(connection.bigqueryConnection).length === 0 && process.env.GOOGLE_APPLICATION_CREDENTIALS) {
356891
+ assertGoogleCredentialsIsNotADirectory();
356769
356892
  config2.keyFilename = process.env.GOOGLE_APPLICATION_CREDENTIALS || "";
356770
356893
  } else {
356771
356894
  throw new Error("BigQuery connection is required, either set the bigqueryConnection in the connection configuration or set the GOOGLE_APPLICATION_CREDENTIALS environment variable.");
@@ -364841,11 +364964,21 @@ class PackageController {
364841
364964
  const packageName = body.name;
364842
364965
  const environment = await this.environmentStore.getEnvironment(environmentName, false);
364843
364966
  let result;
364844
- if (body.location) {
364845
- const bodyLocation = body.location;
364846
- result = await environment.installPackage(packageName, (stagingPath) => this.downloadInto(environmentName, packageName, bodyLocation, stagingPath), (pkg) => formatPublishRejections(pkg));
364847
- } else {
364848
- result = await environment.addPackage(packageName);
364967
+ try {
364968
+ if (body.location) {
364969
+ const bodyLocation = body.location;
364970
+ result = await environment.installPackage(packageName, (stagingPath) => this.downloadInto(environmentName, packageName, bodyLocation, stagingPath), (pkg) => formatPublishRejections(pkg));
364971
+ } else {
364972
+ result = await environment.addPackage(packageName);
364973
+ }
364974
+ } catch (error2) {
364975
+ const answered = internalErrorToHttpError(error2, {
364976
+ log: false
364977
+ });
364978
+ if (answered.status >= 500) {
364979
+ environment.recordPackageAddFailure(packageName, answered.json.message);
364980
+ }
364981
+ throw error2;
364849
364982
  }
364850
364983
  if (!result) {
364851
364984
  throw new Error(`Failed to create package ${packageName}`);
@@ -366555,7 +366688,7 @@ var import_client_s33 = __toESM(require_dist_cjs75(), 1);
366555
366688
  import { Storage as Storage2 } from "@google-cloud/storage";
366556
366689
  var import_extract_zip = __toESM(require_extract_zip(), 1);
366557
366690
  import crypto5 from "crypto";
366558
- import * as fs10 from "fs";
366691
+ import * as fs11 from "fs";
366559
366692
  import * as os3 from "os";
366560
366693
  import * as path13 from "path";
366561
366694
 
@@ -372407,7 +372540,7 @@ class StorageManager {
372407
372540
  // src/service/environment.ts
372408
372541
  init_notebook();
372409
372542
  init_dashboard();
372410
- import { MalloyError as MalloyError6, Runtime as Runtime3 } from "@malloydata/malloy";
372543
+ import { MalloyError as MalloyError7, Runtime as Runtime3 } from "@malloydata/malloy";
372411
372544
 
372412
372545
  // src/service/notebook_lint.ts
372413
372546
  init_dashboard();
@@ -372852,13 +372985,13 @@ init_errors();
372852
372985
  init_authorize();
372853
372986
  init_given();
372854
372987
  import crypto4 from "crypto";
372855
- import * as fs9 from "fs";
372988
+ import * as fs10 from "fs";
372856
372989
  import * as path12 from "path";
372857
372990
  import { fileURLToPath as fileURLToPath7, pathToFileURL as pathToFileURL3 } from "url";
372858
372991
 
372859
372992
  // src/service/compile_restriction.ts
372860
372993
  init_errors();
372861
- import { Malloy, MalloyError as MalloyError3 } from "@malloydata/malloy";
372994
+ import { Malloy, MalloyError as MalloyError4 } from "@malloydata/malloy";
372862
372995
  var RESTRICTED_CONSTRUCT_CODE = "restricted-construct-forbidden";
372863
372996
  var SYNTAX_ERROR_CODE = "syntax-error";
372864
372997
  function isParseFailure(problem) {
@@ -372881,7 +373014,7 @@ async function assertNoRestrictedConstructs(runtime, model, source) {
372881
373014
  });
372882
373015
  problems = compiled.problems ?? [];
372883
373016
  } catch (error2) {
372884
- if (!(error2 instanceof MalloyError3))
373017
+ if (!(error2 instanceof MalloyError4))
372885
373018
  throw error2;
372886
373019
  problems = error2.problems;
372887
373020
  }
@@ -372911,7 +373044,7 @@ init_connection_config();
372911
373044
  init_logger();
372912
373045
  var import_client_s32 = __toESM(require_dist_cjs75(), 1);
372913
373046
  import { Storage } from "@google-cloud/storage";
372914
- import * as fs6 from "fs/promises";
373047
+ import * as fs7 from "fs/promises";
372915
373048
  import { fileURLToPath as fileURLToPath5 } from "url";
372916
373049
 
372917
373050
  // src/storage/DatabaseInterface.ts
@@ -372947,9 +373080,9 @@ async function readManifestBytes(uri) {
372947
373080
  return res.Body.transformToString();
372948
373081
  }
372949
373082
  if (uri.startsWith("file://")) {
372950
- return fs6.readFile(fileURLToPath5(uri), "utf8");
373083
+ return fs7.readFile(fileURLToPath5(uri), "utf8");
372951
373084
  }
372952
- return fs6.readFile(uri, "utf8");
373085
+ return fs7.readFile(uri, "utf8");
372953
373086
  }
372954
373087
  async function fetchManifestEntries(uri) {
372955
373088
  const raw = await readManifestBytes(uri);
@@ -373008,7 +373141,7 @@ init_logger();
373008
373141
  init_notebook_metrics();
373009
373142
  init_materialization_metrics();
373010
373143
  var import_recursive_readdir = __toESM(require_recursive_readdir(), 1);
373011
- import * as fs8 from "fs/promises";
373144
+ import * as fs9 from "fs/promises";
373012
373145
  import * as path11 from "path";
373013
373146
  import { pathToFileURL as pathToFileURL2 } from "url";
373014
373147
  import"@malloydata/db-duckdb/native";
@@ -373019,7 +373152,7 @@ import {
373019
373152
  EmptyURLReader,
373020
373153
  FixedConnectionMap as FixedConnectionMap2,
373021
373154
  MalloyConfig as MalloyConfig3,
373022
- MalloyError as MalloyError5
373155
+ MalloyError as MalloyError6
373023
373156
  } from "@malloydata/malloy";
373024
373157
 
373025
373158
  // src/package_load_metrics.ts
@@ -373909,7 +374042,7 @@ function toTableNameManifest(entries) {
373909
374042
  return out;
373910
374043
  }
373911
374044
  function packageLoadFailureStatus(error2) {
373912
- if (error2 instanceof ModelCompilationError || error2 instanceof MalloyError5) {
374045
+ if (error2 instanceof ModelCompilationError || error2 instanceof MalloyError6) {
373913
374046
  return "compilation_error";
373914
374047
  }
373915
374048
  if (error2 instanceof BadRequestError || error2 instanceof PackageManifestError) {
@@ -374099,11 +374232,11 @@ class Package {
374099
374232
  if (!cleanupDirectoryOnFailure) {
374100
374233
  logger.info(`Preserving existing package directory after failed load: ${packagePath}`);
374101
374234
  } else {
374102
- const stat7 = await fs8.lstat(packagePath).catch(() => null);
374235
+ const stat7 = await fs9.lstat(packagePath).catch(() => null);
374103
374236
  if (stat7?.isSymbolicLink()) {
374104
374237
  logger.info(`Skipping cleanup of symlinked package path on failure: ${packagePath}`);
374105
374238
  } else {
374106
- await fs8.rm(packagePath, { recursive: true, force: true });
374239
+ await fs9.rm(packagePath, { recursive: true, force: true });
374107
374240
  logger.info(`Cleaned up failed package directory: ${packagePath}`);
374108
374241
  }
374109
374242
  }
@@ -374125,10 +374258,10 @@ class Package {
374125
374258
  defaultConnectionName: "duckdb"
374126
374259
  }).catch((err) => {
374127
374260
  const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
374128
- if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError || realError instanceof PackageManifestError) {
374261
+ if (realError instanceof MalloyError6 || realError instanceof ModelCompilationError || realError instanceof PackageManifestError) {
374129
374262
  throw realError;
374130
374263
  }
374131
- throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`);
374264
+ throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`, { cause: realError });
374132
374265
  });
374133
374266
  const [outcome, databases] = await Promise.all([
374134
374267
  workerOutcome,
@@ -374183,7 +374316,7 @@ class Package {
374183
374316
  });
374184
374317
  }
374185
374318
  if (sm.modelPath.endsWith(MODEL_FILE_SUFFIX)) {
374186
- const modelSource = await fs8.readFile(path11.join(packagePath, sm.modelPath), "utf-8");
374319
+ const modelSource = await fs9.readFile(path11.join(packagePath, sm.modelPath), "utf-8");
374187
374320
  assertPersistNamesQuoted(modelSource, sm.modelPath);
374188
374321
  }
374189
374322
  models.set(sm.modelPath, model);
@@ -374803,10 +374936,10 @@ class Package {
374803
374936
  });
374804
374937
  } catch (err) {
374805
374938
  const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
374806
- if (realError instanceof MalloyError5 || realError instanceof ModelCompilationError || realError instanceof PackageManifestError) {
374939
+ if (realError instanceof MalloyError6 || realError instanceof ModelCompilationError || realError instanceof PackageManifestError) {
374807
374940
  throw realError;
374808
374941
  }
374809
- throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`);
374942
+ throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`, { cause: realError });
374810
374943
  }
374811
374944
  const nextModels = new Map;
374812
374945
  const renderTagWarnings = [];
@@ -374912,7 +375045,7 @@ class Package {
374912
375045
  }
374913
375046
  async claimsToBeADashboard(modelPath) {
374914
375047
  try {
374915
- const source = await fs8.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
375048
+ const source = await fs9.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
374916
375049
  return hasArtifactLineOutsideBlocks(source, /^##?[ \t]*artifact\b/);
374917
375050
  } catch {
374918
375051
  return false;
@@ -374924,7 +375057,7 @@ class Package {
374924
375057
  return model.carriesNotebookArtifactNote();
374925
375058
  if (!model.getCompilationError())
374926
375059
  return false;
374927
- const source = await fs8.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
375060
+ const source = await fs9.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
374928
375061
  return claimsToBeANotebook(source);
374929
375062
  } catch {
374930
375063
  return false;
@@ -375237,7 +375370,7 @@ class Package {
375237
375370
  const dir = safeJoinUnderRoot(this.packagePath, DASHBOARDS_DIR);
375238
375371
  let entries;
375239
375372
  try {
375240
- entries = await fs8.readdir(dir);
375373
+ entries = await fs9.readdir(dir);
375241
375374
  } catch {
375242
375375
  return [];
375243
375376
  }
@@ -375343,8 +375476,11 @@ class Package {
375343
375476
  static async validatePackageManifestExistsOrThrowError(packagePath) {
375344
375477
  const packageConfigPath = safeJoinUnderRoot(packagePath, PACKAGE_MANIFEST_NAME);
375345
375478
  try {
375346
- await fs8.stat(packageConfigPath);
375347
- } catch {
375479
+ await fs9.stat(packageConfigPath);
375480
+ } catch (error2) {
375481
+ const code = error2.code;
375482
+ if (code !== "ENOENT" && code !== "ENOTDIR")
375483
+ throw error2;
375348
375484
  logger.error(`Can't find ${packageConfigPath}`);
375349
375485
  throw new PackageNotFoundError(`Package manifest for ${packagePath} does not exist.`);
375350
375486
  }
@@ -375476,6 +375612,7 @@ async function denyHiddenAsNotQueryable(convert, gate) {
375476
375612
  throw error2;
375477
375613
  }
375478
375614
  }
375615
+ var MAX_RECORDED_ADD_FAILURES = 100;
375479
375616
 
375480
375617
  class Environment {
375481
375618
  packages = new Map;
@@ -375483,6 +375620,7 @@ class Environment {
375483
375620
  packageStatuses = new Map;
375484
375621
  failedPackages = new Map;
375485
375622
  mountErrors = new Map;
375623
+ recordedAddFailures = [];
375486
375624
  staleCompileErrors = new Map;
375487
375625
  malloyConfig;
375488
375626
  connectionMutex = new Mutex;
@@ -375522,11 +375660,11 @@ class Environment {
375522
375660
  return;
375523
375661
  const readmePath = path12.join(this.environmentPath, "README.md");
375524
375662
  try {
375525
- await fs9.promises.writeFile(readmePath, readme, "utf-8");
375663
+ await fs10.promises.writeFile(readmePath, readme, "utf-8");
375526
375664
  logger.info(`Updated README.md for environment ${this.environmentName}`);
375527
375665
  } catch (err) {
375528
375666
  logger.error(`Failed to write README.md`, { error: err });
375529
- throw new Error(`Failed to update environment README`);
375667
+ throw new Error(`Failed to update environment README`, { cause: err });
375530
375668
  }
375531
375669
  }
375532
375670
  async update(payload) {
@@ -375560,7 +375698,7 @@ class Environment {
375560
375698
  }
375561
375699
  static async create(environmentName, environmentPath, connections, storageDestinations = []) {
375562
375700
  assertSafeEnvironmentPath(environmentPath);
375563
- if (!(await fs9.promises.stat(environmentPath))?.isDirectory()) {
375701
+ if (!(await fs10.promises.stat(environmentPath))?.isDirectory()) {
375564
375702
  throw new EnvironmentNotFoundError(`Environment path ${environmentPath} not found`);
375565
375703
  }
375566
375704
  logger.info(`Creating environment with connection configuration`);
@@ -375578,7 +375716,7 @@ class Environment {
375578
375716
  async reloadEnvironmentMetadata() {
375579
375717
  let readme = "";
375580
375718
  try {
375581
- readme = (await fs9.promises.readFile(safeJoinUnderRoot(this.environmentPath, README_NAME))).toString();
375719
+ readme = (await fs10.promises.readFile(safeJoinUnderRoot(this.environmentPath, README_NAME))).toString();
375582
375720
  } catch {}
375583
375721
  this.metadata = {
375584
375722
  ...this.metadata,
@@ -375622,7 +375760,7 @@ class Environment {
375622
375760
  if (scope === "append") {
375623
375761
  let modelContent = "";
375624
375762
  try {
375625
- modelContent = await fs9.promises.readFile(modelPath, "utf8");
375763
+ modelContent = await fs10.promises.readFile(modelPath, "utf8");
375626
375764
  } catch {}
375627
375765
  fullSource = modelContent ? `${modelContent}
375628
375766
  ${source}` : source ?? "";
@@ -375646,7 +375784,7 @@ ${source}` : source ?? "";
375646
375784
  const pkg = await this._loadOrGetPackageLocked(packageName);
375647
375785
  let { model: gateModel, exact: hasExactGateModel } = pkg.getCompileAuthorizationModel(modelName);
375648
375786
  if (!hasExactGateModel && source !== undefined) {
375649
- const diskTarget = await fs9.promises.stat(modelPath).catch(() => {
375787
+ const diskTarget = await fs10.promises.stat(modelPath).catch(() => {
375650
375788
  return;
375651
375789
  });
375652
375790
  if (diskTarget?.isFile()) {
@@ -375700,7 +375838,7 @@ ${source}` : source ?? "";
375700
375838
  }, () => hasExactGateModel ? gateModel.assertAuthorizedForRunnable(finalQuery, givens ?? {}) : gateModel.assertAuthorizedFromCompiledRunnable(finalQuery, givens ?? {}));
375701
375839
  }
375702
375840
  } catch (error2) {
375703
- if (!(error2 instanceof MalloyError6))
375841
+ if (!(error2 instanceof MalloyError7))
375704
375842
  throw error2;
375705
375843
  }
375706
375844
  }
@@ -375716,7 +375854,7 @@ ${source}` : source ?? "";
375716
375854
  replacement: source === undefined ? undefined : { modelPath: modelName, source }
375717
375855
  });
375718
375856
  } catch (error2) {
375719
- if (error2 instanceof MalloyError6 || error2 instanceof ModelCompilationError || error2 instanceof PackageManifestError) {
375857
+ if (error2 instanceof MalloyError7 || error2 instanceof ModelCompilationError || error2 instanceof PackageManifestError) {
375720
375858
  throw error2;
375721
375859
  }
375722
375860
  throw new ServiceUnavailableError(`Package compile worker unavailable: ${error2 instanceof Error ? error2.message : String(error2)}`);
@@ -375755,7 +375893,7 @@ ${source}` : source ?? "";
375755
375893
  ], compiled.modelPath);
375756
375894
  }
375757
375895
  }
375758
- const lintText = !(isNotebookModelPath(compiled.modelPath) || isDashboardModelPath(compiled.modelPath)) ? undefined : compiled.modelSourceText ?? (compiled.modelPath === modelName && source !== undefined ? source : await fs9.promises.readFile(path12.join(packagePath, compiled.modelPath), "utf8").catch(() => {
375896
+ const lintText = !(isNotebookModelPath(compiled.modelPath) || isDashboardModelPath(compiled.modelPath)) ? undefined : compiled.modelSourceText ?? (compiled.modelPath === modelName && source !== undefined ? source : await fs10.promises.readFile(path12.join(packagePath, compiled.modelPath), "utf8").catch(() => {
375759
375897
  return;
375760
375898
  }));
375761
375899
  if (lintText !== undefined) {
@@ -375785,7 +375923,7 @@ ${source}` : source ?? "";
375785
375923
  environment: this.environmentName,
375786
375924
  reason: "base_model_load_failed"
375787
375925
  });
375788
- if (error2 instanceof MalloyError6) {
375926
+ if (error2 instanceof MalloyError7) {
375789
375927
  throw new CompileRefusedError(`Cannot validate the submitted source: the model ` + `"${modelName}" does not compile, so there is nothing ` + `to check the submitted source against. Problems: ` + error2.problems.map((p2) => p2.message).join("; "));
375790
375928
  }
375791
375929
  throw new CompileRefusedError(`Cannot validate the submitted source: the model ` + `"${modelName}" could not be loaded to check it against.`);
@@ -375828,7 +375966,7 @@ ${source}` : source ?? "";
375828
375966
  if (typeof givenCode === "string" && givenCode.startsWith("runtime-given-")) {
375829
375967
  throw new BadRequestError(error2 instanceof Error ? error2.message : String(error2));
375830
375968
  }
375831
- if (error2 instanceof MalloyError6) {
375969
+ if (error2 instanceof MalloyError7) {
375832
375970
  throw error2;
375833
375971
  }
375834
375972
  }
@@ -375844,7 +375982,7 @@ ${source}` : source ?? "";
375844
375982
  sql
375845
375983
  };
375846
375984
  } catch (error2) {
375847
- if (error2 instanceof MalloyError6) {
375985
+ if (error2 instanceof MalloyError7) {
375848
375986
  return {
375849
375987
  problems: tagProblems([
375850
375988
  ...error2.problems,
@@ -375897,7 +376035,7 @@ ${source}` : source ?? "";
375897
376035
  try {
375898
376036
  const destinationRoot = storageDestinationRoot(this.environmentPath);
375899
376037
  if (this.destinations.length > 0) {
375900
- fs9.mkdirSync(destinationRoot, { recursive: true });
376038
+ fs10.mkdirSync(destinationRoot, { recursive: true });
375901
376039
  }
375902
376040
  this.destinationMalloyConfig = buildEnvironmentMalloyConfig(this.destinations, destinationRoot);
375903
376041
  } catch (error2) {
@@ -376018,7 +376156,7 @@ ${source}` : source ?? "";
376018
376156
  }
376019
376157
  async _readModelFileLocked(target) {
376020
376158
  try {
376021
- return await fs9.promises.readFile(target, "utf8");
376159
+ return await fs10.promises.readFile(target, "utf8");
376022
376160
  } catch (error2) {
376023
376161
  if (error2.code !== "ENOENT")
376024
376162
  throw error2;
@@ -376026,10 +376164,10 @@ ${source}` : source ?? "";
376026
376164
  }
376027
376165
  }
376028
376166
  async _writeModelFileLocked(target, source) {
376029
- await fs9.promises.mkdir(path12.dirname(target), { recursive: true });
376167
+ await fs10.promises.mkdir(path12.dirname(target), { recursive: true });
376030
376168
  const temporary = `${target}.${crypto4.randomUUID()}.tmp`;
376031
- await fs9.promises.writeFile(temporary, source, "utf8");
376032
- await fs9.promises.rename(temporary, target);
376169
+ await fs10.promises.writeFile(temporary, source, "utf8");
376170
+ await fs10.promises.rename(temporary, target);
376033
376171
  }
376034
376172
  async writeModelFileTransactional(packageName, modelPath, source, check2, verify) {
376035
376173
  assertSafePackageName(packageName);
@@ -376046,7 +376184,7 @@ ${source}` : source ?? "";
376046
376184
  if (previous !== undefined)
376047
376185
  await this._writeModelFileLocked(target, previous);
376048
376186
  else
376049
- await fs9.promises.rm(target, { force: true });
376187
+ await fs10.promises.rm(target, { force: true });
376050
376188
  await this._loadOrGetPackageLocked(packageName, true).catch(() => {
376051
376189
  return;
376052
376190
  });
@@ -376074,7 +376212,7 @@ ${source}` : source ?? "";
376074
376212
  if (path12.basename(dir) !== dirName)
376075
376213
  continue;
376076
376214
  try {
376077
- await fs9.promises.rm(dir, { recursive: true, force: true });
376215
+ await fs10.promises.rm(dir, { recursive: true, force: true });
376078
376216
  } catch (err) {
376079
376217
  logger.warn(`Failed to sweep stale ${dirName} dir at ${dir}`, {
376080
376218
  error: err
@@ -376180,7 +376318,7 @@ ${source}` : source ?? "";
376180
376318
  async addPackage(packageName, options = {}) {
376181
376319
  assertSafePackageName(packageName);
376182
376320
  const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
376183
- if (!await fs9.promises.access(packagePath).then(() => true).catch(() => false) || !(await fs9.promises.stat(packagePath))?.isDirectory()) {
376321
+ if (!await fs10.promises.access(packagePath).then(() => true).catch(() => false) || !(await fs10.promises.stat(packagePath))?.isDirectory()) {
376184
376322
  throw new PackageNotFoundError(`Package ${packageName} not found`);
376185
376323
  }
376186
376324
  this.assertCanAdmitNewPackage(packageName, "add a new package", options.allowAdmission === true);
@@ -376212,7 +376350,7 @@ ${source}` : source ?? "";
376212
376350
  async installPackage(packageName, downloader, validate) {
376213
376351
  assertSafePackageName(packageName);
376214
376352
  const stagingPath = this.allocateStagingPath(packageName);
376215
- await fs9.promises.mkdir(path12.dirname(stagingPath), { recursive: true });
376353
+ await fs10.promises.mkdir(path12.dirname(stagingPath), { recursive: true });
376216
376354
  logger.debug("install.phase1.download.started", {
376217
376355
  environmentName: this.environmentName,
376218
376356
  packageName,
@@ -376222,7 +376360,7 @@ ${source}` : source ?? "";
376222
376360
  try {
376223
376361
  await downloader(stagingPath);
376224
376362
  } catch (err) {
376225
- await fs9.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
376363
+ await fs10.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
376226
376364
  throw err;
376227
376365
  }
376228
376366
  logger.debug("install.phase1.download.completed", {
@@ -376238,13 +376376,13 @@ ${source}` : source ?? "";
376238
376376
  const canonicalPath = safeJoinUnderRoot(this.environmentPath, packageName);
376239
376377
  let retiredPath;
376240
376378
  const oldPackage = this.packages.get(packageName);
376241
- const oldExistsOnDisk = await fs9.promises.access(canonicalPath).then(() => true).catch(() => false);
376379
+ const oldExistsOnDisk = await fs10.promises.access(canonicalPath).then(() => true).catch(() => false);
376242
376380
  if (oldExistsOnDisk) {
376243
376381
  retiredPath = this.allocateRetiredPath(packageName);
376244
- await fs9.promises.mkdir(path12.dirname(retiredPath), {
376382
+ await fs10.promises.mkdir(path12.dirname(retiredPath), {
376245
376383
  recursive: true
376246
376384
  });
376247
- await fs9.promises.rename(canonicalPath, retiredPath);
376385
+ await fs10.promises.rename(canonicalPath, retiredPath);
376248
376386
  logger.debug("install.phase2.retired_old", {
376249
376387
  environmentName: this.environmentName,
376250
376388
  packageName,
@@ -376253,7 +376391,7 @@ ${source}` : source ?? "";
376253
376391
  }
376254
376392
  let newPackage;
376255
376393
  try {
376256
- await fs9.promises.rename(stagingPath, canonicalPath);
376394
+ await fs10.promises.rename(stagingPath, canonicalPath);
376257
376395
  this.setPackageStatus(packageName, "loading" /* LOADING */);
376258
376396
  newPackage = await Package.create(this.environmentName, packageName, canonicalPath, () => this.malloyConfig.malloyConfig, true);
376259
376397
  this.attachDestinationServeConfig(newPackage);
@@ -376267,11 +376405,11 @@ ${source}` : source ?? "";
376267
376405
  canonicalPath
376268
376406
  });
376269
376407
  } catch (err) {
376270
- await fs9.promises.rm(canonicalPath, { recursive: true, force: true }).catch(() => {});
376408
+ await fs10.promises.rm(canonicalPath, { recursive: true, force: true }).catch(() => {});
376271
376409
  let restored = false;
376272
376410
  if (retiredPath) {
376273
376411
  try {
376274
- await fs9.promises.rename(retiredPath, canonicalPath);
376412
+ await fs10.promises.rename(retiredPath, canonicalPath);
376275
376413
  restored = true;
376276
376414
  } catch (restoreErr) {
376277
376415
  logger.error("Failed to restore retired package after install rollback", {
@@ -376281,7 +376419,7 @@ ${source}` : source ?? "";
376281
376419
  });
376282
376420
  }
376283
376421
  }
376284
- await fs9.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
376422
+ await fs10.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
376285
376423
  if (oldPackage && restored) {
376286
376424
  this.setPackageStatus(packageName, "serving" /* SERVING */);
376287
376425
  } else {
@@ -376315,7 +376453,7 @@ ${source}` : source ?? "";
376315
376453
  packageName,
376316
376454
  retiredPath: pathToClean
376317
376455
  });
376318
- fs9.promises.rm(pathToClean, { recursive: true, force: true }).catch((err) => {
376456
+ fs10.promises.rm(pathToClean, { recursive: true, force: true }).catch((err) => {
376319
376457
  logger.warn(`Failed to clean up retired package directory ${pathToClean}`, { error: err });
376320
376458
  });
376321
376459
  });
@@ -376448,7 +376586,7 @@ ${source}` : source ?? "";
376448
376586
  try {
376449
376587
  let existingManifest = {};
376450
376588
  try {
376451
- const content = await fs9.promises.readFile(manifestPath, "utf-8");
376589
+ const content = await fs10.promises.readFile(manifestPath, "utf-8");
376452
376590
  existingManifest = JSON.parse(content);
376453
376591
  } catch (_err) {
376454
376592
  logger.warn(`Could not read manifest for ${packageName}`);
@@ -376476,11 +376614,11 @@ ${source}` : source ?? "";
376476
376614
  }
376477
376615
  } : {}
376478
376616
  };
376479
- await fs9.promises.writeFile(manifestPath, JSON.stringify(updatedManifest, null, 2), "utf-8");
376617
+ await fs10.promises.writeFile(manifestPath, JSON.stringify(updatedManifest, null, 2), "utf-8");
376480
376618
  logger.info(`Updated publisher.json for ${packageName}`);
376481
376619
  } catch (error2) {
376482
376620
  logger.error(`Failed to update publisher.json`, { error: error2 });
376483
- throw new Error(`Failed to update package manifest`);
376621
+ throw new Error(`Failed to update package manifest`, { cause: error2 });
376484
376622
  }
376485
376623
  }
376486
376624
  async updatePackage(packageName, body) {
@@ -376562,9 +376700,25 @@ ${source}` : source ?? "";
376562
376700
  setPackageMountError(packageName, message) {
376563
376701
  this.mountErrors.set(packageName, message);
376564
376702
  }
376703
+ recordPackageAddFailure(packageName, message) {
376704
+ if (this.packageStatuses.has(packageName))
376705
+ return;
376706
+ if (!this.mountErrors.has(packageName)) {
376707
+ this.recordedAddFailures.push(packageName);
376708
+ while (this.recordedAddFailures.length > MAX_RECORDED_ADD_FAILURES) {
376709
+ const evicted = this.recordedAddFailures.shift();
376710
+ if (evicted !== undefined)
376711
+ this.mountErrors.delete(evicted);
376712
+ }
376713
+ }
376714
+ this.mountErrors.set(packageName, message);
376715
+ }
376565
376716
  clearPackageLoadFailure(packageName) {
376566
376717
  this.failedPackages.delete(packageName);
376567
376718
  this.mountErrors.delete(packageName);
376719
+ const recorded = this.recordedAddFailures.indexOf(packageName);
376720
+ if (recorded !== -1)
376721
+ this.recordedAddFailures.splice(recorded, 1);
376568
376722
  this.staleCompileErrors.delete(packageName);
376569
376723
  }
376570
376724
  getFailedPackages() {
@@ -376609,10 +376763,10 @@ ${source}` : source ?? "";
376609
376763
  const retiredPath = this.allocateRetiredPath(packageName);
376610
376764
  let renamed = false;
376611
376765
  try {
376612
- await fs9.promises.mkdir(path12.dirname(retiredPath), {
376766
+ await fs10.promises.mkdir(path12.dirname(retiredPath), {
376613
376767
  recursive: true
376614
376768
  });
376615
- await fs9.promises.rename(canonicalPath, retiredPath);
376769
+ await fs10.promises.rename(canonicalPath, retiredPath);
376616
376770
  renamed = true;
376617
376771
  } catch (err) {
376618
376772
  logger.error("Error renaming package directory to retired during unload", {
@@ -376625,7 +376779,7 @@ ${source}` : source ?? "";
376625
376779
  this.packageStatuses.delete(packageName);
376626
376780
  if (renamed) {
376627
376781
  setImmediate(() => {
376628
- fs9.promises.rm(retiredPath, { recursive: true, force: true }).catch((err) => {
376782
+ fs10.promises.rm(retiredPath, { recursive: true, force: true }).catch((err) => {
376629
376783
  logger.warn(`Failed to clean up retired package directory ${retiredPath}`, { error: err });
376630
376784
  });
376631
376785
  });
@@ -376711,7 +376865,7 @@ ${source}` : source ?? "";
376711
376865
  async deleteDuckDBConnection(connectionName) {
376712
376866
  const duckdbPath = path12.join(this.environmentPath, `${connectionName}.duckdb`);
376713
376867
  try {
376714
- await fs9.promises.rm(duckdbPath, { force: true });
376868
+ await fs10.promises.rm(duckdbPath, { force: true });
376715
376869
  logger.info(`Removed DuckDB connection file ${connectionName} from environment ${this.environmentName}`);
376716
376870
  } catch (error2) {
376717
376871
  logger.error(`Failed to remove DuckDB connection file ${connectionName} from environment ${this.environmentName}`, { error: error2 });
@@ -376722,8 +376876,11 @@ ${source}` : source ?? "";
376722
376876
  logger.info(`Removed DuckLake connection ${connectionName} from environment ${this.environmentName}`);
376723
376877
  }
376724
376878
  }
376879
+
376880
+ // src/service/environment_store.ts
376881
+ init_google_credentials();
376725
376882
  // package.json
376726
- var version2 = "0.8.5";
376883
+ var version2 = "0.9.1";
376727
376884
 
376728
376885
  // src/version.ts
376729
376886
  var SERVER_VERSION = version2;
@@ -376777,11 +376934,11 @@ function validateEnvironmentAzureUrls(environment) {
376777
376934
  }
376778
376935
  async function clearMountTarget(targetPath) {
376779
376936
  try {
376780
- const stats = await fs10.promises.lstat(targetPath);
376937
+ const stats = await fs11.promises.lstat(targetPath);
376781
376938
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
376782
- await fs10.promises.rm(targetPath, { recursive: true, force: true });
376939
+ await fs11.promises.rm(targetPath, { recursive: true, force: true });
376783
376940
  } else {
376784
- await fs10.promises.unlink(targetPath);
376941
+ await fs11.promises.unlink(targetPath);
376785
376942
  }
376786
376943
  } catch {}
376787
376944
  }
@@ -376953,6 +377110,7 @@ class EnvironmentStore {
376953
377110
  environments = new Map;
376954
377111
  failedEnvironments = new Map;
376955
377112
  unconfiguredNotice = null;
377113
+ initError = null;
376956
377114
  environmentMutexes = new Map;
376957
377115
  publisherConfigIsFrozen;
376958
377116
  finishedInitialization;
@@ -377036,7 +377194,7 @@ class EnvironmentStore {
377036
377194
  if (existingEnvironments.length > 0) {
377037
377195
  await Promise.all(existingEnvironments.map(async (dbEnvironment) => {
377038
377196
  try {
377039
- const environmentExists = await fs10.promises.access(dbEnvironment.path).then(() => true).catch(() => false);
377197
+ const environmentExists = await fs11.promises.access(dbEnvironment.path).then(() => true).catch(() => false);
377040
377198
  const environmentConfig = environmentManifest.environments.find((p2) => p2.name === dbEnvironment.name);
377041
377199
  if (!environmentExists) {
377042
377200
  if (environmentConfig) {
@@ -377112,6 +377270,7 @@ class EnvironmentStore {
377112
377270
  this.emitReadinessLine();
377113
377271
  } catch (error2) {
377114
377272
  markNotReady();
377273
+ this.initError = redactPgSecrets(messageWithFilesystemCause(error2));
377115
377274
  const errorData = this.extractErrorDataFromError(error2);
377116
377275
  logger.error("Error initializing environment store", errorData);
377117
377276
  try {
@@ -377404,12 +377563,12 @@ class EnvironmentStore {
377404
377563
  }
377405
377564
  async cleanupAndCreatePublisherPath() {
377406
377565
  const reInit = process.env.INITIALIZE_STORAGE === "true";
377407
- await fs10.promises.mkdir(this.serverRootPath, { recursive: true });
377566
+ await fs11.promises.mkdir(this.serverRootPath, { recursive: true });
377408
377567
  if (reInit) {
377409
377568
  const uploadDocsPath2 = path13.join(this.serverRootPath, PUBLISHER_DATA_DIR);
377410
377569
  logger.info(`Reinitialization mode: Cleaning up upload documents path ${uploadDocsPath2}`);
377411
377570
  try {
377412
- await fs10.promises.rm(uploadDocsPath2, {
377571
+ await fs11.promises.rm(uploadDocsPath2, {
377413
377572
  recursive: true,
377414
377573
  force: true
377415
377574
  });
@@ -377424,7 +377583,7 @@ class EnvironmentStore {
377424
377583
  logger.info(`Using existing publisher path`);
377425
377584
  }
377426
377585
  const uploadDocsPath = path13.join(this.serverRootPath, PUBLISHER_DATA_DIR);
377427
- await fs10.promises.mkdir(uploadDocsPath, { recursive: true });
377586
+ await fs11.promises.mkdir(uploadDocsPath, { recursive: true });
377428
377587
  }
377429
377588
  async listEnvironments(skipInitializationCheck = false) {
377430
377589
  if (!skipInitializationCheck) {
@@ -377502,6 +377661,9 @@ class EnvironmentStore {
377502
377661
  if (this.unconfiguredNotice && status.environments.length === 0) {
377503
377662
  status.emptyReason = this.unconfiguredNotice;
377504
377663
  }
377664
+ if (this.initError) {
377665
+ status.initError = this.initError;
377666
+ }
377505
377667
  return status;
377506
377668
  }
377507
377669
  async getEnvironment(environmentName, reload = false) {
@@ -377574,9 +377736,6 @@ class EnvironmentStore {
377574
377736
  const loaded = await this.loadEnvironmentIntoDisk(environmentName, packagesToProcess);
377575
377737
  absoluteEnvironmentPath = loaded.path;
377576
377738
  mountErrors = loaded.mountErrors;
377577
- if (absoluteEnvironmentPath.endsWith(".zip")) {
377578
- absoluteEnvironmentPath = await this.unzipEnvironment(absoluteEnvironmentPath);
377579
- }
377580
377739
  } else {
377581
377740
  absoluteEnvironmentPath = await this.scaffoldEnvironment(environment);
377582
377741
  }
@@ -377603,41 +377762,44 @@ class EnvironmentStore {
377603
377762
  }
377604
377763
  async unzipEnvironment(absoluteEnvironmentPath) {
377605
377764
  assertSafeEnvironmentPath(absoluteEnvironmentPath);
377606
- const startedAt = Date.now();
377607
- logger.info(`Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`);
377608
377765
  const archiveDir = path13.resolve(path13.dirname(absoluteEnvironmentPath));
377609
377766
  const unzippedEnvironmentPath = path13.resolve(archiveDir, path13.basename(absoluteEnvironmentPath, ".zip"));
377610
377767
  if (!unzippedEnvironmentPath.startsWith(archiveDir + path13.sep)) {
377611
377768
  throw new BadRequestError(`Refusing to unzip "${absoluteEnvironmentPath}": target escapes its directory`);
377612
377769
  }
377613
- await fs10.promises.rm(unzippedEnvironmentPath, {
377770
+ await this.extractZipInto(absoluteEnvironmentPath, unzippedEnvironmentPath);
377771
+ return unzippedEnvironmentPath;
377772
+ }
377773
+ async extractZipInto(archivePath, targetDir) {
377774
+ const startedAt = Date.now();
377775
+ logger.info(`Detected zip file at "${archivePath}". Unzipping...`);
377776
+ await fs11.promises.rm(targetDir, {
377614
377777
  recursive: true,
377615
377778
  force: true
377616
377779
  });
377617
- await fs10.promises.mkdir(unzippedEnvironmentPath, { recursive: true });
377780
+ await fs11.promises.mkdir(targetDir, { recursive: true });
377618
377781
  let entryCount = 0;
377619
377782
  let totalUncompressedBytes = 0;
377620
377783
  try {
377621
- await import_extract_zip.default(absoluteEnvironmentPath, {
377622
- dir: path13.resolve(unzippedEnvironmentPath),
377784
+ await import_extract_zip.default(archivePath, {
377785
+ dir: path13.resolve(targetDir),
377623
377786
  onEntry: (entry) => {
377624
377787
  if ((entry.externalFileAttributes >>> 16 & 61440) === 40960) {
377625
- throw new BadRequestError(`Refusing to unzip "${absoluteEnvironmentPath}": entry "${entry.fileName}" is a symbolic link`);
377788
+ throw new BadRequestError(`Refusing to unzip "${archivePath}": entry "${entry.fileName}" is a symbolic link`);
377626
377789
  }
377627
377790
  entryCount += 1;
377628
377791
  totalUncompressedBytes += entry.uncompressedSize ?? 0;
377629
377792
  }
377630
377793
  });
377631
377794
  } catch (error2) {
377632
- await fs10.promises.rm(unzippedEnvironmentPath, {
377795
+ await fs11.promises.rm(targetDir, {
377633
377796
  recursive: true,
377634
377797
  force: true
377635
377798
  });
377636
377799
  throw error2;
377637
377800
  }
377638
377801
  const mib = (totalUncompressedBytes / (1024 * 1024)).toFixed(1);
377639
- logger.info(`Unzipped "${absoluteEnvironmentPath}" -> "${unzippedEnvironmentPath}" ` + `(${entryCount} entries, ${mib} MiB uncompressed) in ` + `${formatDuration(Date.now() - startedAt)}`);
377640
- return unzippedEnvironmentPath;
377802
+ logger.info(`Unzipped "${archivePath}" -> "${targetDir}" ` + `(${entryCount} entries, ${mib} MiB uncompressed) in ` + `${formatDuration(Date.now() - startedAt)}`);
377641
377803
  }
377642
377804
  async updateEnvironment(environment) {
377643
377805
  await this.finishedInitialization;
@@ -377675,7 +377837,7 @@ class EnvironmentStore {
377675
377837
  await this.deleteEnvironmentFromDatabase(environmentName);
377676
377838
  if (environmentPath) {
377677
377839
  try {
377678
- await fs10.promises.rm(environmentPath, {
377840
+ await fs11.promises.rm(environmentPath, {
377679
377841
  recursive: true,
377680
377842
  force: true
377681
377843
  });
@@ -377696,7 +377858,7 @@ class EnvironmentStore {
377696
377858
  throw new PublisherConfigError(PUBLISHER_CONFIG_NAME, error2);
377697
377859
  } else {
377698
377860
  try {
377699
- const entries = await fs10.promises.readdir(serverRootPath, {
377861
+ const entries = await fs11.promises.readdir(serverRootPath, {
377700
377862
  withFileTypes: true
377701
377863
  });
377702
377864
  const environments = [];
@@ -377729,9 +377891,9 @@ class EnvironmentStore {
377729
377891
  assertSafePackageName(environment.name);
377730
377892
  const environmentName = environment.name;
377731
377893
  const absoluteEnvironmentPath = safeJoinUnderRoot(this.serverRootPath, PUBLISHER_DATA_DIR, environmentName);
377732
- await fs10.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
377894
+ await fs11.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
377733
377895
  if (environment.readme) {
377734
- await fs10.promises.writeFile(safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"), environment.readme);
377896
+ await fs11.promises.writeFile(safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"), environment.readme);
377735
377897
  }
377736
377898
  return absoluteEnvironmentPath;
377737
377899
  }
@@ -377752,7 +377914,7 @@ class EnvironmentStore {
377752
377914
  }
377753
377915
  recordMountFailure(error2, location, packageNames, mountErrors) {
377754
377916
  logger.error(`Failed to download or mount location "${location}"`, this.extractErrorDataFromError(error2));
377755
- const message = redactPgSecrets(error2 instanceof Error ? error2.message : String(error2));
377917
+ const message = redactPgSecrets(messageWithFilesystemCause(error2));
377756
377918
  for (const packageName of packageNames) {
377757
377919
  mountErrors.set(packageName, message);
377758
377920
  }
@@ -377761,7 +377923,7 @@ class EnvironmentStore {
377761
377923
  assertSafePackageName(environmentName);
377762
377924
  const absoluteTargetPath = safeJoinUnderRoot(this.serverRootPath, PUBLISHER_DATA_DIR, environmentName);
377763
377925
  const mountErrors = new Map;
377764
- await fs10.promises.mkdir(absoluteTargetPath, { recursive: true });
377926
+ await fs11.promises.mkdir(absoluteTargetPath, { recursive: true });
377765
377927
  if (!packages || packages.length === 0) {
377766
377928
  throw new PackageNotFoundError(`No packages found for environment ${environmentName}`);
377767
377929
  }
@@ -377793,7 +377955,7 @@ class EnvironmentStore {
377793
377955
  for (const [groupedLocation, packagesForLocation] of locationGroups) {
377794
377956
  const locationHash = crypto5.createHash("sha256").update(groupedLocation).digest("hex").substring(0, 16);
377795
377957
  const tempDownloadPath = safeJoinUnderRoot(absoluteTargetPath, `.temp_${locationHash}`);
377796
- await fs10.promises.mkdir(tempDownloadPath, { recursive: true });
377958
+ await fs11.promises.mkdir(tempDownloadPath, { recursive: true });
377797
377959
  logger.info(`Created temporary directory: ${tempDownloadPath}`);
377798
377960
  let downloaded = true;
377799
377961
  try {
@@ -377808,6 +377970,7 @@ class EnvironmentStore {
377808
377970
  const packageDir = _package.name;
377809
377971
  assertSafePackageName(packageDir);
377810
377972
  absolutePackagePath = safeJoinUnderRoot(absoluteTargetPath, packageDir);
377973
+ const isLocalArchive = this.isLocalPath(_package.location) && _package.location.endsWith(".zip");
377811
377974
  let sourcePath;
377812
377975
  if (this.isGitHubURL(_package.location)) {
377813
377976
  const githubInfo = parseGitHubUrl(_package.location);
@@ -377822,30 +377985,32 @@ class EnvironmentStore {
377822
377985
  sourcePath = tempDownloadPath;
377823
377986
  }
377824
377987
  } else {
377825
- if (this.isLocalPath(_package.location)) {
377988
+ if (isLocalArchive) {
377989
+ sourcePath = tempDownloadPath;
377990
+ } else if (this.isLocalPath(_package.location)) {
377826
377991
  sourcePath = this.resolveLocalPath(_package.location);
377827
377992
  } else {
377828
377993
  sourcePath = safeJoinUnderRoot(tempDownloadPath, groupedLocation);
377829
377994
  }
377830
377995
  }
377831
- const sourceExists = await fs10.promises.access(sourcePath).then(() => true).catch(() => false);
377996
+ const sourceExists = await fs11.promises.access(sourcePath).then(() => true).catch(() => false);
377832
377997
  if (sourceExists) {
377833
- const isInPlace = this.inPlaceEnvs.has(environmentName) && this.isLocalPath(_package.location);
377998
+ const isInPlace = this.inPlaceEnvs.has(environmentName) && this.isLocalPath(_package.location) && !isLocalArchive;
377834
377999
  if (isInPlace) {
377835
378000
  await clearMountTarget(absolutePackagePath);
377836
378001
  const absoluteSourcePath = path13.resolve(sourcePath);
377837
378002
  const linkType = process.platform === "win32" ? "junction" : "dir";
377838
378003
  try {
377839
- await fs10.promises.symlink(absoluteSourcePath, absolutePackagePath, linkType);
378004
+ await fs11.promises.symlink(absoluteSourcePath, absolutePackagePath, linkType);
377840
378005
  logger.info(`In-place mount (watch mode): linked package "${packageDir}" -> "${absoluteSourcePath}" (${++mountedCount}/${totalPackages})`);
377841
378006
  } catch (linkError) {
377842
378007
  const code = linkError?.code ?? String(linkError);
377843
378008
  logger.warn(`In-place mount failed for package "${packageDir}" (${code}); falling back to a copy. Source-edit live reload is disabled for this package.`);
377844
378009
  await clearMountTarget(absolutePackagePath);
377845
- await fs10.promises.mkdir(absolutePackagePath, {
378010
+ await fs11.promises.mkdir(absolutePackagePath, {
377846
378011
  recursive: true
377847
378012
  });
377848
- await fs10.promises.cp(sourcePath, absolutePackagePath, {
378013
+ await fs11.promises.cp(sourcePath, absolutePackagePath, {
377849
378014
  recursive: true
377850
378015
  });
377851
378016
  logger.info(`Copied package "${packageDir}" (${++mountedCount}/${totalPackages})`);
@@ -377853,22 +378018,24 @@ class EnvironmentStore {
377853
378018
  } else {
377854
378019
  if (this.inPlaceEnvs.has(environmentName) && !this.isLocalPath(_package.location)) {
377855
378020
  logger.warn(`Watch mode: package "${packageDir}" has remote location "${_package.location}" — falling back to copy. Source-edit live reload won't work for this package; clone the source locally and use a local-dir location to enable it.`);
378021
+ } else if (this.inPlaceEnvs.has(environmentName) && isLocalArchive) {
378022
+ logger.warn(`Watch mode: package "${packageDir}" is an archive "${_package.location}" — extracted and copied. Source-edit live reload won't work for this package; use an unpacked local-dir location to enable it.`);
377856
378023
  }
377857
378024
  await clearMountTarget(absolutePackagePath);
377858
- await fs10.promises.mkdir(absolutePackagePath, {
378025
+ await fs11.promises.mkdir(absolutePackagePath, {
377859
378026
  recursive: true
377860
378027
  });
377861
- await fs10.promises.cp(sourcePath, absolutePackagePath, {
378028
+ await fs11.promises.cp(sourcePath, absolutePackagePath, {
377862
378029
  recursive: true
377863
378030
  });
377864
378031
  logger.info(`Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"} (${++mountedCount}/${totalPackages})`);
377865
378032
  }
377866
378033
  } else {
377867
378034
  await clearMountTarget(absolutePackagePath);
377868
- await fs10.promises.mkdir(absolutePackagePath, {
378035
+ await fs11.promises.mkdir(absolutePackagePath, {
377869
378036
  recursive: true
377870
378037
  });
377871
- await fs10.promises.cp(tempDownloadPath, absolutePackagePath, {
378038
+ await fs11.promises.cp(tempDownloadPath, absolutePackagePath, {
377872
378039
  recursive: true
377873
378040
  });
377874
378041
  logger.info(`Copied entire download as package "${packageDir}" (${++mountedCount}/${totalPackages})`);
@@ -377881,7 +378048,7 @@ class EnvironmentStore {
377881
378048
  }
377882
378049
  }
377883
378050
  try {
377884
- await fs10.promises.rm(tempDownloadPath, {
378051
+ await fs11.promises.rm(tempDownloadPath, {
377885
378052
  recursive: true,
377886
378053
  force: true
377887
378054
  });
@@ -377903,7 +378070,7 @@ class EnvironmentStore {
377903
378070
  } catch (error2) {
377904
378071
  const errorData = this.extractErrorDataFromError(error2);
377905
378072
  logger.error(`Failed to download GCS directory "${location}"`, errorData);
377906
- throw new PackageNotFoundError(`Failed to download GCS directory: ${location}`);
378073
+ throw new PackageNotFoundError(`Failed to download GCS directory: ${location}`, { cause: error2 });
377907
378074
  }
377908
378075
  }
377909
378076
  if (this.isGitHubURL(location)) {
@@ -377917,7 +378084,7 @@ class EnvironmentStore {
377917
378084
  } catch (error2) {
377918
378085
  const errorData = this.extractErrorDataFromError(error2);
377919
378086
  logger.error(`Failed to clone GitHub repository "${location}"`, errorData);
377920
- throw new PackageNotFoundError(`Failed to clone GitHub repository: ${location}`);
378087
+ throw new PackageNotFoundError(`Failed to clone GitHub repository: ${location}`, { cause: error2 });
377921
378088
  }
377922
378089
  }
377923
378090
  if (this.isS3URL(location)) {
@@ -377928,7 +378095,7 @@ class EnvironmentStore {
377928
378095
  } catch (error2) {
377929
378096
  const errorData = this.extractErrorDataFromError(error2);
377930
378097
  logger.error(`Failed to download S3 directory "${location}"`, errorData);
377931
- throw new PackageNotFoundError(`Failed to download S3 directory: ${location}`);
378098
+ throw new PackageNotFoundError(`Failed to download S3 directory: ${location}`, { cause: error2 });
377932
378099
  }
377933
378100
  }
377934
378101
  if (this.isLocalPath(location)) {
@@ -377940,7 +378107,7 @@ class EnvironmentStore {
377940
378107
  } catch (error2) {
377941
378108
  const errorData = this.extractErrorDataFromError(error2);
377942
378109
  logger.error(`Failed to mount local directory "${packagePath}"`, errorData);
377943
- throw new PackageNotFoundError(`Failed to mount local directory: ${packagePath}`);
378110
+ throw new PackageNotFoundError(`Failed to mount local directory: ${packagePath}`, { cause: error2 });
377944
378111
  }
377945
378112
  }
377946
378113
  const errorMsg = `Invalid package path: "${location}". Must be an absolute mounted path or a GCS/S3/GitHub URI.`;
@@ -377949,25 +378116,31 @@ class EnvironmentStore {
377949
378116
  }
377950
378117
  async mountLocalDirectory(environmentPath, absoluteTargetPath, environmentName, packageName) {
377951
378118
  assertSafeEnvironmentPath(absoluteTargetPath);
377952
- if (environmentPath.endsWith(".zip")) {
377953
- environmentPath = await this.unzipEnvironment(environmentPath);
377954
- }
377955
- const environmentDirExists = (await fs10.promises.stat(environmentPath))?.isDirectory() ?? false;
377956
- if (environmentDirExists) {
377957
- await fs10.promises.rm(absoluteTargetPath, {
378119
+ const notFound = () => new PackageNotFoundError(`Package ${packageName} for environment ${environmentName} not found in "${environmentPath}"`);
378120
+ const source = await fs11.promises.stat(environmentPath).catch((error2) => {
378121
+ if (error2.code === "ENOENT") {
378122
+ throw notFound();
378123
+ }
378124
+ throw error2;
378125
+ });
378126
+ if (environmentPath.endsWith(".zip") && source.isFile()) {
378127
+ await this.extractZipInto(environmentPath, absoluteTargetPath);
378128
+ } else if (source.isDirectory()) {
378129
+ await fs11.promises.rm(absoluteTargetPath, {
377958
378130
  recursive: true,
377959
378131
  force: true
377960
378132
  });
377961
- await fs10.promises.mkdir(absoluteTargetPath, { recursive: true });
377962
- await fs10.promises.cp(environmentPath, absoluteTargetPath, {
378133
+ await fs11.promises.mkdir(absoluteTargetPath, { recursive: true });
378134
+ await fs11.promises.cp(environmentPath, absoluteTargetPath, {
377963
378135
  recursive: true
377964
378136
  });
377965
378137
  } else {
377966
- throw new PackageNotFoundError(`Package ${packageName} for environment ${environmentName} not found in "${environmentPath}"`);
378138
+ throw notFound();
377967
378139
  }
377968
378140
  }
377969
378141
  async downloadGcsDirectory(gcsPath, environmentName, absoluteDirPath, isCompressedFile) {
377970
378142
  assertSafeEnvironmentPath(absoluteDirPath);
378143
+ assertGoogleCredentialsIsNotADirectory();
377971
378144
  const trimmedPath = gcsPath.slice(5);
377972
378145
  const [bucketName, ...prefixParts] = trimmedPath.split("/");
377973
378146
  const prefix = prefixParts.join("/");
@@ -377978,11 +378151,11 @@ class EnvironmentStore {
377978
378151
  throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${gcsPath}`);
377979
378152
  }
377980
378153
  if (!isCompressedFile) {
377981
- await fs10.promises.rm(absoluteDirPath, {
378154
+ await fs11.promises.rm(absoluteDirPath, {
377982
378155
  recursive: true,
377983
378156
  force: true
377984
378157
  });
377985
- await fs10.promises.mkdir(absoluteDirPath, { recursive: true });
378158
+ await fs11.promises.mkdir(absoluteDirPath, { recursive: true });
377986
378159
  } else {
377987
378160
  absoluteDirPath = `${absoluteDirPath}.zip`;
377988
378161
  }
@@ -377992,10 +378165,10 @@ class EnvironmentStore {
377992
378165
  if (file.name.endsWith("/")) {
377993
378166
  return;
377994
378167
  }
377995
- await fs10.promises.mkdir(path13.dirname(absoluteFilePath), {
378168
+ await fs11.promises.mkdir(path13.dirname(absoluteFilePath), {
377996
378169
  recursive: true
377997
378170
  });
377998
- return fs10.promises.writeFile(absoluteFilePath, await file.download());
378171
+ return fs11.promises.writeFile(absoluteFilePath, await file.download());
377999
378172
  }));
378000
378173
  if (isCompressedFile) {
378001
378174
  await this.unzipEnvironment(absoluteDirPath);
@@ -378009,7 +378182,7 @@ class EnvironmentStore {
378009
378182
  const prefix = prefixParts.join("/");
378010
378183
  if (isCompressedFile) {
378011
378184
  const zipFilePath = `${absoluteDirPath}.zip`;
378012
- await fs10.promises.mkdir(path13.dirname(zipFilePath), {
378185
+ await fs11.promises.mkdir(path13.dirname(zipFilePath), {
378013
378186
  recursive: true
378014
378187
  });
378015
378188
  const command = new import_client_s33.GetObjectCommand({
@@ -378020,7 +378193,7 @@ class EnvironmentStore {
378020
378193
  if (!item.Body) {
378021
378194
  throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${s3Path}`);
378022
378195
  }
378023
- const file = fs10.createWriteStream(zipFilePath);
378196
+ const file = fs11.createWriteStream(zipFilePath);
378024
378197
  item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
378025
378198
  await new Promise((resolve6, reject2) => {
378026
378199
  file.on("error", reject2);
@@ -378034,8 +378207,8 @@ class EnvironmentStore {
378034
378207
  Bucket: bucketName,
378035
378208
  Prefix: prefix
378036
378209
  });
378037
- await fs10.promises.rm(absoluteDirPath, { recursive: true, force: true });
378038
- await fs10.promises.mkdir(absoluteDirPath, { recursive: true });
378210
+ await fs11.promises.rm(absoluteDirPath, { recursive: true, force: true });
378211
+ await fs11.promises.mkdir(absoluteDirPath, { recursive: true });
378039
378212
  if (!objects.Contents || objects.Contents.length === 0) {
378040
378213
  throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${s3Path}`);
378041
378214
  }
@@ -378049,7 +378222,7 @@ class EnvironmentStore {
378049
378222
  return;
378050
378223
  }
378051
378224
  const absoluteFilePath = safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
378052
- await fs10.promises.mkdir(path13.dirname(absoluteFilePath), {
378225
+ await fs11.promises.mkdir(path13.dirname(absoluteFilePath), {
378053
378226
  recursive: true
378054
378227
  });
378055
378228
  const command = new import_client_s33.GetObjectCommand({
@@ -378060,7 +378233,7 @@ class EnvironmentStore {
378060
378233
  if (!item.Body) {
378061
378234
  return;
378062
378235
  }
378063
- const file = fs10.createWriteStream(absoluteFilePath);
378236
+ const file = fs11.createWriteStream(absoluteFilePath);
378064
378237
  item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
378065
378238
  await new Promise((resolve6, reject2) => {
378066
378239
  file.on("error", reject2);
@@ -378077,11 +378250,11 @@ class EnvironmentStore {
378077
378250
  }
378078
378251
  const { owner, repoName, packagePath } = githubInfo;
378079
378252
  const cleanPackagePath = (packagePath?.replace("/tree/main", "") || "").replace(/^\/+/, "");
378080
- await fs10.promises.rm(absoluteDirPath, {
378253
+ await fs11.promises.rm(absoluteDirPath, {
378081
378254
  recursive: true,
378082
378255
  force: true
378083
378256
  });
378084
- await fs10.promises.mkdir(absoluteDirPath, { recursive: true });
378257
+ await fs11.promises.mkdir(absoluteDirPath, { recursive: true });
378085
378258
  const repoUrl = `https://github.com/${owner}/${repoName}`;
378086
378259
  const reporter = new CloneProgressReporter(cloneProgressLabel(`${owner}/${repoName}`, progressContext));
378087
378260
  await new Promise((resolve6, reject2) => {
@@ -378107,24 +378280,24 @@ class EnvironmentStore {
378107
378280
  return;
378108
378281
  }
378109
378282
  const packageFullPath = safeJoinUnderRoot(absoluteDirPath, cleanPackagePath);
378110
- const packageExists = await fs10.promises.access(packageFullPath).then(() => true).catch(() => false);
378283
+ const packageExists = await fs11.promises.access(packageFullPath).then(() => true).catch(() => false);
378111
378284
  if (!packageExists) {
378112
378285
  throw new Error(`Package path "${cleanPackagePath}" does not exist in the cloned repository.`);
378113
378286
  }
378114
- const dirContents = await fs10.promises.readdir(absoluteDirPath);
378287
+ const dirContents = await fs11.promises.readdir(absoluteDirPath);
378115
378288
  for (const entry of dirContents) {
378116
378289
  if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
378117
- await fs10.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
378290
+ await fs11.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
378118
378291
  recursive: true,
378119
378292
  force: true
378120
378293
  });
378121
378294
  }
378122
378295
  }
378123
- const packageContents = await fs10.promises.readdir(packageFullPath);
378296
+ const packageContents = await fs11.promises.readdir(packageFullPath);
378124
378297
  for (const entry of packageContents) {
378125
- await fs10.promises.rename(safeJoinUnderRoot(packageFullPath, entry), safeJoinUnderRoot(absoluteDirPath, entry));
378298
+ await fs11.promises.rename(safeJoinUnderRoot(packageFullPath, entry), safeJoinUnderRoot(absoluteDirPath, entry));
378126
378299
  }
378127
- await fs10.promises.rm(packageFullPath, { recursive: true, force: true });
378300
+ await fs11.promises.rm(packageFullPath, { recursive: true, force: true });
378128
378301
  }
378129
378302
  extractErrorDataFromError(error2) {
378130
378303
  const errorMessage = error2 instanceof Error ? error2.message : String(error2);
@@ -383336,7 +383509,8 @@ A JSON object with:
383336
383509
  - version: this server's release version.
383337
383510
  - environments: each environment's name with its loaded package names.
383338
383511
  - emptyReason (only present when the server found no config at startup, or the --config path was missing): why environments is empty, and the path it checked. The server still reports serving in that state.
383339
- - loadErrors (only present when something failed): entries of {environment, package?, message, stale?, failedAt?}. An entry WITHOUT stale means the package (or whole environment, when package is absent) did not load and is missing from environments. An entry WITH stale: true means the package IS serving, but its most recent reload failed to compile, so the model answering queries is OLDER than the files on disk; the message says why. Fix the file and reload (reload_package) to clear it.
383512
+ - initError (only present when startup failed): why. The server stays at "initializing" and never serves; the cause is usually a config file it cannot read or parse, or a server root it cannot write (publisher.db). A package or environment that failed to load is under loadErrors instead.
383513
+ - loadErrors (only present when something failed): entries of {environment, package?, message, stale?, failedAt?}. An entry WITHOUT stale means the package (or whole environment, when package is absent) did not load and is missing from environments; that includes a package add that failed on the server's side. An entry WITH stale: true means the package IS serving, but its most recent reload failed to compile, so the model answering queries is OLDER than the files on disk; the message says why. Fix the file and reload (reload_package) to clear it.
383340
383514
 
383341
383515
  No loadErrors key means everything configured loaded and nothing is stale.`;
383342
383516
  function registerGetStatusTool(mcpServer, environmentStore) {
@@ -383355,6 +383529,9 @@ function registerGetStatusTool(mcpServer, environmentStore) {
383355
383529
  ...status.emptyReason !== undefined && {
383356
383530
  emptyReason: status.emptyReason
383357
383531
  },
383532
+ ...status.initError !== undefined && {
383533
+ initError: status.initError
383534
+ },
383358
383535
  ...status.loadErrors !== undefined && {
383359
383536
  loadErrors: status.loadErrors
383360
383537
  }
@@ -383628,7 +383805,8 @@ otherwise a modelling agent is dispatched to fix a model that is already right.
383628
383805
 
383629
383806
  ## Step 6: Append events, then stop
383630
383807
 
383631
- Append to \`evals/<set>/runs/<runId>/events.jsonl\` with \`caseId\` set. Shapes
383808
+ Append to \`<workdir>/runs/<runId>/events.jsonl\` with \`caseId\` set (the run
383809
+ directory \`eval.py run\` printed; the workdir is the set's, from \`eval.toml\`). Shapes
383632
383810
  live in \`reference/ledger-schema.md\`.
383633
383811
 
383634
383812
  1. \`attempt\`: qid, sample, phase, question_sha, submitted, final_query,
@@ -384203,7 +384381,7 @@ a permanent parallel mechanism.`
384203
384381
  {
384204
384382
  name: "eval-answer/ledger-schema",
384205
384383
  description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
384206
- 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."
384384
+ 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>/ # the set, in the model's repository\n set.json # set metadata (below)\n eval.toml # servers and paths for eval.py (config.py)\n cases.jsonl # one case per line\n judge-regressions.jsonl # judge verdicts a human overruled\n\n<workdir>/ # [paths] workdir, default ~/.malloy-eval/<set>-<hash>/\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 packages/ # built report packages\n servers/<role>/ # server roots for eval.py serve\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 answer key together.\nThe workdir is never inside the model package: a run holds a `model.malloy`\nsnapshot and a report is a Malloy package, and either one nested there puts\nthe package into `loadErrors`. For a checkpoint to pin the ledger as well,\npoint the workdir at a directory in the repository outside the package, and\ngitignore its `servers/` and `packages/`.\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; a `scalar` value is an object naming the canonical query's column, `{\"net_sales\": 834216}`, never a bare number), `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| `evalConfig` | The set's `eval.toml` (or `eval.json`) the run read, as `{path, values}`; `path` is null when the set has none. |\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` ids the compiled served model does not declare, from the in-arm lint (bare names from a search of the model text when the compiled model could not be read). 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."
384207
384385
  },
384208
384386
  {
384209
384387
  name: "eval-diagnose",
@@ -384526,7 +384704,7 @@ model-owned finding deserves a probe of its own before it justifies an edit.
384526
384704
 
384527
384705
  ## Step 4: Append issue events, then stop
384528
384706
 
384529
- Append to \`evals/<set>/runs/<runId>/events.jsonl\` with \`kind: issue\`
384707
+ Append to \`<workdir>/runs/<runId>/events.jsonl\` with \`kind: issue\`
384530
384708
  (shapes in \`skill:eval-answer\` \`reference/ledger-schema.md\`):
384531
384709
 
384532
384710
  - \`issue_id\`, affected \`qids\`, \`primary_code\`, \`contributing_codes\`
@@ -385237,7 +385415,7 @@ Yours, after applying step 3 and step 4:
385237
385415
  \`\`\`json
385238
385416
  {"qid": "ecom-q-014", "question": "How did denim sell last quarter versus the one before?",
385239
385417
  "questionSha": "<stamped>", "split": "dev", "source": "questions.jsonl",
385240
- "golden": {"status": "provisional", "kind": "scalar", "value": 1830000,
385418
+ "golden": {"status": "provisional", "kind": "scalar", "value": {"denim_change": 1830000},
385241
385419
  "canonicalQuery": "SELECT ...", "verifiedBy": "authored_query",
385242
385420
  "rubric": "Reports the quarter-over-quarter change for denim, and breaks the figure out by channel."},
385243
385421
  "sourceFields": {"difficulty": "hard"}}
@@ -386031,547 +386209,8 @@ can never fire.`
386031
386209
  },
386032
386210
  {
386033
386211
  name: "eval-loop",
386034
- description: "Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files under the model package''s evals/ directory; checkpoints are git commits of the model repo. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.",
386035
- body: `# The Evaluation Loop
386036
-
386037
- You conduct this loop. There is no batch orchestrator to start, no eval API,
386038
- and no eval MCP tools. The ledger is plain files in the model package's git
386039
- repository (\`reference/ledger-schema.md\` in \`skill:eval-answer\` defines every
386040
- file and event). Scoring is an LLM judge you spawn per case. There is no
386041
- scripted scorer, and there will not be one: a script that can pass a wrong
386042
- answer is worse than none. The scripts under \`scripts/\` run the loop -- they
386043
- answer, re-execute, spawn the judge, compare runs, and write the ledger -- but
386044
- none of them decides whether an answer was right.
386045
-
386046
- \`\`\`
386047
- scrape/run -> eval -> diagnose -> improve -> checkpoint
386048
- \`\`\`
386049
-
386050
- **This skill conducts; it does not restate.** Scoring lives in
386051
- \`skill:eval-answer\`. Components and owners live in \`skill:eval-diagnose\`.
386052
- Edit rules live in \`skill:eval-improve\`.
386053
-
386054
- Do not merge **eval** into **diagnose**. A conductor who scores while
386055
- explaining writes the explanation into the score. Do not skip the **acceptance
386056
- check** inside improve. The acceptance check decides whether *this* edit
386057
- stays. **Checkpoint** decides whether a *sequence* of accepted edits can be
386058
- undone.
386059
-
386060
- ## Where the rest of this lives
386061
-
386062
- This file is the procedure. Five things it used to carry inline are files beside
386063
- it now, because each is needed at one moment rather than every run, and loading
386064
- all of them for every run is how a skill stops being read.
386065
-
386066
- | When | Read |
386067
- |---|---|
386068
- | about to run one | \`reference/running-a-run.md\` |
386069
- | a golden is wrong, doubted, or out of step with the model | \`reference/golden-side-door.md\` |
386070
- | auditing a key you doubt, or a set you did not author | \`reference/auditing-an-answer-key.md\` |
386071
- | deciding whether an edit stays | \`reference/acceptance-check.md\` |
386072
- | about to quote a number, set the band, or read a set's flips | \`reference/measurement.md\` |
386073
- | the run finished and someone has to read it | \`skill:eval-report\` |
386074
- | you changed judge doctrine or its inputs | \`reference/checking-the-judge.md\` |
386075
-
386076
- Read the file, do not work from the summary here. The acceptance-check rules and
386077
- the golden side door are both places where acting on a half-memory of the rule
386078
- produces a confident wrong answer rather than an error.
386079
-
386080
- ## The five steps
386081
-
386082
- | Step | Job | Writes |
386083
- |---|---|---|
386084
- | **a. scrape / run** | Put cases in the ledger; spawn a blind answerer | cases; \`attempt\`, \`tool_call\` |
386085
- | **b. eval** | Judge the answer; score which required entities retrieval delivered | \`score\` |
386086
- | **c. diagnose** | Why it failed, who owns it | \`issue\` / \`issue_status\`. Stop. Do not edit. |
386087
- | **d. improve** | One smallest model edit, then the acceptance check | improve writes \`candidate\`; you write \`acceptance_check\`. Revert on reject. |
386088
- | **e. checkpoint** | Git commit after an accepted acceptance check | \`checkpoint\` event, then the commit |
386089
-
386090
- Every run then **reports** (below). A run whose result exists only as JSONL and
386091
- a scrolled-away console summary has not been delivered.
386092
-
386093
- **scrape** and **run** share a letter but are not the same job. Scrape writes
386094
- cases. Run writes attempts. Do not invent questions and score
386095
- them in one breath.
386096
-
386097
- **Step d is \`skill:eval-improve\`, not a hand edit plus another arm.** The
386098
- failure mode is specific and it has happened: the edits were made by hand,
386099
- published, and validated by re-running the whole arm -- with the answer key
386100
- repaired in the same window. No \`candidate\`, \`acceptance_check\` or \`checkpoint\`
386101
- event exists in that run's ledger, and the resulting move cannot be attributed
386102
- to the model, the key, or the answerer. Re-running a whole arm after changing
386103
- two things is the one thing the acceptance check exists to replace: it scores
386104
- the edit against dev AND holdout, and it is cheaper than the arm.
386105
-
386106
- ### Scrape, minimally
386107
-
386108
- Importing an existing corpus IS the scrape step: copy the set from its home
386109
- (for example a benchmarks checkout) into \`evals/<set>/\` and convert to the
386110
- ledger shapes. **\`skill:eval-import\` is that job in full** -- how to classify
386111
- what arrived with each question, why nothing imports as a verified value, and
386112
- the seal that makes a later edit to a question detectable. Read it whenever the
386113
- questions came from outside, which is most of the time. While importing:
386114
-
386115
- - Freeze each case's \`split\`: \`dev\` or \`holdout\`. Diagnose and improve read
386116
- dev cases only; the acceptance check runs both. A set that is all dev cannot defend an
386117
- accept.
386118
- - Later, each diagnosed-and-fixed failure becomes a new frozen dev case, so a
386119
- fixed bug cannot silently return.
386120
-
386121
- Scraping from production logs (chat transcripts, retrieval traces) is the
386122
- other supported source, and usually the better one: real traffic asks what
386123
- people actually ask. Where your logs physically live is a host concern; look
386124
- for a host-specific log-fetching skill. \`skill:eval-import\` takes over once
386125
- you have the text, and its \`reference/case-format.md\` covers what a log pull
386126
- needs that a question list does not.
386127
-
386128
- Prefer variety over volume when you sample, from either source. Cases that
386129
- differ in grain, source, filter shape, and phrasing are what move a
386130
- measurement; a second sample of the same case is nearly free of new
386131
- information.
386132
-
386133
- ### Mode aliases
386134
-
386135
- Older mode names still work as aliases for how far one run walks:
386136
-
386137
- | Alias | Steps |
386138
- |---|---|
386139
- | \`measure\` | scrape/run + eval |
386140
- | \`triage\` | plus diagnose |
386141
- | \`improve\` | plus improve + acceptance check + checkpoint on accept |
386142
-
386143
- Say which alias (or which steps) you are running before the first question.
386144
- Record it in \`run.json\`. Do not mix steps in a way that lets the answerer see
386145
- gold, issues, or the model file.
386146
-
386147
- Most runs should stop after eval. Diagnose when you need a histogram of
386148
- components and owners. Improve only for diagnosed *model* gaps, one batch at
386149
- a time. Checkpoint only after the acceptance check **accepts**.
386150
-
386151
- ## Roles
386152
-
386153
- | Role | Sees |
386154
- |---|---|
386155
- | **Answerer** | The question and the Malloy tools. Never the golden, \`evals/\`, the model file, or any hint it is being evaluated. |
386156
- | **Judge** | The golden and the prediction. Never conducts, never answers, never edits. One fresh subagent per verdict (\`skill:eval-judge\`). |
386157
- | **You (conductor / improver)** | Everything, including goldens and traces. |
386158
- | **Acceptance check** | The edit and the evidence. Never the improver's self-assessment alone. |
386159
-
386160
- The answerer stays blind. That is not optional. A grader-visible answerer
386161
- writes toward the expected answer, and the score is fiction.
386162
-
386163
- There are no eval MCP tools on purpose. The answerer inherits your tools,
386164
- including Shell and Read, so any eval convenience surface would also be a
386165
- gold path for it. Blindness is prevention plus detection, not a guarantee:
386166
- \`eval-answer\` runs the contamination checklist on every attempt, which is
386167
- why you keep a host-side tool-use log per answerer.
386168
-
386169
- ## Pick the target first
386170
-
386171
- Both a local model server and a hosted platform expose the same two tools the
386172
- answerer needs, \`get_context\` and \`execute_query\`, so the loop runs against
386173
- either. What differs is which model is answering and whose data it reads, and
386174
- those are two separate axes:
386175
-
386176
- | Target | Model under test | Data | Can edit and re-test? |
386177
- |---|---|---|---|
386178
- | **Local (direct)** | your working files | local (for example duckdb), or a direct warehouse connection | yes |
386179
- | **Local (proxied)** | your working files | the platform's connection, through a proxy connection type | yes |
386180
- | **Remote** | the published version, through the platform's hosted \`get_context\`/\`execute_query\` | the platform's | no, publishing is not an eval action |
386181
-
386182
- The middle row is the one worth knowing about: it decouples the two axes, so you
386183
- can evaluate a model you are still editing against the customer's real data. It
386184
- is a connection configuration, not a feature.
386185
-
386186
- ### Three ways to reach the tools
386187
-
386188
- That table is about which MODEL answers. A second, independent choice is where
386189
- the answerer's MCP tools come from, and \`--mcp-url\` is the whole of it:
386190
-
386191
- | | \`--target\` | \`--mcp-url\` | Auth |
386192
- |---|---|---|---|
386193
- | **1. Local Publisher** | \`local\` | \`http://localhost:4040/mcp\` (default) | none |
386194
- | **2. Hosted, through an editor extension's local bridge** | \`platform\` | the localhost URL the extension prints | none: the extension holds the credential |
386195
- | **3. Hosted, directly** | \`platform\` | the host's \`https\` endpoint, scoped if it offers one | a cached OAuth login, once, interactively |
386196
-
386197
- \`\`\`bash
386198
- # 1. local
386199
- --target local # --mcp-url defaults to the local Publisher
386200
-
386201
- # 2. hosted via the extension's bridge -- no OAuth, but check what it exposes:
386202
- # the same proxy may front a local Publisher instead
386203
- --target platform --mcp-url http://localhost:<port-the-extension-prints>/mcp \\
386204
- --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>
386205
-
386206
- # 3. hosted directly -- authenticate first, under the SAME server name
386207
- claude mcp add --transport http <name> <scoped-url>
386208
- claude # /mcp -> <name> -> Authenticate
386209
- --target platform --mcp-url <scoped-url> \\
386210
- --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>
386211
- \`\`\`
386212
-
386213
- All three hand the answerer the same three capabilities (\`get_context\`,
386214
- \`execute_query\`, and the docs search), so a comparison between them is between
386215
- agents that could do the same things; \`test_the_two_arms_hold_the_same_capabilities\`
386216
- pins it. Modes 2 and 3 take those names from \`--hosted-tools\`, which defaults to
386217
- the bare trio; pass it only if this host names them differently.
386218
-
386219
- A platform run left on the local default \`--mcp-url\` is refused rather than
386220
- probed, because pointing every answerer at a local Publisher measures a
386221
- different model over different data than the run claims.
386222
-
386223
- Two rules follow, and both are the kind of mistake that produces confident
386224
- nonsense rather than an error:
386225
-
386226
- - **The answerer and the conductor must hit the same target.** If the answerer
386227
- queries the published model and you re-execute its query against your edited
386228
- local copy, the score describes neither. Decide the target before the first
386229
- question and record it.
386230
- - **Pin the version the target actually served, not the one you happen to have.**
386231
- A local target pins a commit; a platform target pins the published version.
386232
- Recording a local commit for a run that queried a published model is a pin
386233
- that means nothing.
386234
-
386235
- Which target for which job:
386236
-
386237
- - **Baseline what customers experience:** Remote. It is the deployed model
386238
- through the deployed engine, which is the thing they actually hit. The judge
386239
- sees no re-executed rows on a Remote run (there is no local copy of the
386240
- bytes), so its verdicts rest on the answer text and the golden; say so.
386241
- - **Improve and accept:** local, because the acceptance check needs compile,
386242
- reload, and a fresh re-answer between edits. Publishing to a customer
386243
- environment to score an edit is not something this loop does. Where the host
386244
- offers draft execution, that counts as local for this purpose.
386245
- - **Measure real data without touching production:** local proxied.
386246
-
386247
- So a measure-only run can use any target; a run that includes **improve** needs
386248
- a local one.
386249
-
386250
- Two things to check before a platform run, because neither errors and both make
386251
- the run measure something other than what it names:
386252
-
386253
- - **The answerer's skills must be written for THIS host.** A shared skill names
386254
- an MCP tool by its bare name (\`get_context\`) so it reads correctly anywhere,
386255
- but a host/router skill names its own host's tools directly. Install the
386256
- latter for the wrong host and the answerer is told to call tools it does not
386257
- have. \`run_baseline.py\` warns when the manifest it loaded names Publisher-only
386258
- tools on a platform target; point \`--answerer-manifest\`, or \`--skills-root\`,
386259
- at the checkout that ships this host's manifest.
386260
- - **The tool names are configuration.** \`--hosted-mcp-server\` is both the
386261
- \`mcp__<server>__<tool>\` prefix and the OAuth cache key, so it has to match the
386262
- name the answerer authenticated under, and \`--hosted-tools\` lists the bare
386263
- tools that host exposes.
386264
- - **Get the hosted tools in front of a headless answerer, one of two ways.**
386265
- A spawned answerer cannot complete an OAuth flow, so the tools have to be
386266
- reachable before the run starts. \`run_baseline.py\` proves it with one cheap
386267
- probe and refuses to spend an arm otherwise -- a run whose answerers have no
386268
- tools does not error, it reads as a terrible model.
386269
-
386270
- 1. **Authenticate once, interactively.** Works anywhere, including a plain
386271
- CLI install, and is the route to assume unless you know otherwise. The
386272
- token is cached per server NAME, so authenticate under the same name the
386273
- run passes to \`--hosted-mcp-server\`:
386274
-
386275
- \`\`\`bash
386276
- claude mcp add --transport http <name> <scoped-url>
386277
- claude # then /mcp -> <name> -> Authenticate
386278
- \`\`\`
386279
-
386280
- Then come back and run. This is a hand-off to a person; there is no
386281
- headless equivalent, so plan for it rather than discovering it mid-run.
386282
-
386283
- 2. **A local proxy that already holds the credential.** Some hosts ship an
386284
- editor extension whose local MCP proxy can expose the hosted
386285
- \`get_context\` / \`execute_query\` -- often behind a setting that is off by
386286
- default. Where that exists, point \`--mcp-url\` at the proxy on localhost
386287
- and no OAuth step is needed, because the extension holds it. Check what
386288
- the proxy actually exposes before relying on it: the same proxy may serve
386289
- a local Publisher's tools instead, and then \`--hosted-tools\` is
386290
- naming tools that are not there. This route is not available to someone
386291
- running the CLI alone.
386292
-
386293
- - **Prefer a SCOPED endpoint URL over asking for scope.** A hosted MCP is
386294
- usually reachable two ways: a global endpoint where every call carries an
386295
- organization and workspace, and a scoped one where the URL itself is the
386296
- scope. \`--scope\` and the prompt can only ASK an answerer to stay in one
386297
- package; a scoped URL enforces it. For an agent being measured that is the
386298
- difference between a case answered against the package it names and one
386299
- answered against whatever else the account can see. Authenticate once
386300
- interactively (\`claude\`, \`/mcp\`) under the same server name the run will use;
386301
- the token is cached per name, and a spawned headless answerer cannot complete
386302
- an OAuth flow.
386303
-
386304
- - **Pin the VERSION in the scope, not just the package.** Write
386305
- \`--scope <env>/<package>@<version>\`. Both hosted tools take a version and both
386306
- document the same default for an omitted one: the PINNED version, which is
386307
- whatever the workspace serves at the moment of the call. So an unversioned run
386308
- records \`targetVersion\` in \`run.json\` and then answers from whatever is
386309
- current, and the two part company the moment anyone publishes -- including
386310
- mid-run, which measures two builds under one label. \`--target-version\` fills
386311
- the version in when the scope omits it, so a platform run is pinned without
386312
- opting in; a scope naming a different version is refused rather than taken as
386313
- an override.
386314
-
386315
- ## Before you start
386316
-
386317
- 1. The model package under evaluation must live in a git repository, with
386318
- \`evals/<set>/\` in the package, beside the model files. Git is the checkpoint
386319
- mechanism; without it there is no rollback and no run can include improve.
386320
-
386321
- Keeping the set IN the package is what stops a model edit and its answer key
386322
- drifting apart: they move in one commit, so fixing a measure and forgetting
386323
- the golden that depended on it stops being possible. It is safe -- measured
386324
- on a running server, a \`cases.jsonl\` inside a package appears in no model
386325
- listing, no notebook listing, no package resource, and 404s over HTTP, so an
386326
- MCP-only answerer has no route to it.
386327
-
386328
- What it buys differs by target. On a LOCAL Publisher it does not get you free
386329
- versioning -- \`sourceContentSha\` hashes model paths only, so the set needs
386330
- its own \`datasetSha\`. On a hosted target that publishes the whole package
386331
- directory as an IMMUTABLE version, the set rides inside that version and
386332
- \`targetVersion\` pins model and answer key together; nothing can be edited
386333
- under a published version, which is what makes it a pin. Check which you have
386334
- before deciding how much of this you need.
386335
-
386336
- **Look for a set and for prior runs before you author either.** A minute of
386337
- \`find . -name cases.jsonl\`, a glance at \`evals/*/runs/\` and at your host's
386338
- own transcripts for this repo. Two sessions fourteen minutes apart built the
386339
- same 29-case answer key from scratch, because the first had committed
386340
- nothing before it was deleted and the second had no way to know it existed.
386341
- Roughly a working day was spent twice, and three specific things were
386342
- rediscovered at cost: the MCP login flow, the retrieval gate's 401, and a
386343
- judge-rendering bug that had already cost $17 of arm once. The two keys,
386344
- independently authored from the same questions, differ by about ten points
386345
- on comparable answers -- which is the available measure of how much a key
386346
- depends on its author, and a reason to reuse one rather than rebuild it.
386347
-
386348
- **Audit the set's entity ids before the first arm.** \`verify_goldens.py\`
386349
- checks that each id names something in the model; \`check_findable.py\` checks
386350
- that a search of its own kind actually returns it. Both are free of model
386351
- calls. An id that fails either scores a retrieval miss on every run, and the
386352
- miss reads as the model's fault.
386353
-
386354
- **Commit the set before you spend money on an arm**, and keep durable
386355
- outputs in the repository. A findings document in \`~/Downloads\` is gone the
386356
- first time somebody tidies up; the set, the run directory and the write-up
386357
- belong in git beside the model.
386358
-
386359
- 2. The server must be up. Where your host offers retrieval tracing, turn it on
386360
- and confirm a trace lookup answers, so a call's ranked results can be
386361
- recovered afterwards.
386362
-
386363
- **Open-source Publisher has none.** \`PUBLISHER_MCP_TRACE=retrieval\` is set by
386364
- \`serve.py\` and read by nothing: the trace store was written and never merged,
386365
- so there is no trace tool and \`traceId\` is null on every local attempt. This
386366
- does not block a scored run, because attribution never depended on it:
386367
- \`rankedSummary\` is copied onto the \`tool_call\` event at capture, precisely so
386368
- the evidence survives without a store. So do not refuse a local run for want
386369
- of tracing -- an earlier version of this rule did, and it refused every local
386370
- run there has ever been. Refuse one whose \`tool_call\` events carry no
386371
- \`rankedSummary\`, which is what attribution actually reads.
386372
-
386373
- 3. Health-check: your host's status check until it reports serving, and inspect
386374
- \`loadErrors\`. A dead database that still answers HTTP is an environment
386375
- failure, not a model failure. Stop and fix it. Four consecutive
386376
- environment or no-result attempts means stop the run.
386377
-
386378
- 4. Load the set: scrape/import as above, or reuse an existing \`evals/<set>/\`.
386379
- Never keep two live copies of one set; the set directory in the model repo
386380
- is the single source of truth, versioned by \`datasetVersion\` in
386381
- \`set.json\`.
386382
-
386383
- 5. Review goldens before you score. **Check how many cases can take a verdict
386384
- at all**, not just how many cases there are: a golden the set stamps
386385
- \`provisional\`, \`invalid\` or \`ambiguous\`, or a case with no golden, scores
386386
- \`verdict: null\` and stays out of the pass rate. An imported set is
386387
- \`provisional\` throughout by design (\`skill:eval-import\`), and the only thing
386388
- that changes that is \`verify_goldens.py --promote\` after a re-derivation
386389
- through the truth package. A run whose every key is underived is refused
386390
- rather than spent; a set of bare questions runs, because its answers are
386391
- what keys get derived from. A verified golden that holds a value but
386392
- no local artifact stays verified by provenance and is not scorable until you
386393
- have rows or a scalar to compare (the judge needs both sides). A \`criteria\`
386394
- golden is the exception: it holds no value, so its clauses are both sides. If diagnosis later marks
386395
- \`BAD-REFERENCE\` or \`AMBIGUOUS-REFERENCE\`, follow
386396
- \`reference/golden-side-door.md\`. Both are expected in the wild; both
386397
- are the golden side door below, not improve, and not a sixth step.
386398
-
386399
- 6. Create \`runs/<runId>/run.json\` with the attribution pins
386400
- (\`reference/ledger-schema.md\`): mode, dataset version, **the target and the
386401
- version it served** (a local target pins a commit, so commit or stash first;
386402
- answering from a dirty tree pins nothing), server version, judge version and
386403
- rubric sha, answerer model, call budget, trace mode. Freeze those for the
386404
- whole run. Raising a call budget mid-run moved mean outcomes on an unchanged
386405
- model.
386406
-
386407
- The call budget is \`--max-turns\`, written to \`run.json\` as \`maxTurns\`.
386408
- **Size it from a pilot rather than taking the default of 30.** Run the three
386409
- cheapest cases uncapped (\`--max-turns 100\`) and set the cap at twice their
386410
- maximum. A set whose questions need two or three sources joined does not fit
386411
- a cap sized for single-source lookups: on one such set the completed
386412
- attempts had a median of 17 turns and a 90th percentile of 26 against a cap
386413
- of 30, and four cases died at it. A cap 15% above the 90th percentile of
386414
- completed work is not a safety margin. Note also that \`malloy-analysis\` tells
386415
- the answerer to persist -- retry a phrasing, let a small query settle whether
386416
- a field exists -- so a tight cap and that instruction are in direct conflict.
386417
-
386418
- **Do not change the model and the measuring instrument in the same step.**
386419
- Those are two different axes and only one of them is cheap to separate.
386420
-
386421
- Batching MODEL edits is fine and expected. Clustering exists so that one
386422
- edit closes several cases, and an arm per fix does not survive contact with
386423
- arithmetic: 20 fixes over 100 cases is 2,000 answers, and at the measured
386424
- $0.33 a case on a proxied warehouse that is $660 of answering to attribute
386425
- what the acceptance check attributes for the price of the affected cases
386426
- plus holdout. Budget five arms for a defensible claim (a baseline, two for
386427
- the A/A, two post-edit), not one per edit, and let \`skill:eval-improve\`
386428
- carry each cluster.
386429
-
386430
- The instrument is the other axis: the answer key, the judge and its prompt,
386431
- the answerer model, the skills. Move one of those together with the model
386432
- and there is nothing left holding still, so the result measures neither. On
386433
- the run this comes from, nine model commits and a re-derived answer key
386434
- landed between two arms, and the move from 20% to 39% belongs to no one --
386435
- not because two model edits were batched, but because the ruler changed at
386436
- the same time as the thing being measured. A key repair mid-improve is not
386437
- forbidden; it ends that comparison, so re-baseline rather than quoting a
386438
- delta across it.
386439
-
386440
- The answerer model is instrument too, and it is the one most often left
386441
- unstated. The same 23-case set read 12 match / 11 near / 0 no_match on one
386442
- model and 8 / 4 / 15 on a smaller one. No statement about "the agent's"
386443
- capability means anything until two arms name the same answerer.
386444
-
386445
- 7. Generate every answerer prompt from the stored case in \`cases.jsonl\`.
386446
- Never retype the question. A truncated retype is indistinguishable from a
386447
- real question downstream.
386448
-
386449
- ## Per question
386450
-
386451
- 1. Health-check again.
386452
- 2. Spawn a *fresh* blind subagent. Give it only the question text and the
386453
- Malloy analysis tools. Tell it to follow the \`malloy-analysis\` skill. Do not
386454
- mention eval, gold, scoring, or this skill.
386455
- 3. Keep a host-side tool-use log for that subagent (name, input path or
386456
- command, MCP tool name). Publisher traces see MCP only; a Read of a gold
386457
- CSV is invisible server-side.
386458
- 4. \`skill:eval-answer\`: contamination first, then re-execute, then the judge,
386459
- then events.
386460
- 5. \`skill:eval-diagnose\` only when this run includes diagnose, only on dev
386461
- cases, and only after the score event exists.
386462
- 6. \`skill:eval-improve\` only when this run includes improve, and only for
386463
- \`owner: model\`. Then run the acceptance check. On accept, checkpoint.
386464
-
386465
- ## Report the run, or nobody can read it
386466
-
386467
- A run directory is JSONL. It is a record, not a result, and the console summary
386468
- scrolls away. **Every run ends by producing something a person can open**, and
386469
- that job is \`skill:eval-report\`: it builds the servable run package (the case
386470
- matrix app and the aggregate notebook) and gives the template for the write-up.
386471
-
386472
- Read it at the end of every run, including a run that failed. The standing
386473
- complaint about this loop is that "a bunch of stuff happens and it is hard to
386474
- know the actual results", and a ledger nobody renders is why.
386475
-
386476
- Two rules from it are worth repeating here, because they are the ones a
386477
- conductor skips:
386478
-
386479
- - **Separate a MODEL failure from an EVAL failure.** A wrong answer and a
386480
- broken measurement look identical in a pass rate and have nothing else in
386481
- common. An arm holding a truncated, contaminated or environment-failed
386482
- attempt has no rate to quote at all.
386483
- - **Say when a step did not run, and why.** \`improve\` not running because every
386484
- cluster came back \`owner: agent-skill\` is a RESULT, and it reads identically
386485
- to having forgotten unless it is written down.
386486
-
386487
- Keep the write-up in the repository beside the set, not in a chat log and not
386488
- in \`~/Downloads\`.
386489
-
386490
- ## Checkpoint
386491
-
386492
- A checkpoint is a git commit of the model repository, taken after an acceptance check
386493
- accepts, so a bad improve direction can be rolled back. It is not a report,
386494
- and it is not a remote publish.
386495
-
386496
- 1. Commit the model files AND the set's ledger in one commit; put the label
386497
- and the closed issue ids in the message.
386498
- 2. Append the \`checkpoint\` event (\`action: created\`, label, \`modelGitSha\`
386499
- from the commit you just made, issueIds). The event line itself rides in
386500
- the next commit; append-only logs trail by one commit and that is fine.
386501
- 3. Confirm \`git status\` is clean for the model files.
386502
-
386503
- **Restore**: \`git checkout <sha> -- <model files>\` (or \`git revert\` the
386504
- checkpoint commits), then reload the package, then append a \`checkpoint\`
386505
- event with \`action: restored\` and the sha. Readers return to the model that
386506
- existed before the bad direction.
386507
-
386508
- Take a checkpoint of the current model *before* the first improve batch if no
386509
- commit pins it yet. Rolling back by hand is guesswork.
386510
-
386511
- If reload reports \`mode: reinstalled\`, the package was re-fetched from its
386512
- install location and may have overwritten the restored files. Prefer in-place
386513
- / watch-mounted packages for this loop.
386514
-
386515
- ## Out of scope
386516
-
386517
- This loop is local. The ledger is files, the checkpoints are git, you are the
386518
- conductor. Do not:
386519
-
386520
- - publish the model to a hosted platform as a "true" checkpoint or learning
386521
- curve
386522
- - start a Python orchestrator (\`loop.py\`, \`run_all.py\`, \`improve_batch.py\`)
386523
- that runs the five steps end to end unattended. You conduct; the scripts are
386524
- the steps, not the sequencing. There are more than twenty of them and they
386525
- are not the exception to this: each does one step you invoke and hands back a
386526
- result you read. What is forbidden is a script that decides what to do next,
386527
- because every judgement this loop protects lives in that decision
386528
- - score by string-diffing rows instead of judging them, or reintroduce a
386529
- scripted row oracle: one that can pass a wrong answer is worse than none
386530
- - wait for a bigger gold set before the loop can run; dev/holdout on what
386531
- exists beats waiting
386532
- - register eval MCP tools or stand up an eval API
386533
- - encode unsettled goldens into the model
386534
-
386535
- ## Prime directives
386536
-
386537
- - The model is the only thing improve edits. No question text, qids, or
386538
- expected values in any name, doc, or comment.
386539
- - **You are measuring a model, not reviewing this harness.** Read a script when
386540
- a number you have to report cannot be explained otherwise, and stop there. Do
386541
- not audit the scripts, propose fixes to them, or hand back tooling critique
386542
- in place of a result -- a run that ends in harness feedback has not answered
386543
- the question it was asked. A defect that changed a number gets one sentence
386544
- in the report; a defect that changed nothing gets one line in chat, once.
386545
- Fixing it is a different task, and the user starts it.
386546
- - When the environment misbehaves, stop. Never diagnose a sick system. The
386547
- harness is part of the environment: a known-broken measurement does not
386548
- become quotable by being finished. Measured against this directive, an arm
386549
- already paid for gets quoted anyway -- it happened three times in one run,
386550
- once with the confound stated in the same message that started the arm -- so
386551
- the harness now enforces the parts it can. A run with a truncated or
386552
- contaminated attempt prints no pass rate and records \`status: incomplete\`,
386553
- and \`flip_table.py\` names what one arm left unscored that the other did not.
386554
- Read \`run_error\` on the attempts
386555
- and the INCOMPLETE line before quoting any number.
386556
- - When a subagent disagrees with you, probe. Do not win by authority.
386557
- - When a rule here is wrong, change this file and note it on the run.
386558
-
386559
- ## Related skills
386560
-
386561
- - \`skill:eval-answer\`: contamination, judge protocol, events. Its
386562
- \`reference/ledger-schema.md\` is the file contract; \`skill:eval-judge\` is
386563
- the judge.
386564
- - \`skill:eval-diagnose\`: component, owner, issue events. No edit.
386565
- - \`skill:eval-improve\`: smallest model edit, probe receipts, no self-accept.
386566
- - \`skill:eval-report\`: the run package and the write-up a person reads.
386567
- - The \`malloy-analysis\` skill: what the blind answerer follows. It is installed
386568
- from the \`analysis\` manifest group, not the \`eval\` group.
386569
-
386570
- ## Reference files over MCP
386571
-
386572
- 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 \`eval-loop/<name>\` instead.
386573
-
386574
- Available: acceptance-check, auditing-an-answer-key, checking-the-judge, golden-side-door, measurement, running-a-run.`
386212
+ description: "Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files: the set in the model package''s evals/ directory, runs in the set''s workdir; checkpoints are git commits of the model repo. `eval.py` runs each step from the set''s eval.toml. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.",
386213
+ body: "# The Evaluation Loop\n\nYou conduct this loop. There is no batch orchestrator to start, no eval API,\nand no eval MCP tools. The ledger is plain files: the set in the model\npackage's git repository, and each run in the set's workdir\n(`reference/ledger-schema.md` in `skill:eval-answer` defines every file and\nevent). `scripts/eval.py` runs each step from the set's `eval.toml`;\n`reference/running-a-run.md` has the commands. Scoring is an LLM judge you spawn per case. There is no\nscripted scorer, and there will not be one: a script that can pass a wrong\nanswer is worse than none. The scripts under `scripts/` run the loop -- they\nanswer, re-execute, spawn the judge, compare runs, and write the ledger -- but\nnone of them decides whether an answer was right.\n\n```\nscrape/run -> eval -> diagnose -> improve -> checkpoint\n```\n\n**This skill conducts; it does not restate.** Scoring lives in\n`skill:eval-answer`. Components and owners live in `skill:eval-diagnose`.\nEdit rules live in `skill:eval-improve`.\n\nDo not merge **eval** into **diagnose**. A conductor who scores while\nexplaining writes the explanation into the score. Do not skip the **acceptance\ncheck** inside improve. The acceptance check decides whether *this* edit\nstays. **Checkpoint** decides whether a *sequence* of accepted edits can be\nundone.\n\n## Where the rest of this lives\n\nThis file is the procedure. The things it used to carry inline are files beside\nit now, because each is needed at one moment rather than every run, and loading\nall of them for every run is how a skill stops being read.\n\n| When | Read |\n|---|---|\n| the set does not exist yet, or has never run | `reference/setting-up-a-set.md` |\n| about to run one | `reference/running-a-run.md` |\n| a golden is wrong, doubted, or out of step with the model | `reference/golden-side-door.md` |\n| auditing a key you doubt, or a set you did not author | `reference/auditing-an-answer-key.md` |\n| deciding whether an edit stays | `reference/acceptance-check.md` |\n| about to quote a number, set the band, or read a set's flips | `reference/measurement.md` |\n| the run finished and someone has to read it | `skill:eval-report` |\n| you changed judge doctrine or its inputs | `reference/checking-the-judge.md` |\n\nRead the file, do not work from the summary here. The acceptance-check rules and\nthe golden side door are both places where acting on a half-memory of the rule\nproduces a confident wrong answer rather than an error.\n\n## The five steps\n\n| Step | Job | Writes |\n|---|---|---|\n| **a. scrape / run** | Put cases in the ledger; spawn a blind answerer | cases; `attempt`, `tool_call` |\n| **b. eval** | Judge the answer; score which required entities retrieval delivered | `score` |\n| **c. diagnose** | Why it failed, who owns it | `issue` / `issue_status`. Stop. Do not edit. |\n| **d. improve** | One smallest model edit, then the acceptance check | improve writes `candidate`; you write `acceptance_check`. Revert on reject. |\n| **e. checkpoint** | Git commit after an accepted acceptance check | `checkpoint` event, then the commit |\n\nEvery run then **reports** (below). A run whose result exists only as JSONL and\na scrolled-away console summary has not been delivered.\n\n**scrape** and **run** share a letter but are not the same job. Scrape writes\ncases. Run writes attempts. Do not invent questions and score\nthem in one breath.\n\n**Step d is `skill:eval-improve`, not a hand edit plus another arm.** The\nfailure mode is specific and it has happened: the edits were made by hand,\npublished, and validated by re-running the whole arm -- with the answer key\nrepaired in the same window. No `candidate`, `acceptance_check` or `checkpoint`\nevent exists in that run's ledger, and the resulting move cannot be attributed\nto the model, the key, or the answerer. Re-running a whole arm after changing\ntwo things is the one thing the acceptance check exists to replace: it scores\nthe edit against dev AND holdout, and it is cheaper than the arm.\n\n### Scrape, minimally\n\nImporting an existing corpus IS the scrape step: copy the set from its home\n(for example a benchmarks checkout) into `evals/<set>/` and convert to the\nledger shapes. **`skill:eval-import` is that job in full** -- how to classify\nwhat arrived with each question, why nothing imports as a verified value, and\nthe seal that makes a later edit to a question detectable. Read it whenever the\nquestions came from outside, which is most of the time. While importing:\n\n- Freeze each case's `split`: `dev` or `holdout`. Diagnose and improve read\n dev cases only; the acceptance check runs both. A set that is all dev cannot defend an\n accept.\n- Later, each diagnosed-and-fixed failure becomes a new frozen dev case, so a\n fixed bug cannot silently return.\n\nScraping from production logs (chat transcripts, retrieval traces) is the\nother supported source, and usually the better one: real traffic asks what\npeople actually ask. Where your logs physically live is a host concern; look\nfor a host-specific log-fetching skill. `skill:eval-import` takes over once\nyou have the text, and its `reference/case-format.md` covers what a log pull\nneeds that a question list does not.\n\nPrefer variety over volume when you sample, from either source. Cases that\ndiffer in grain, source, filter shape, and phrasing are what move a\nmeasurement; a second sample of the same case is nearly free of new\ninformation.\n\n### Mode aliases\n\nOlder mode names still work as aliases for how far one run walks:\n\n| Alias | Steps |\n|---|---|\n| `measure` | scrape/run + eval (`eval.py package` then needs `--without-diagnosis`) |\n| `triage` | plus diagnose |\n| `improve` | plus improve + acceptance check + checkpoint on accept |\n\nSay which alias (or which steps) you are running before the first question.\nRecord it in `run.json`. Do not mix steps in a way that lets the answerer see\ngold, issues, or the model file.\n\nMost runs should stop after eval. Diagnose when you need a histogram of\ncomponents and owners. Improve only for diagnosed *model* gaps, one batch at\na time. Checkpoint only after the acceptance check **accepts**.\n\n## Roles\n\n| Role | Sees |\n|---|---|\n| **Answerer** | The question and the Malloy tools. Never the golden, `evals/`, the model file, or any hint it is being evaluated. |\n| **Judge** | The golden and the prediction. Never conducts, never answers, never edits. One fresh subagent per verdict (`skill:eval-judge`). |\n| **You (conductor / improver)** | Everything, including goldens and traces. |\n| **Acceptance check** | The edit and the evidence. Never the improver's self-assessment alone. |\n\nThe answerer stays blind. That is not optional. A grader-visible answerer\nwrites toward the expected answer, and the score is fiction.\n\nThere are no eval MCP tools on purpose. The answerer inherits your tools,\nincluding Shell and Read, so any eval convenience surface would also be a\ngold path for it. Blindness is prevention plus detection, not a guarantee:\n`eval-answer` runs the contamination checklist on every attempt, which is\nwhy you keep a host-side tool-use log per answerer.\n\n## Pick the target first\n\nBoth a local model server and a hosted platform expose the same two tools the\nanswerer needs, `get_context` and `execute_query`, so the loop runs against\neither. What differs is which model is answering and whose data it reads, and\nthose are two separate axes:\n\n| Target | Model under test | Data | Can edit and re-test? |\n|---|---|---|---|\n| **Local (direct)** | your working files | local (for example duckdb), or a direct warehouse connection | yes |\n| **Local (proxied)** | your working files | the platform's connection, through a proxy connection type | yes |\n| **Remote** | the published version, through the platform's hosted `get_context`/`execute_query` | the platform's | no, publishing is not an eval action |\n\nThe middle row is the one worth knowing about: it decouples the two axes, so you\ncan evaluate a model you are still editing against the customer's real data. It\nis a connection configuration, not a feature.\n\n### Three ways to reach the tools\n\nThat table is about which MODEL answers. A second, independent choice is where\nthe answerer's MCP tools come from, and `--mcp-url` is the whole of it:\n\n| | `--target` | `--mcp-url` | Auth |\n|---|---|---|---|\n| **1. Local Publisher** | `local` | `http://localhost:4040/mcp` (default) | none |\n| **2. Hosted, through an editor extension's local bridge** | `platform` | the localhost URL the extension prints | none: the extension holds the credential |\n| **3. Hosted, directly** | `platform` | the host's `https` endpoint, scoped if it offers one | a cached OAuth login, once, interactively |\n\n```bash\n# 1. local\n--target local # --mcp-url defaults to the local Publisher\n\n# 2. hosted via the extension's bridge -- no OAuth, but check what it exposes:\n# the same proxy may front a local Publisher instead\n--target platform --mcp-url http://localhost:<port-the-extension-prints>/mcp \\\n --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>\n\n# 3. hosted directly -- authenticate first, under the SAME server name\nclaude mcp add --transport http <name> <scoped-url>\nclaude # /mcp -> <name> -> Authenticate\n--target platform --mcp-url <scoped-url> \\\n --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>\n```\n\nAll three hand the answerer the same three capabilities (`get_context`,\n`execute_query`, and the docs search), so a comparison between them is between\nagents that could do the same things; `test_the_two_arms_hold_the_same_capabilities`\npins it. Modes 2 and 3 take those names from `--hosted-tools`, which defaults to\nthe bare trio; pass it only if this host names them differently.\n\nA platform run left on the local default `--mcp-url` is refused rather than\nprobed, because pointing every answerer at a local Publisher measures a\ndifferent model over different data than the run claims.\n\nTwo rules follow, and both are the kind of mistake that produces confident\nnonsense rather than an error:\n\n- **The answerer and the conductor must hit the same target.** If the answerer\n queries the published model and you re-execute its query against your edited\n local copy, the score describes neither. Decide the target before the first\n question and record it.\n- **Pin the version the target actually served, not the one you happen to have.**\n A local target pins a commit; a platform target pins the published version.\n Recording a local commit for a run that queried a published model is a pin\n that means nothing.\n\nWhich target for which job:\n\n- **Baseline what customers experience:** Remote. It is the deployed model\n through the deployed engine, which is the thing they actually hit. The judge\n sees no re-executed rows on a Remote run (there is no local copy of the\n bytes), so its verdicts rest on the answer text and the golden; say so.\n- **Improve and accept:** local, because the acceptance check needs compile,\n reload, and a fresh re-answer between edits. Publishing to a customer\n environment to score an edit is not something this loop does. Where the host\n offers draft execution, that counts as local for this purpose.\n- **Measure real data without touching production:** local proxied.\n\nSo a measure-only run can use any target; a run that includes **improve** needs\na local one.\n\nTwo things to check before a platform run, because neither errors and both make\nthe run measure something other than what it names:\n\n- **The answerer's skills must be written for THIS host.** A shared skill names\n an MCP tool by its bare name (`get_context`) so it reads correctly anywhere,\n but a host/router skill names its own host's tools directly. Install the\n latter for the wrong host and the answerer is told to call tools it does not\n have. `run_baseline.py` warns when the manifest it loaded names Publisher-only\n tools on a platform target; point `--answerer-manifest`, or `--skills-root`,\n at the checkout that ships this host's manifest.\n- **The tool names are configuration.** `--hosted-mcp-server` is both the\n `mcp__<server>__<tool>` prefix and the OAuth cache key, so it has to match the\n name the answerer authenticated under, and `--hosted-tools` lists the bare\n tools that host exposes.\n- **Get the hosted tools in front of a headless answerer, one of two ways.**\n A spawned answerer cannot complete an OAuth flow, so the tools have to be\n reachable before the run starts. `run_baseline.py` proves it with one cheap\n probe and refuses to spend an arm otherwise -- a run whose answerers have no\n tools does not error, it reads as a terrible model.\n\n 1. **Authenticate once, interactively.** Works anywhere, including a plain\n CLI install, and is the route to assume unless you know otherwise. The\n token is cached per server NAME, so authenticate under the same name the\n run passes to `--hosted-mcp-server`:\n\n ```bash\n claude mcp add --transport http <name> <scoped-url>\n claude # then /mcp -> <name> -> Authenticate\n ```\n\n Then come back and run. This is a hand-off to a person; there is no\n headless equivalent, so plan for it rather than discovering it mid-run.\n\n 2. **A local proxy that already holds the credential.** Some hosts ship an\n editor extension whose local MCP proxy can expose the hosted\n `get_context` / `execute_query` -- often behind a setting that is off by\n default. Where that exists, point `--mcp-url` at the proxy on localhost\n and no OAuth step is needed, because the extension holds it. Check what\n the proxy actually exposes before relying on it: the same proxy may serve\n a local Publisher's tools instead, and then `--hosted-tools` is\n naming tools that are not there. This route is not available to someone\n running the CLI alone.\n\n- **Prefer a SCOPED endpoint URL over asking for scope.** A hosted MCP is\n usually reachable two ways: a global endpoint where every call carries an\n organization and workspace, and a scoped one where the URL itself is the\n scope. `--scope` and the prompt can only ASK an answerer to stay in one\n package; a scoped URL enforces it. For an agent being measured that is the\n difference between a case answered against the package it names and one\n answered against whatever else the account can see. Authenticate once\n interactively (`claude`, `/mcp`) under the same server name the run will use;\n the token is cached per name, and a spawned headless answerer cannot complete\n an OAuth flow.\n\n- **Pin the VERSION in the scope, not just the package.** Write\n `--scope <env>/<package>@<version>`. Both hosted tools take a version and both\n document the same default for an omitted one: the PINNED version, which is\n whatever the workspace serves at the moment of the call. So an unversioned run\n records `targetVersion` in `run.json` and then answers from whatever is\n current, and the two part company the moment anyone publishes -- including\n mid-run, which measures two builds under one label. `--target-version` fills\n the version in when the scope omits it, so a platform run is pinned without\n opting in; a scope naming a different version is refused rather than taken as\n an override.\n\n## Before you start\n\n1. The model package under evaluation must live in a git repository, with\n `evals/<set>/` in the package, beside the model files. Git is the checkpoint\n mechanism; without it there is no rollback and no run can include improve.\n\n Keeping the set IN the package is what stops a model edit and its answer key\n drifting apart: they move in one commit, so fixing a measure and forgetting\n the golden that depended on it stops being possible. It is safe -- measured\n on a running server, a `cases.jsonl` inside a package appears in no model\n listing, no notebook listing, no package resource, and 404s over HTTP, so an\n MCP-only answerer has no route to it.\n\n What it buys differs by target. On a LOCAL Publisher it does not get you free\n versioning -- `sourceContentSha` hashes model paths only, so the set needs\n its own `datasetSha`. On a hosted target that publishes the whole package\n directory as an IMMUTABLE version, the set rides inside that version and\n `targetVersion` pins model and answer key together; nothing can be edited\n under a published version, which is what makes it a pin. Check which you have\n before deciding how much of this you need.\n\n **Look for a set and for prior runs before you author either.** A minute of\n `find . -name cases.jsonl`, a glance at the set's workdir (`<workdir>/runs/`,\n `~/.malloy-eval/<set>-<hash>/runs/` unless `eval.toml` moves it) and at your host's\n own transcripts for this repo. Two sessions fourteen minutes apart built the\n same 29-case answer key from scratch, because the first had committed\n nothing before it was deleted and the second had no way to know it existed.\n Roughly a working day was spent twice, and three specific things were\n rediscovered at cost: the MCP login flow, the retrieval gate's 401, and a\n judge-rendering bug that had already cost $17 of arm once. The two keys,\n independently authored from the same questions, differ by about ten points\n on comparable answers -- which is the available measure of how much a key\n depends on its author, and a reason to reuse one rather than rebuild it.\n\n **Audit the set's entity ids before the first arm.** `verify_goldens.py`\n checks that each id names something in the model; `check_findable.py` checks\n that a search of its own kind actually returns it. Both are free of model\n calls. An id that fails either scores a retrieval miss on every run, and the\n miss reads as the model's fault.\n\n **Commit the set before you spend money on an arm**, and keep durable\n outputs in the repository. A findings document in `~/Downloads` is gone the\n first time somebody tidies up; the set and the write-up belong in git\n beside the model.\n\n **Runs go in the set's workdir, never inside the model package.** A run\n directory holds a `model.malloy` snapshot, and a built report is a Malloy\n package; inside the package under test, either puts that package into\n `loadErrors`. The default workdir, `~/.malloy-eval/<set>-<hash>/`, is outside git,\n which suits a measure-only run. A run that will improve and checkpoint needs\n its ledger kept: set `[paths] workdir` in `eval.toml` to a directory in the\n model's repository but outside the package, and gitignore its `servers/`\n and `packages/`, which are a server's database and rebuilt reports.\n\n2. The server must be up. Where your host offers retrieval tracing, turn it on\n and confirm a trace lookup answers, so a call's ranked results can be\n recovered afterwards.\n\n **Open-source Publisher has none.** `PUBLISHER_MCP_TRACE=retrieval` is set by\n `serve.py` and read by nothing: the trace store was written and never merged,\n so there is no trace tool and `traceId` is null on every local attempt. This\n does not block a scored run, because attribution never depended on it:\n `rankedSummary` is copied onto the `tool_call` event at capture, precisely so\n the evidence survives without a store. So do not refuse a local run for want\n of tracing -- an earlier version of this rule did, and it refused every local\n run there has ever been. Refuse one whose `tool_call` events carry no\n `rankedSummary`, which is what attribution actually reads.\n\n3. Health-check: your host's status check until it reports serving, and inspect\n `loadErrors`. A dead database that still answers HTTP is an environment\n failure, not a model failure. Stop and fix it. Four consecutive\n environment or no-result attempts means stop the run.\n\n4. Load the set: scrape/import as above, or reuse an existing `evals/<set>/`.\n `eval.py check --set <set>` names every gap in it before anything starts.\n Never keep two live copies of one set; the set directory in the model repo\n is the single source of truth, versioned by `datasetVersion` in\n `set.json`.\n\n5. Review goldens before you score. **Check how many cases can take a verdict\n at all**, not just how many cases there are: a golden the set stamps\n `provisional`, `invalid` or `ambiguous`, or a case with no golden, scores\n `verdict: null` and stays out of the pass rate. An imported set is\n `provisional` throughout by design (`skill:eval-import`), and the only thing\n that changes that is `verify_goldens.py --promote` after a re-derivation\n through the truth package. A run whose every key is underived is refused\n rather than spent; a set of bare questions runs, because its answers are\n what keys get derived from. A verified golden that holds a value but\n no local artifact stays verified by provenance and is not scorable until you\n have rows or a scalar to compare (the judge needs both sides). A `criteria`\n golden is the exception: it holds no value, so its clauses are both sides. If diagnosis later marks\n `BAD-REFERENCE` or `AMBIGUOUS-REFERENCE`, follow\n `reference/golden-side-door.md`. Both are expected in the wild; both\n are the golden side door below, not improve, and not a sixth step.\n\n6. `eval.py run` creates `<workdir>/runs/<runId>/run.json` with the attribution pins\n (`reference/ledger-schema.md`): mode, dataset version, **the target and the\n version it served** (a local target pins a commit, so commit or stash first;\n answering from a dirty tree pins nothing), server version, judge version and\n rubric sha, answerer model, call budget, trace mode. Freeze those for the\n whole run. Raising a call budget mid-run moved mean outcomes on an unchanged\n model.\n\n The call budget is `--max-turns`, written to `run.json` as `maxTurns`.\n **Size it from a pilot rather than taking the default of 30.** Run the three\n cheapest cases uncapped (`--max-turns 100`) and set the cap at twice their\n maximum. A set whose questions need two or three sources joined does not fit\n a cap sized for single-source lookups: on one such set the completed\n attempts had a median of 17 turns and a 90th percentile of 26 against a cap\n of 30, and four cases died at it. A cap 15% above the 90th percentile of\n completed work is not a safety margin. Note also that `malloy-analysis` tells\n the answerer to persist -- retry a phrasing, let a small query settle whether\n a field exists -- so a tight cap and that instruction are in direct conflict.\n\n **Do not change the model and the measuring instrument in the same step.**\n Those are two different axes and only one of them is cheap to separate.\n\n Batching MODEL edits is fine and expected. Clustering exists so that one\n edit closes several cases, and an arm per fix does not survive contact with\n arithmetic: 20 fixes over 100 cases is 2,000 answers, and at the measured\n $0.33 a case on a proxied warehouse that is $660 of answering to attribute\n what the acceptance check attributes for the price of the affected cases\n plus holdout. Budget five arms for a defensible claim (a baseline, two for\n the A/A, two post-edit), not one per edit, and let `skill:eval-improve`\n carry each cluster.\n\n The instrument is the other axis: the answer key, the judge and its prompt,\n the answerer model, the skills. Move one of those together with the model\n and there is nothing left holding still, so the result measures neither. On\n the run this comes from, nine model commits and a re-derived answer key\n landed between two arms, and the move from 20% to 39% belongs to no one --\n not because two model edits were batched, but because the ruler changed at\n the same time as the thing being measured. A key repair mid-improve is not\n forbidden; it ends that comparison, so re-baseline rather than quoting a\n delta across it.\n\n The answerer model is instrument too, and it is the one most often left\n unstated. The same 23-case set read 12 match / 11 near / 0 no_match on one\n model and 8 / 4 / 15 on a smaller one. No statement about \"the agent's\"\n capability means anything until two arms name the same answerer.\n\n7. Generate every answerer prompt from the stored case in `cases.jsonl`.\n Never retype the question. A truncated retype is indistinguishable from a\n real question downstream.\n\n## Per question\n\n1. Health-check again.\n2. Spawn a *fresh* blind subagent. Give it only the question text and the\n Malloy analysis tools. Tell it to follow the `malloy-analysis` skill. Do not\n mention eval, gold, scoring, or this skill.\n3. Keep a host-side tool-use log for that subagent (name, input path or\n command, MCP tool name). Publisher traces see MCP only; a Read of a gold\n CSV is invisible server-side.\n4. `skill:eval-answer`: contamination first, then re-execute, then the judge,\n then events.\n5. `skill:eval-diagnose` only when this run includes diagnose, only on dev\n cases, and only after the score event exists.\n6. `skill:eval-improve` only when this run includes improve, and only for\n `owner: model`. Then run the acceptance check. On accept, checkpoint.\n\n## Report the run, or nobody can read it\n\nA run directory is JSONL. It is a record, not a result, and the console summary\nscrolls away. **Every run ends by producing something a person can open**, and\nthat job is `skill:eval-report`: it builds the servable run package (the case\nmatrix app and the aggregate notebook) and gives the template for the write-up.\n\nRead it at the end of every run, including a run that failed. The standing\ncomplaint about this loop is that \"a bunch of stuff happens and it is hard to\nknow the actual results\", and a ledger nobody renders is why.\n\nTwo rules from it are worth repeating here, because they are the ones a\nconductor skips:\n\n- **Separate a MODEL failure from an EVAL failure.** A wrong answer and a\n broken measurement look identical in a pass rate and have nothing else in\n common. An arm holding a truncated, contaminated or environment-failed\n attempt has no rate to quote at all.\n- **Say when a step did not run, and why.** `improve` not running because every\n cluster came back `owner: agent-skill` is a RESULT, and it reads identically\n to having forgotten unless it is written down.\n\nKeep the write-up in the repository beside the set, not in a chat log and not\nin `~/Downloads`.\n\n## Checkpoint\n\nA checkpoint is a git commit of the model repository, taken after an acceptance check\naccepts, so a bad improve direction can be rolled back. It is not a report,\nand it is not a remote publish.\n\n1. Commit the model files AND the set's ledger in one commit; put the label\n and the closed issue ids in the message. The run's ledger is in that commit\n only when the workdir is in the repository (Before you start, item 1).\n2. Append the `checkpoint` event (`action: created`, label, `modelGitSha`\n from the commit you just made, issueIds). The event line itself rides in\n the next commit; append-only logs trail by one commit and that is fine.\n3. Confirm `git status` is clean for the model files.\n\n**Restore**: `git checkout <sha> -- <model files>` (or `git revert` the\ncheckpoint commits), then reload the package, then append a `checkpoint`\nevent with `action: restored` and the sha. Readers return to the model that\nexisted before the bad direction.\n\nTake a checkpoint of the current model *before* the first improve batch if no\ncommit pins it yet. Rolling back by hand is guesswork.\n\nIf reload reports `mode: reinstalled`, the package was re-fetched from its\ninstall location and may have overwritten the restored files. Prefer in-place\n/ watch-mounted packages for this loop.\n\n## Out of scope\n\nThis loop is local. The ledger is files, the checkpoints are git, you are the\nconductor. Do not:\n\n- publish the model to a hosted platform as a \"true\" checkpoint or learning\n curve\n- start a Python orchestrator (`loop.py`, `run_all.py`, `improve_batch.py`)\n that runs the five steps end to end unattended. You conduct; the scripts are\n the steps, not the sequencing. There are more than twenty of them and they\n are not the exception to this: each does one step you invoke and hands back a\n result you read. What is forbidden is a script that decides what to do next,\n because every judgement this loop protects lives in that decision\n- score by string-diffing rows instead of judging them, or reintroduce a\n scripted row oracle: one that can pass a wrong answer is worse than none\n- wait for a bigger gold set before the loop can run; dev/holdout on what\n exists beats waiting\n- register eval MCP tools or stand up an eval API\n- encode unsettled goldens into the model\n\n## Prime directives\n\n- The model is the only thing improve edits. No question text, qids, or\n expected values in any name, doc, or comment.\n- **You are measuring a model, not reviewing this harness.** Read a script when\n a number you have to report cannot be explained otherwise, and stop there. Do\n not audit the scripts, propose fixes to them, or hand back tooling critique\n in place of a result -- a run that ends in harness feedback has not answered\n the question it was asked. A defect that changed a number gets one sentence\n in the report; a defect that changed nothing gets one line in chat, once.\n Fixing it is a different task, and the user starts it.\n- When the environment misbehaves, stop. Never diagnose a sick system. The\n harness is part of the environment: a known-broken measurement does not\n become quotable by being finished. Measured against this directive, an arm\n already paid for gets quoted anyway -- it happened three times in one run,\n once with the confound stated in the same message that started the arm -- so\n the harness now enforces the parts it can. A run with a truncated or\n contaminated attempt prints no pass rate and records `status: incomplete`,\n and `flip_table.py` names what one arm left unscored that the other did not.\n Read `run_error` on the attempts\n and the INCOMPLETE line before quoting any number.\n- When a subagent disagrees with you, probe. Do not win by authority.\n- When a rule here is wrong, change this file and note it on the run.\n\n## Related skills\n\n- `skill:eval-answer`: contamination, judge protocol, events. Its\n `reference/ledger-schema.md` is the file contract; `skill:eval-judge` is\n the judge.\n- `skill:eval-diagnose`: component, owner, issue events. No edit.\n- `skill:eval-improve`: smallest model edit, probe receipts, no self-accept.\n- `skill:eval-report`: the run package and the write-up a person reads.\n- The `malloy-analysis` skill: what the blind answerer follows. It is installed\n from the `analysis` manifest group, not the `eval` group.\n\n## Reference files over MCP\n\nThis 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 `eval-loop/<name>` instead.\n\nAvailable: acceptance-check, auditing-an-answer-key, checking-the-judge, golden-side-door, measurement, running-a-run, setting-up-a-set."
386575
386214
  },
386576
386215
  {
386577
386216
  name: "eval-loop/acceptance-check",
@@ -387328,25 +386967,58 @@ model score.`
387328
386967
  one fresh answerer per case with only the Publisher MCP tools, a contamination
387329
386968
  check, a judge, and a conformant \`events.jsonl\`.
387330
386969
 
386970
+ Every step reads the set's \`eval.toml\`: the model server's environment,
386971
+ package, repo and ports, and the truth server's. Write it once per set, beside
386972
+ \`set.json\`. Relative paths resolve against the file. A set that does not exist
386973
+ yet starts at \`setting-up-a-set.md\`, beside this file.
386974
+
386975
+ \`\`\`toml
386976
+ [model] # the Publisher the answerer queries
386977
+ environment = "<env>"
386978
+ package = "<pkg>"
386979
+ repo = "<path to the model package>"
386980
+ port = 4811
386981
+ mcp_port = 4812
386982
+
386983
+ [truth] # a second Publisher, serving only the truth package
386984
+ environment = "truth"
386985
+ port = 4881
386986
+ mcp_port = 4882
386987
+ \`\`\`
386988
+
386989
+ No script defaults to any particular set's environment or package. A missing
386990
+ one stops with the key to add. Runs, built packages and server roots go under
386991
+ \`~/.malloy-eval/<set>-<hash>/\` unless \`[paths] workdir\` says otherwise, never inside
386992
+ the repository: a run holds a \`model.malloy\` snapshot and a built package is a
386993
+ Malloy package, and nested in the package under test either can put that
386994
+ package into \`loadErrors\`. The hash is of the set's path, so two sets with one
386995
+ name never share a \`runs/\`. Each command below is \`python3 skills/eval-loop/scripts/eval.py\`,
386996
+ and any flag the underlying script takes passes through.
386997
+
387331
386998
  \`\`\`bash
387332
- # 1. serve the model under test -- in its own session, so the shell's exit
387333
- # cannot take it down, and returning only once it answers a query.
386999
+ # 0. every gap in the set at once, before anything starts: missing files, a
387000
+ # case the importer refuses, a truth package the model server would serve,
387001
+ # a table path that resolves to nothing once served, a port already held.
387002
+ # Free, and it starts nothing. Exits 1 until nothing blocks a run.
387003
+ eval.py check --set <set>
387004
+
387005
+ # 1. serve the model under test, and the TRUTH package on a second server the
387006
+ # answerer has no route to. Each runs in its own session, so the shell's
387007
+ # exit cannot take it down, and returns only once it answers a query. The
387008
+ # script writes each server's publisher.config.json from eval.toml, and
387009
+ # refuses a truth server on the model server's ports.
387334
387010
  # --warm-retrieval also waits for the embedding index to settle and exits 3
387335
387011
  # if it does not reach \`ready\`: the sync is lazy, so without it the first
387336
387012
  # cases are answered LEXICALLY and the run reports that as the model's
387337
387013
  # number.
387338
387014
  # Semantic retrieval needs EMBEDDING_API_KEY in the server's environment
387339
- # (the Publisher does not read OPENAI_API_KEY); without it every ranking is
387340
- # lexical, the warm-up reports \`cooldown\`, and the provider's own error is
387341
- # only in publisher.log (grep Embedding).
387342
- python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
387343
- --server-root <root> --port 4811 --mcp-port 4040 --trace-retrieval \\
387344
- --warm-retrieval --environment <env> --package <pkg> \\
387015
+ # (the Publisher does not read OPENAI_API_KEY). The script warns when it is
387016
+ # unset; without it every ranking is lexical, the warm-up reports
387017
+ # \`cooldown\`, and the provider's own error is only in publisher.log
387018
+ # (grep Embedding).
387019
+ eval.py serve model --set <set> --warm-retrieval \\
387345
387020
  [--allow-proxy] # required for a \`publisher\`-type (proxied) connection
387346
- # a second server for the TRUTH package, on other ports, that the answerer
387347
- # has no route to:
387348
- python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
387349
- --server-root <truthroot> --port 4881 --mcp-port 4882 [--allow-proxy]
387021
+ eval.py serve truth --set <set> [--allow-proxy]
387350
387022
 
387351
387023
  # 2a. audit the set against the package it is about to be scored on. Free, and
387352
387024
  # it catches the failure that reads as a model regression: an entity id
@@ -387358,10 +387030,9 @@ python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/s
387358
387030
  # --definitions <ledger> (verify_definitions.py), a set whose every
387359
387031
  # value-bearing case rests on validated definitions exits 0 instead:
387360
387032
  # the composition rule, values not re-derived but their definitions
387361
- # checked.
387362
- python3 skills/eval-answer/scripts/verify_goldens.py \\
387363
- --set <repo>/evals/ecommerce --publisher http://localhost:4881 \\
387364
- --model <repo>/ecommerce --target-package ecommerce
387033
+ # checked. --model adds the entity-id audit; it greps the model text, so a
387034
+ # column a source exposes implicitly reads as missing.
387035
+ eval.py verify --set <set> [--model <package>]
387365
387036
 
387366
387037
  # 2b. ONLY on a set whose keys are still provisional -- an imported one is,
387367
387038
  # throughout, by design. Nothing else in this toolchain writes
@@ -387370,59 +387041,50 @@ python3 skills/eval-answer/scripts/verify_goldens.py \\
387370
387041
  # AND carries a second derivation, and prints the reason for every golden
387371
387042
  # it left alone. \`--refresh\` is NOT this: it rewrites a drifted value and
387372
387043
  # never touches a status.
387373
- python3 skills/eval-answer/scripts/verify_goldens.py \\
387374
- --set <repo>/evals/ecommerce --publisher http://localhost:4881 \\
387375
- --target-package ecommerce --promote
387044
+ eval.py verify --set <set> --promote
387376
387045
 
387377
387046
  # 2. smoke one case first ($0.13), then the arm. Goldens are re-derived from
387378
387047
  # the truth server before either starts; a drifted set refuses to run.
387379
- python3 skills/eval-loop/scripts/run_baseline.py \\
387380
- --set <repo>/evals/ecommerce --out results/smoke --only <qid> --no-judge \\
387381
- --truth-publisher http://localhost:4881
387382
- python3 skills/eval-loop/scripts/run_baseline.py \\
387383
- --set <repo>/evals/ecommerce --out results/<arm> \\
387384
- --parallel 4 --truth-publisher http://localhost:4881 \\
387385
- --model-repo <repo> --model-dir <package> # the checkout the MODEL is
387386
- # versioned in, recorded as modelGitSha, and the package
387387
- # directory inside it the -dirty marker is decided over
387388
- # (relative to the repo). Without --model-dir any
387389
- # untracked file anywhere in the repo marks the model
387390
- # dirty. The repo cannot be inferred: Publisher serves a
387391
- # copy under publisher_data/, whose surrounding tree is
387392
- # the server's storage, not the model's history.
387048
+ # [model] repo is the model PACKAGE directory. The run records the commit
387049
+ # of the checkout it sits in as modelGitSha, and decides the -dirty marker
387050
+ # over that package directory only, so an untracked file elsewhere in the
387051
+ # repository does not mark the model dirty. It cannot be inferred:
387052
+ # Publisher serves a copy under publisher_data/, whose surrounding tree is
387053
+ # the server's storage, not the model's history.
387054
+ eval.py run --set <set> --label smoke --only <qid> --no-judge
387055
+ eval.py run --set <set> --parallel 4
387393
387056
  # the run names itself <set>-<phase>-<nn> (ecommerce-baseline-01, then -02
387394
- # for the second arm of the A/A). Pass --label only for a run that needs a
387395
- # human name; hand-typed arm names stop being readable within an afternoon.
387057
+ # for the second arm of the A/A) and prints its directory. Pass --label
387058
+ # only for a run that needs a human name; hand-typed arm names stop being
387059
+ # readable within an afternoon.
387396
387060
 
387397
387061
  # 3. compare two arms, or two runs of one arm. It exits 2 when the arms used
387398
387062
  # different retrievers, because those flips measure the retriever.
387399
- python3 skills/eval-loop/scripts/flip_table.py --a results/<a> --b results/<b>
387063
+ python3 skills/eval-loop/scripts/flip_table.py --a <run a> --b <run b>
387400
387064
 
387401
387065
  # 3a. on an A/A, write the band into the set's calibration record. Nothing
387402
387066
  # else may quote a band, and a band only covers runs whose pins match.
387403
387067
  python3 skills/eval-loop/scripts/flip_table.py \\
387404
- --a results/aa-1 --b results/aa-2 --calibration \\
387405
- >> <repo>/evals/ecommerce/CALIBRATION.md
387068
+ --a <aa-1 run> --b <aa-2 run> --calibration >> <set>/CALIBRATION.md
387406
387069
 
387407
387070
  # 3b. check the JUDGE, not the model, after any change to the judge skill, a
387408
387071
  # rubric, or what the judge is shown. It also reports which fixtures are
387409
387072
  # unpinned and which decision classes nothing covers.
387410
- python3 skills/eval-loop/scripts/check_judge.py \\
387411
- --set <repo>/evals/ecommerce --repeat 3
387073
+ python3 skills/eval-loop/scripts/check_judge.py --set <set> --repeat 3
387412
387074
 
387413
387075
  # 4. FIRST: any golden in doubt, from the judge or the set. \`jq .doubtedGoldens
387414
- # results/<arm>/run.json\` -- non-empty means settle those through the golden
387415
- # side door before diagnosing, or you send a modelling agent at a model that
387416
- # is already right.
387417
- python3 skills/eval-diagnose/scripts/diagnose.py \\
387418
- --run results/<arm> --set <repo>/evals/ecommerce --model-dir <package>
387076
+ # <run>/run.json\` -- non-empty means settle those through the golden side
387077
+ # door before diagnosing, or you send a modelling agent at a model that is
387078
+ # already right. It probes the server the run recorded.
387079
+ eval.py diagnose --set <set> --label <label>
387419
387080
  # (cluster_failures.py gives a free mechanical first look, as
387420
387081
  # clusters-mechanical.jsonl; it groups by retrieval outcome and is not a
387421
387082
  # diagnosis)
387422
387083
 
387423
- # 5. build the browsable package
387424
- python3 skills/eval-loop/scripts/build_run_package.py \\
387425
- --run results/<a> --run results/<b> --set <repo>/evals/ecommerce --out <pkg>
387084
+ # 5. build the browsable package. Refused until every run has a diagnosis
387085
+ # (--without-diagnosis overrides); a run where everything passed records an
387086
+ # empty one. Pass --run twice for an A/B.
387087
+ eval.py package --set <set> --label <label>
387426
387088
  \`\`\`
387427
387089
 
387428
387090
  ### What the run prints at the end
@@ -387442,12 +387104,12 @@ Three layers, in this order, and the order is what makes it readable:
387442
387104
  report, so this layer is the two commands that turn it into something you
387443
387105
  read: \`build_run_package.py\` (a Malloy model over the run's CSVs,
387444
387106
  \`notebooks/eval_run.malloy\` for the aggregate tables, and an in-package HTML app for
387445
- the case matrix and its per-case drawer), then a \`POST .../packages\` that
387446
- registers it on the Publisher already running, with no restart. It prints
387447
- the resulting URL with the run's own paths filled in.
387448
-
387449
- The POST lands the package in the environment the run used. Point it at
387450
- another if you would rather that package listing stay untouched.
387107
+ the case matrix and its per-case drawer), registered on a Publisher that
387108
+ is already running, with no restart. \`eval.py package\` builds it,
387109
+ registers it on the TRUTH server, and prints both URLs; the package holds
387110
+ the answer key, and that is the server the answerer has no route to. The
387111
+ registration command is also written into the package's README.md, for
387112
+ serving it again later; \`--no-register\` builds without registering.
387451
387113
 
387452
387114
  Order of magnitude for planning, **calibrated on ecommerce over local duckdb**:
387453
387115
  a Sonnet arm over a few dozen cases costs single-digit dollars and finishes in
@@ -387485,6 +387147,163 @@ Two failure modes worth pre-empting, because both produce a clean-looking run:
387485
387147
  - **Check the served revision is the one you edited.** Publisher serves a
387486
387148
  snapshot copy, so a model fix can be absent from the run that is supposed to
387487
387149
  measure it. Query the changed measure once before spending an arm on it.`
387150
+ },
387151
+ {
387152
+ name: "eval-loop/setting-up-a-set",
387153
+ description: "Setting up a set on your own model. Reference detail for the eval-loop skill.",
387154
+ body: `<!-- The first run of your own set, in order. Read it when a set does not exist yet. -->
387155
+
387156
+ # Setting up a set on your own model
387157
+
387158
+ \`examples/storefront/evals/storefront-tour\` is a finished set. This is the path
387159
+ to one of your own: which files a set needs, what writes each one, and the
387160
+ order they go in. Every command is \`python3 skills/eval-loop/scripts/eval.py\`,
387161
+ run from the Publisher clone, unless it names another script.
387162
+
387163
+ \`eval.py check --set <set>\` reads everything below and names every gap at once,
387164
+ without starting a server or calling a model. Run it after each step; the set is
387165
+ ready when it prints \`ready: nothing blocks a run\`.
387166
+
387167
+ ## What you need first
387168
+
387169
+ - A Malloy package with a \`publisher.json\`, in a git repository. Git is how a
387170
+ run pins the model it measured and how an improve step is rolled back.
387171
+ - A built Publisher clone (\`bun install && bun run build\`), Python 3.11+, and a
387172
+ Java runtime for the SDK build.
387173
+ - \`EMBEDDING_API_KEY\` exported, for semantic retrieval. Without it the model
387174
+ server ranks lexically and says so on start.
387175
+
387176
+ ## 1. Make the set directory
387177
+
387178
+ One directory per set, in the same repository as the model package.
387179
+ \`skill:eval-import\` "Where the set goes" has the rule about how close to the
387180
+ package it may sit. It holds:
387181
+
387182
+ | File | What it is | Written by |
387183
+ |---|---|---|
387184
+ | \`set.json\` | the set's name, the package and model under test, the truth package | you |
387185
+ | \`cases.jsonl\` | one case per question: text, split, golden | \`skill:eval-import\` |
387186
+ | \`as-received/\` | the questions exactly as they arrived | you, untouched |
387187
+ | \`eval.toml\` | where each step finds its servers | you |
387188
+ | \`gold/<qid>.json\` | a golden's second derivation | you, when you derive one |
387189
+
387190
+ \`set.json\`, at minimum (\`reference/ledger-schema.md\` in \`skill:eval-answer\`
387191
+ has every field):
387192
+
387193
+ \`\`\`json
387194
+ {"name": "<set>", "description": "<what it measures>", "datasetVersion": 1,
387195
+ "targetPackage": "<package name>", "targetModelPath": "<model>.malloy"}
387196
+ \`\`\`
387197
+
387198
+ ## 2. Import the questions
387199
+
387200
+ \`skill:eval-import\` turns questions in any shape into \`cases.jsonl\` and says what
387201
+ each one is worth. A set with no questions yet has nothing to import: write the
387202
+ questions a person would actually ask into \`as-received/\` first, then import
387203
+ them, rather than writing cases by hand. Then stamp and validate:
387204
+
387205
+ \`\`\`bash
387206
+ python3 skills/eval-import/scripts/import_cases.py --set <set> --stamp
387207
+ \`\`\`
387208
+
387209
+ It refuses what later steps would fail on, with the fix: no \`split\`, a value
387210
+ claiming \`verified\` on arrival, a \`scalar\` value that is a bare number rather
387211
+ than \`{"<column>": <value>}\`. A set of bare questions is valid. It runs and
387212
+ measures coverage, and cannot score accuracy until keys exist.
387213
+
387214
+ ## 3. Build the truth package, if any case holds a value
387215
+
387216
+ A golden holding a value is re-derived before every run from a truth package:
387217
+ the raw tables your model reads, with no modelling, served on a second server
387218
+ the answerer cannot reach. Scaffold it OUTSIDE the model package, because
387219
+ Publisher serves every \`.malloy\` under a package directory:
387220
+
387221
+ \`\`\`bash
387222
+ python3 skills/eval-answer/scripts/init_truth_package.py \\
387223
+ --package <model package> --out <outside it>/<set>-truth --name <set>-truth
387224
+ \`\`\`
387225
+
387226
+ It writes one source per raw table and links the model's data directory in, so
387227
+ file paths still resolve once Publisher serves its copy of the package. The link
387228
+ holds this machine's path; to commit the truth package, copy the data in
387229
+ instead, as \`examples/storefront-tour-truth/\` does. Add the package's scope
387230
+ filters by hand, as its header says, and nothing else. Then set
387231
+ \`"truthPackage": "<set>-truth"\` in \`set.json\`.
387232
+
387233
+ A set of criteria goldens or bare questions needs no truth package; \`check\`
387234
+ notes its absence and moves on.
387235
+
387236
+ ## 4. Write eval.toml
387237
+
387238
+ \`\`\`toml
387239
+ [model] # the Publisher the answerer queries
387240
+ environment = "<env>"
387241
+ package = "<package name>"
387242
+ repo = "<path to the model package>" # relative to this file
387243
+ port = 4811
387244
+ mcp_port = 4812
387245
+
387246
+ [truth] # only with a truth package; an empty [truth] takes 4881/4882
387247
+ environment = "truth"
387248
+ package_dir = "<path to the truth package>"
387249
+ port = 4881
387250
+ mcp_port = 4882
387251
+ \`\`\`
387252
+
387253
+ \`package\` here and \`targetPackage\` in \`set.json\` must name the same package.
387254
+ Where both are set, \`eval.toml\` wins, and \`check\` refuses the set until they
387255
+ agree.
387256
+
387257
+ Pick ports nothing else on the machine holds; \`check\` names any that are taken.
387258
+ Every later step reads them from this file, so change them here rather than
387259
+ passing \`--port\`, which \`serve\` refuses when it disagrees with the file.
387260
+
387261
+ ## 5. Check, serve, verify
387262
+
387263
+ \`\`\`bash
387264
+ eval.py check --set <set> # until: ready
387265
+ eval.py serve model --set <set> --warm-retrieval
387266
+ eval.py serve truth --set <set> # with a truth package
387267
+ eval.py verify --set <set> # free: re-derives every golden
387268
+ \`\`\`
387269
+
387270
+ \`verify\` exits 0 when every golden re-derives, 1 when one drifted, and 3 when it
387271
+ could not check (no truth package, or no truth server). Read 3 as "not run",
387272
+ never as a pass.
387273
+
387274
+ ## 6. Turn provisional keys into scorable ones
387275
+
387276
+ An imported value is \`provisional\`, and a provisional golden takes no verdict.
387277
+ \`--promote\` marks one \`verified\` only when it re-derived cleanly through the
387278
+ truth package AND a second, differently shaped derivation exists: a
387279
+ \`golden.verification\` block, or \`gold/<qid>.json\` holding \`verifyRows\`
387280
+ (\`reference/ledger-schema.md\`, the \`golden\` row, says what the second one must
387281
+ vary).
387282
+
387283
+ \`\`\`bash
387284
+ eval.py verify --set <set> --promote
387285
+ \`\`\`
387286
+
387287
+ It prints why it left each golden alone. \`check\` shows the scorable count.
387288
+ Where a person has checked a value by hand instead of deriving it a second way,
387289
+ \`--promote --attest "<who checked, when, how>"\` promotes it and writes that text
387290
+ on the golden, so a reader sees a person stood behind it rather than a query.
387291
+
387292
+ ## 7. The first run
387293
+
387294
+ \`\`\`bash
387295
+ eval.py run --set <set> --label smoke --only <qid>[,<qid>...] --max-turns 40 # a case or two first
387296
+ eval.py run --set <set> --label baseline-01 --max-turns 40
387297
+ eval.py diagnose --set <set> --label baseline-01
387298
+ eval.py package --set <set> --label baseline-01
387299
+ \`\`\`
387300
+
387301
+ Then \`reference/running-a-run.md\` for what the run prints and how to read it,
387302
+ and \`skill:eval-report\` for the write-up.
387303
+
387304
+ Measured on the tour's local DuckDB data, with sonnet answering and judging:
387305
+ about $0.35 a case to run, and $0.60 and three minutes to diagnose one failed
387306
+ case. A warehouse over a proxy costs several times more per case.`
387488
387307
  },
387489
387308
  {
387490
387309
  name: "eval-report",
@@ -387538,15 +387357,23 @@ different task with its own turn.
387538
387357
  ## Step 1: build the artifacts, before writing a word
387539
387358
 
387540
387359
  \`\`\`bash
387541
- python3 skills/eval-loop/scripts/build_run_package.py \\
387542
- --run <run-dir> --set <set-dir> --out /tmp/eval-<label>
387543
- curl -sS -X POST http://<publisher>/api/v0/environments/<env>/packages \\
387544
- -H 'content-type: application/json' \\
387545
- -d '{"name":"eval-<label>","location":"/tmp/eval-<label>"}'
387360
+ python3 skills/eval-loop/scripts/eval.py package --set <set-dir> --label <label>
387546
387361
  \`\`\`
387547
387362
 
387548
- That builds a Malloy package over the run's own CSVs and registers it with no
387549
- restart. It gives you two things to link:
387363
+ That builds a Malloy package over the run's own CSVs, registers it with no
387364
+ restart, and prints the two URLs below. It registers on the TRUTH server,
387365
+ because the package holds the answer key and the answerer must not reach it.
387366
+ If the truth server is not running it says so and prints the \`curl\` to run
387367
+ once it is. A set with no truth server gets no registration: the only
387368
+ Publisher is the answerer's. Add a \`[truth]\` section and
387369
+ \`eval.py serve truth\`, or pass \`--on-model-server\`, which prints the \`curl\`
387370
+ and the \`DELETE\` to run before the next run.
387371
+
387372
+ It refuses a run with no diagnosis, since the report's cluster views would be
387373
+ empty. Run \`eval.py diagnose\` first; when this run skipped diagnosis on purpose,
387374
+ pass \`--without-diagnosis\` and say in the report that it has no clusters.
387375
+
387376
+ It gives you two things to link:
387550
387377
 
387551
387378
  | Artifact | What it is | URL |
387552
387379
  |---|---|---|
@@ -395391,7 +395218,7 @@ function initializeMcpServer(environmentStore) {
395391
395218
  // src/mcp_config.ts
395392
395219
  init_config();
395393
395220
  init_logger();
395394
- import * as fs11 from "fs";
395221
+ import * as fs12 from "fs";
395395
395222
  import * as os4 from "os";
395396
395223
  import * as path15 from "path";
395397
395224
  var MCP_CONFIG_FILENAME = ".mcp.json";
@@ -395419,7 +395246,7 @@ function mcpEndpoint(host, port) {
395419
395246
  function findGitWorkTreeRoot(dir) {
395420
395247
  let current = path15.resolve(dir);
395421
395248
  for (;; ) {
395422
- if (fs11.existsSync(path15.join(current, ".git")))
395249
+ if (fs12.existsSync(path15.join(current, ".git")))
395423
395250
  return current;
395424
395251
  const parent = path15.dirname(current);
395425
395252
  if (parent === current)
@@ -395429,10 +395256,10 @@ function findGitWorkTreeRoot(dir) {
395429
395256
  }
395430
395257
  function conflictingMalloyUrl(file, endpoint) {
395431
395258
  try {
395432
- const stats = fs11.lstatSync(file);
395259
+ const stats = fs12.lstatSync(file);
395433
395260
  if (!stats.isFile() || stats.size > 64 * 1024)
395434
395261
  return;
395435
- const parsed = JSON.parse(fs11.readFileSync(file, "utf8"));
395262
+ const parsed = JSON.parse(fs12.readFileSync(file, "utf8"));
395436
395263
  const url2 = parsed?.mcpServers?.malloy?.url;
395437
395264
  return typeof url2 === "string" && url2 !== endpoint ? url2 : undefined;
395438
395265
  } catch {
@@ -395450,7 +395277,7 @@ function ensureMcpConfig(options) {
395450
395277
  try {
395451
395278
  return {
395452
395279
  path: file,
395453
- isSymlink: fs11.lstatSync(file).isSymbolicLink()
395280
+ isSymlink: fs12.lstatSync(file).isSymbolicLink()
395454
395281
  };
395455
395282
  } catch {
395456
395283
  return;
@@ -395469,7 +395296,7 @@ function ensureMcpConfig(options) {
395469
395296
  }
395470
395297
  const realish = (p2) => {
395471
395298
  try {
395472
- return fs11.realpathSync(p2);
395299
+ return fs12.realpathSync(p2);
395473
395300
  } catch {
395474
395301
  return path15.resolve(p2);
395475
395302
  }
@@ -395487,7 +395314,7 @@ function ensureMcpConfig(options) {
395487
395314
  action: "skipped-git",
395488
395315
  dir,
395489
395316
  gitRoot,
395490
- rootConfig: fs11.existsSync(rootCandidate) ? rootCandidate : undefined,
395317
+ rootConfig: fs12.existsSync(rootCandidate) ? rootCandidate : undefined,
395491
395318
  endpoint,
395492
395319
  staleConfig
395493
395320
  };
@@ -395496,7 +395323,7 @@ function ensureMcpConfig(options) {
395496
395323
  return { action: "exists", file, endpoint };
395497
395324
  const body = JSON.stringify({ mcpServers: { malloy: malloyServer(endpoint) } }, null, 2) + `
395498
395325
  `;
395499
- fs11.writeFileSync(file, body, { encoding: "utf8", flag: "wx" });
395326
+ fs12.writeFileSync(file, body, { encoding: "utf8", flag: "wx" });
395500
395327
  return { action: "created", file };
395501
395328
  } catch (error2) {
395502
395329
  const code = error2?.code;
@@ -400154,11 +399981,11 @@ function stripNonTagText(input) {
400154
399981
  return current;
400155
399982
  }
400156
399983
  async function listPackageDataApps(environmentName, packageName, publicRoot) {
400157
- const fs12 = await import("fs/promises");
399984
+ const fs13 = await import("fs/promises");
400158
399985
  const out = [];
400159
399986
  let realPublicRoot;
400160
399987
  try {
400161
- realPublicRoot = await fs12.realpath(publicRoot);
399988
+ realPublicRoot = await fs13.realpath(publicRoot);
400162
399989
  } catch {
400163
399990
  return out;
400164
399991
  }
@@ -400167,7 +399994,7 @@ async function listPackageDataApps(environmentName, packageName, publicRoot) {
400167
399994
  return;
400168
399995
  let entries;
400169
399996
  try {
400170
- entries = await fs12.readdir(dir, { withFileTypes: true });
399997
+ entries = await fs13.readdir(dir, { withFileTypes: true });
400171
399998
  } catch {
400172
399999
  return;
400173
400000
  }
@@ -400177,7 +400004,7 @@ async function listPackageDataApps(environmentName, packageName, publicRoot) {
400177
400004
  const full = path17.join(dir, entry.name);
400178
400005
  let realFull;
400179
400006
  try {
400180
- realFull = await fs12.realpath(full);
400007
+ realFull = await fs13.realpath(full);
400181
400008
  } catch {
400182
400009
  continue;
400183
400010
  }
@@ -400191,7 +400018,7 @@ async function listPackageDataApps(environmentName, packageName, publicRoot) {
400191
400018
  let title = rel;
400192
400019
  let fit;
400193
400020
  try {
400194
- const fh = await fs12.open(full, "r");
400021
+ const fh = await fs13.open(full, "r");
400195
400022
  try {
400196
400023
  const buf = Buffer.alloc(4096);
400197
400024
  const { bytesRead } = await fh.read(buf, 0, 4096, 0);