@sanity/ailf 6.1.0 → 6.1.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.
@@ -200,6 +200,23 @@ export interface ArtifactDescriptor<TEntry = unknown, TPreview = unknown> {
200
200
  * descriptors. `entryKey` is ignored on versioned bulk paths.
201
201
  */
202
202
  readonly objectPath: ArtifactObjectPath;
203
+ /**
204
+ * Extract the positional args (beyond `runId`) to pass to `objectPath`
205
+ * when this descriptor needs more than `runId` to build its path.
206
+ *
207
+ * The default plain-bulk path is `objectPath(runId)`. The default
208
+ * per-entry path is `objectPath(runId, entryKey)`. Descriptors whose
209
+ * path needs additional axes from `association` and/or fields from the
210
+ * payload (e.g. the bulk-versioned-with-report-axis carve-out used by
211
+ * the diagnosis descriptor — see `BULK_VERSIONED_WITH_REPORT_AXIS`)
212
+ * set this function. The writer calls it and spreads the result:
213
+ * `objectPath(runId, ...extractPathArgs(association, payload))`.
214
+ *
215
+ * Returning `[undefined]` (or any `undefined` element) is fine — the
216
+ * underlying builder is expected to throw a meaningful error in that
217
+ * case, which the writer surfaces via its existing try/catch.
218
+ */
219
+ readonly extractPathArgs?: (association: AssociationValues, payload: unknown) => readonly (string | undefined)[];
203
220
  /**
204
221
  * Build a filename-safe entry key from association values. Only meaningful
205
222
  * for `layout === "per-entry"` — bulk descriptors omit it.
@@ -674,6 +674,7 @@ function buildDescriptor(input) {
674
674
  versionedBy: input.versionedBy,
675
675
  pathSafetyMarker: input.pathSafetyMarker,
676
676
  objectPath,
677
+ extractPathArgs: input.extractPathArgs,
677
678
  formatEntryKey,
678
679
  parseEntryKey,
679
680
  manifestPreview: input.manifestPreview,
@@ -1185,6 +1186,19 @@ export const ARTIFACT_REGISTRY = {
1185
1186
  writePolicy: "post-hoc",
1186
1187
  versionedBy: "diagnosisVersion",
1187
1188
  objectPath: diagnosisPathBuilder(),
1189
+ // The diagnosis path builder takes (runId, reportId, version). The
1190
+ // reportId comes from association.report; the version is a compound
1191
+ // `diagnosisVersion|cardVersion` synthesized from the payload's
1192
+ // `inputs` field via `encodeDiagnosisPathVersion`. Writers spread
1193
+ // this beyond `runId` when calling `objectPath`.
1194
+ extractPathArgs: (assoc, payload) => {
1195
+ const reportId = typeof assoc.report === "string" ? assoc.report : undefined;
1196
+ const diag = payload;
1197
+ const dv = diag?.inputs?.diagnosisVersion;
1198
+ const cv = diag?.inputs?.cardVersion;
1199
+ const version = dv && cv ? encodeDiagnosisPathVersion(dv, cv) : undefined;
1200
+ return [reportId, version];
1201
+ },
1188
1202
  // Defense-in-depth: this descriptor's axes (`run`, `report`) are both
1189
1203
  // bounded, so the `assertValidArtifactDescriptor` unbounded-axis rule
1190
1204
  // does not fire and the carve-out is never consulted at module load
@@ -343,6 +343,16 @@ export interface ReportStorePort {
343
343
  read(id: string): Promise<null | unknown>;
344
344
  /** Patch synthesis telemetry onto a published report (Phase 6 / DIAG-06). */
345
345
  patchSynthesis(id: string, telemetry: unknown): Promise<void>;
346
+ /**
347
+ * Patch a single artifact-manifest entry onto a published report.
348
+ *
349
+ * Used by deferred commands (e.g. `ailf interpret`) whose post-hoc writer
350
+ * produces a new ArtifactRef after the doc was already published. The
351
+ * pipeline path lifts the full manifest at publish time
352
+ * (publish-report-step); this is the post-hoc equivalent for one slot.
353
+ * Non-fatal on Sanity failure — mirrors `patchSynthesis`.
354
+ */
355
+ patchArtifactManifest(id: string, slot: string, ref: unknown): Promise<void>;
346
356
  }
347
357
  /**
348
358
  * Minimal report sink interface used by AppContext.
@@ -27,7 +27,7 @@
27
27
  * @see docs/decisions/D0032-run-anchored-artifact-store.md
28
28
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md
29
29
  */
30
- import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
30
+ import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type AssociationValues, type RunId, type RunManifest, type WriteSource } from "../_vendor/ailf-core/index.d.ts";
31
31
  import { type UploadMetricsSink } from "./upload-metrics.js";
32
32
  export interface ApiGatewayArtifactWriterOptions {
33
33
  /** Base URL of the API gateway (e.g., "https://ailf-api.sanity.build"). */
@@ -41,10 +41,20 @@ export interface ApiGatewayArtifactWriterOptions {
41
41
  * Defaults to a no-op so the hot path stays free when metrics are off.
42
42
  */
43
43
  metrics?: UploadMetricsSink;
44
+ /**
45
+ * Identifies what kind of execution context owns this writer instance.
46
+ * The D0050 guard refuses to emit a descriptor whose `writePolicy` is
47
+ * different from this value. `"pipeline"` (the default) is for writers
48
+ * driven by an `ailf run` pipeline; `"post-hoc"` is for writers driven
49
+ * by deferred commands like `ailf interpret` that emit descriptors
50
+ * declared `writePolicy: "post-hoc"` (D0050).
51
+ */
52
+ writerSource?: WriteSource;
44
53
  }
45
54
  export declare class ApiGatewayArtifactWriter implements ArtifactWriter {
46
55
  private readonly options;
47
56
  private readonly metrics;
57
+ private readonly writerSource;
48
58
  constructor(options: ApiGatewayArtifactWriterOptions);
49
59
  emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
50
60
  appendNdjson(): Promise<ArtifactRef | null>;
@@ -33,14 +33,16 @@ import { NO_OP_UPLOAD_METRICS, } from "./upload-metrics.js";
33
33
  export class ApiGatewayArtifactWriter {
34
34
  options;
35
35
  metrics;
36
+ writerSource;
36
37
  constructor(options) {
37
38
  this.options = options;
38
39
  this.metrics = options.metrics ?? NO_OP_UPLOAD_METRICS;
40
+ this.writerSource = options.writerSource ?? "pipeline";
39
41
  }
40
42
  // ---- Canonical W0049 API ------------------------------------------------
41
43
  async emit(type, association, payload) {
42
44
  const descriptor = ARTIFACT_REGISTRY[type];
43
- assertWritePolicyMatches("pipeline", descriptor);
45
+ assertWritePolicyMatches(this.writerSource, descriptor);
44
46
  const runId = association.run;
45
47
  if (!runId) {
46
48
  console.warn(` ⚠️ emit("${type}"): association.run is required, skipping`);
@@ -25,7 +25,7 @@
25
25
  * does this writer. Traces flow through the GCS-direct writer when ADC
26
26
  * credentials are present.
27
27
  */
28
- import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
28
+ import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type AssociationValues, type RunId, type RunManifest, type WriteSource } from "../_vendor/ailf-core/index.d.ts";
29
29
  import { type UploadMetricsSink } from "./upload-metrics.js";
30
30
  export interface BatchingApiGatewayArtifactWriterOptions {
31
31
  /** Base URL of the API gateway (e.g., "https://ailf-api.sanity.build"). */
@@ -46,12 +46,22 @@ export interface BatchingApiGatewayArtifactWriterOptions {
46
46
  putConcurrency?: number;
47
47
  /** Optional metrics sink; defaults to no-op. */
48
48
  metrics?: UploadMetricsSink;
49
+ /**
50
+ * Identifies what kind of execution context owns this writer instance.
51
+ * The D0050 guard refuses to emit a descriptor whose `writePolicy` is
52
+ * different from this value. `"pipeline"` (the default) is for writers
53
+ * driven by an `ailf run` pipeline; `"post-hoc"` is for writers driven
54
+ * by deferred commands like `ailf interpret` that emit descriptors
55
+ * declared `writePolicy: "post-hoc"` (D0050).
56
+ */
57
+ writerSource?: WriteSource;
49
58
  }
50
59
  export declare class BatchingApiGatewayArtifactWriter implements ArtifactWriter {
51
60
  private readonly options;
52
61
  private readonly pending;
53
62
  private flushing;
54
63
  private microtaskScheduled;
64
+ private readonly writerSource;
55
65
  constructor(options: BatchingApiGatewayArtifactWriterOptions);
56
66
  emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
57
67
  appendNdjson(): Promise<ArtifactRef | null>;
@@ -51,6 +51,7 @@ export class BatchingApiGatewayArtifactWriter {
51
51
  pending = [];
52
52
  flushing = null;
53
53
  microtaskScheduled = false;
54
+ writerSource;
54
55
  constructor(options) {
55
56
  this.options = {
56
57
  apiBaseUrl: options.apiBaseUrl,
@@ -60,11 +61,12 @@ export class BatchingApiGatewayArtifactWriter {
60
61
  putConcurrency: options.putConcurrency ?? DEFAULT_PUT_CONCURRENCY,
61
62
  metrics: options.metrics ?? NO_OP_UPLOAD_METRICS,
62
63
  };
64
+ this.writerSource = options.writerSource ?? "pipeline";
63
65
  }
64
66
  // ---- ArtifactWriter surface --------------------------------------------
65
67
  async emit(type, association, payload) {
66
68
  const descriptor = ARTIFACT_REGISTRY[type];
67
- assertWritePolicyMatches("pipeline", descriptor);
69
+ assertWritePolicyMatches(this.writerSource, descriptor);
68
70
  const runId = association.run;
69
71
  if (!runId) {
70
72
  console.warn(` ⚠️ emit("${type}"): association.run is required, skipping`);
@@ -28,7 +28,7 @@
28
28
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md
29
29
  */
30
30
  import { Storage } from "@google-cloud/storage";
31
- import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type ArtifactWriterProgressOptions, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
31
+ import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type ArtifactWriterProgressOptions, type AssociationValues, type RunId, type RunManifest, type WriteSource } from "../_vendor/ailf-core/index.d.ts";
32
32
  import { type UploadMetricsSink } from "./upload-metrics.js";
33
33
  export interface GcsArtifactWriterOptions {
34
34
  /** GCS bucket name (e.g., "ailf-artifacts") */
@@ -51,6 +51,15 @@ export interface GcsArtifactWriterOptions {
51
51
  * Defaults to a no-op so the hot path stays free when metrics are off.
52
52
  */
53
53
  metrics?: UploadMetricsSink;
54
+ /**
55
+ * Identifies what kind of execution context owns this writer instance.
56
+ * The D0050 guard refuses to emit a descriptor whose `writePolicy` is
57
+ * different from this value. `"pipeline"` (the default) is for writers
58
+ * driven by an `ailf run` pipeline; `"post-hoc"` is for writers driven
59
+ * by deferred commands like `ailf interpret` that emit descriptors
60
+ * declared `writePolicy: "post-hoc"` (D0050).
61
+ */
62
+ writerSource?: WriteSource;
54
63
  }
55
64
  export declare class GcsArtifactWriter implements ArtifactWriter {
56
65
  private client;
@@ -68,6 +77,7 @@ export declare class GcsArtifactWriter implements ArtifactWriter {
68
77
  * measured safe, without forcing the producer to own that knob.
69
78
  */
70
79
  private readonly limiter;
80
+ private readonly writerSource;
71
81
  constructor(options: GcsArtifactWriterOptions);
72
82
  private reportProgress;
73
83
  emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
@@ -57,9 +57,11 @@ export class GcsArtifactWriter {
57
57
  * measured safe, without forcing the producer to own that knob.
58
58
  */
59
59
  limiter;
60
+ writerSource;
60
61
  constructor(options) {
61
62
  this.options = options;
62
63
  this.metrics = options.metrics ?? NO_OP_UPLOAD_METRICS;
64
+ this.writerSource = options.writerSource ?? "pipeline";
63
65
  if (options.storage) {
64
66
  this.client = options.storage;
65
67
  }
@@ -79,7 +81,7 @@ export class GcsArtifactWriter {
79
81
  // ---- Canonical W0049 API ------------------------------------------------
80
82
  async emit(type, association, payload) {
81
83
  const descriptor = ARTIFACT_REGISTRY[type];
82
- assertWritePolicyMatches("pipeline", descriptor);
84
+ assertWritePolicyMatches(this.writerSource, descriptor);
83
85
  const runId = association.run;
84
86
  if (!runId) {
85
87
  console.warn(` ⚠️ emit("${type}"): association.run is required, skipping`);
@@ -92,7 +94,8 @@ export class GcsArtifactWriter {
92
94
  const { body } = prepareUploadBody(payload, descriptor.mime);
93
95
  const preview = buildManifestPreview(descriptor, payload);
94
96
  if (descriptor.layout === "bulk") {
95
- const path = descriptor.objectPath(runId);
97
+ const extra = descriptor.extractPathArgs?.(association, payload) ?? [];
98
+ const path = descriptor.objectPath(runId, ...extra);
96
99
  const ref = await this.putBody(path, body, {
97
100
  layout: "bulk",
98
101
  mime: descriptor.mime,
@@ -133,7 +136,7 @@ export class GcsArtifactWriter {
133
136
  }
134
137
  async appendNdjson(type, association, rows) {
135
138
  const descriptor = ARTIFACT_REGISTRY[type];
136
- assertWritePolicyMatches("pipeline", descriptor);
139
+ assertWritePolicyMatches(this.writerSource, descriptor);
137
140
  if (descriptor.mime !== "application/x-ndjson") {
138
141
  console.warn(` ⚠️ appendNdjson("${type}"): descriptor mime is ${descriptor.mime}, not application/x-ndjson — skipping`);
139
142
  return null;
@@ -36,7 +36,7 @@
36
36
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md (§ M4)
37
37
  * @see packages/eval/src/artifact-capture/gcs-artifact-writer.ts (mirror)
38
38
  */
39
- import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type ArtifactWriterProgressOptions, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
39
+ import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type ArtifactWriterProgressOptions, type AssociationValues, type RunId, type RunManifest, type WriteSource } from "../_vendor/ailf-core/index.d.ts";
40
40
  export interface LocalFilesystemArtifactWriterOptions {
41
41
  /**
42
42
  * Absolute or cwd-relative root directory under which `runs/{runId}/…`
@@ -56,10 +56,20 @@ export interface LocalFilesystemArtifactWriterOptions {
56
56
  * render per-batch updates during long export phases.
57
57
  */
58
58
  progress?: ArtifactWriterProgressOptions;
59
+ /**
60
+ * Identifies what kind of execution context owns this writer instance.
61
+ * The D0050 guard refuses to emit a descriptor whose `writePolicy` is
62
+ * different from this value. `"pipeline"` (the default) is for writers
63
+ * driven by an `ailf run` pipeline; `"post-hoc"` is for writers driven
64
+ * by deferred commands like `ailf interpret` that emit descriptors
65
+ * declared `writePolicy: "post-hoc"` (D0050).
66
+ */
67
+ writerSource?: WriteSource;
59
68
  }
60
69
  export declare class LocalFilesystemArtifactWriter implements ArtifactWriter {
61
70
  private readonly options;
62
71
  private readonly excludeSet;
72
+ private readonly writerSource;
63
73
  constructor(options: LocalFilesystemArtifactWriterOptions);
64
74
  private reportProgress;
65
75
  emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
@@ -46,9 +46,11 @@ import { redactArtifactData } from "./redact-artifact.js";
46
46
  export class LocalFilesystemArtifactWriter {
47
47
  options;
48
48
  excludeSet;
49
+ writerSource;
49
50
  constructor(options) {
50
51
  this.options = options;
51
52
  this.excludeSet = new Set(options.exclude ?? []);
53
+ this.writerSource = options.writerSource ?? "pipeline";
52
54
  }
53
55
  reportProgress(ref) {
54
56
  const progress = this.options.progress;
@@ -66,7 +68,7 @@ export class LocalFilesystemArtifactWriter {
66
68
  if (this.excludeSet.has(type))
67
69
  return null;
68
70
  const descriptor = ARTIFACT_REGISTRY[type];
69
- assertWritePolicyMatches("pipeline", descriptor);
71
+ assertWritePolicyMatches(this.writerSource, descriptor);
70
72
  const runId = association.run;
71
73
  if (!runId) {
72
74
  console.warn(` ⚠️ emit("${type}"): association.run is required, skipping`);
@@ -81,7 +83,8 @@ export class LocalFilesystemArtifactWriter {
81
83
  // descriptor's capBytes.
82
84
  const preview = buildManifestPreview(descriptor, payload);
83
85
  if (descriptor.layout === "bulk") {
84
- const relPath = descriptor.objectPath(runId);
86
+ const extra = descriptor.extractPathArgs?.(association, payload) ?? [];
87
+ const relPath = descriptor.objectPath(runId, ...extra);
85
88
  const absPath = this.resolve(relPath);
86
89
  const wrote = await this.writeAtomic(absPath, body);
87
90
  if (!wrote)
@@ -128,7 +131,7 @@ export class LocalFilesystemArtifactWriter {
128
131
  if (this.excludeSet.has(type))
129
132
  return null;
130
133
  const descriptor = ARTIFACT_REGISTRY[type];
131
- assertWritePolicyMatches("pipeline", descriptor);
134
+ assertWritePolicyMatches(this.writerSource, descriptor);
132
135
  if (descriptor.mime !== "application/x-ndjson") {
133
136
  console.warn(` ⚠️ appendNdjson("${type}"): descriptor mime is ${descriptor.mime}, not application/x-ndjson — skipping`);
134
137
  return null;
@@ -15,7 +15,7 @@
15
15
  * @see packages/core/src/ports/context.ts — AppContext interface
16
16
  * @see docs/archive/exec-plans/ports-and-adapters/phase-7-composition-root.md
17
17
  */
18
- import { type AppContext, type ArtifactWriter, type ArtifactWriterProgressOptions, type AssertionRegistration, type CardRegistry, type DiagnosisRunner, type Logger, type ResolvedConfig } from "./_vendor/ailf-core/index.d.ts";
18
+ import { type AppContext, type ArtifactWriter, type ArtifactWriterProgressOptions, type AssertionRegistration, type CardRegistry, type DiagnosisRunner, type Logger, type ResolvedConfig, type WriteSource } from "./_vendor/ailf-core/index.d.ts";
19
19
  export type { LLMClientKeys } from "./_vendor/ailf-core/index.d.ts";
20
20
  import { type BorderlineConsensusOptions, type BorderlineConsensusResult } from "./pipeline/borderline-consensus-runner.js";
21
21
  import { CompositeTaskSource, ContentLakeTaskSource, RepoTaskSource } from "./adapters/task-sources/index.js";
@@ -44,7 +44,7 @@ export declare function createAppContext(config: ResolvedConfig): AppContext;
44
44
  *
45
45
  * Exported for unit-test access; not part of the public package API.
46
46
  */
47
- export declare function createArtifactWriter(config: ResolvedConfig, logger: Logger, progress?: ArtifactWriterProgressOptions): ArtifactWriter;
47
+ export declare function createArtifactWriter(config: ResolvedConfig, logger: Logger, progress?: ArtifactWriterProgressOptions, writerSource?: WriteSource): ArtifactWriter;
48
48
  /**
49
49
  * Build the `TaskSource` adapter wired by the composition root for a
50
50
  * given `ResolvedConfig`. Exported for test access — composition-root
@@ -110,10 +110,27 @@ export declare function buildDiagnosisRegistry(): CardRegistry;
110
110
  *
111
111
  * Wires the full 8-card registry, `loadAttributions` bound to the local
112
112
  * filesystem (Phase-4 per-entry attribution objects at
113
- * `{artifactsDir}/runs/{runId}/attribution/*.json`), and no-op cache
114
- * reader/writer (Plan-06 CLI command will wire the real cache seam).
113
+ * `{artifactsDir}/runs/{runId}/attribution/*.json`), and the real
114
+ * `diagnosisWriter` that emits the Diagnosis through a post-hoc-flagged
115
+ * artifact writer AND patches the report doc's
116
+ * `summary.artifactManifest.diagnosis` slot. Two steps because `ailf interpret`
117
+ * runs after the report doc has already been published — the pipeline path's
118
+ * publish-report-step.ts:187 lifts the in-memory run manifest into the doc at
119
+ * end-of-run, but that step never fires for a deferred command.
120
+ *
121
+ * The post-hoc writer is built with `writerSource: "post-hoc"` so the D0050
122
+ * guard accepts the diagnosis descriptor (`writePolicy: "post-hoc"`). Without
123
+ * this, every emit would be rejected at runtime.
124
+ *
125
+ * `diagnosisReader` is still a no-op shim: the Studio data path uses the
126
+ * artifact-manifest entry (populated by the writer + patch) plus a signed-URL
127
+ * fetch, so reader-side cache wiring is deferred to a follow-up W-item.
128
+ * Without the reader, `ailf interpret --refresh` cache hits are not yet served
129
+ * from GCS — they recompute.
115
130
  *
116
131
  * Plan-06 API/CLI consumers import this function from the composition root
117
132
  * and pass `ctx` from `createAppContext(config)`.
118
133
  */
119
- export declare function getDiagnosisRunner(ctx: AppContext): DiagnosisRunner;
134
+ export declare function getDiagnosisRunner(ctx: AppContext, opts?: {
135
+ artifactWriter?: ArtifactWriter;
136
+ }): DiagnosisRunner;
@@ -192,7 +192,7 @@ const DEFAULT_LOCAL_ARTIFACTS_DIR = ".ailf/results/captures";
192
192
  *
193
193
  * Exported for unit-test access; not part of the public package API.
194
194
  */
195
- export function createArtifactWriter(config, logger, progress) {
195
+ export function createArtifactWriter(config, logger, progress, writerSource = "pipeline") {
196
196
  // Legacy `artifactUpload: false` still disables — treat as an alias for
197
197
  // the canonical `artifactsDisabled: true` until W0052 removes it.
198
198
  if (config.artifactsDisabled === true || config.artifactUpload === false) {
@@ -214,10 +214,11 @@ export function createArtifactWriter(config, logger, progress) {
214
214
  // W0053: progress attaches to the OUTERMOST of (local-only | fanout). When
215
215
  // fanout is wired, the delegates stay silent so we don't double-count the
216
216
  // same caller-visible write across two backends.
217
- const remote = createRemoteArtifactWriter(config, logger, metrics);
217
+ const remote = createRemoteArtifactWriter(config, logger, metrics, writerSource);
218
218
  const local = new LocalFilesystemArtifactWriter({
219
219
  rootDir,
220
220
  exclude,
221
+ writerSource,
221
222
  ...(remote ? {} : { progress }),
222
223
  });
223
224
  // W0064 — when a remote backend is wired, list it first so its ArtifactRef
@@ -267,7 +268,7 @@ function resolveExcludeList(raw, logger) {
267
268
  * the sole backend for that run, which is the D0033 M4 default for laptops
268
269
  * and CI without GCS creds.
269
270
  */
270
- function createRemoteArtifactWriter(config, logger, metrics) {
271
+ function createRemoteArtifactWriter(config, logger, metrics, writerSource = "pipeline") {
271
272
  const bucket = config.artifactGcsBucket ?? DEFAULT_ARTIFACT_BUCKET;
272
273
  const hasGcsCredentials = Boolean(process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GCLOUD_PROJECT);
273
274
  if (hasGcsCredentials) {
@@ -279,6 +280,7 @@ function createRemoteArtifactWriter(config, logger, metrics) {
279
280
  logger.debug(`Artifact remote backend: GcsArtifactWriter (ADC, bucket=${bucket}, defaultConcurrency=8)`);
280
281
  return new GcsArtifactWriter({
281
282
  bucket,
283
+ writerSource,
282
284
  ...(metrics ? { metrics } : {}),
283
285
  });
284
286
  }
@@ -306,6 +308,7 @@ function createRemoteArtifactWriter(config, logger, metrics) {
306
308
  apiKey: config.apiKey,
307
309
  bucket,
308
310
  putConcurrency: concurrency,
311
+ writerSource,
309
312
  ...(metrics ? { metrics } : {}),
310
313
  });
311
314
  }
@@ -314,6 +317,7 @@ function createRemoteArtifactWriter(config, logger, metrics) {
314
317
  apiBaseUrl: config.apiUrl,
315
318
  apiKey: config.apiKey,
316
319
  bucket,
320
+ writerSource,
317
321
  ...(metrics ? { metrics } : {}),
318
322
  });
319
323
  }
@@ -585,17 +589,83 @@ async function loadAttributionsFromLocalFs(runId, artifactsDir, logger) {
585
589
  *
586
590
  * Wires the full 8-card registry, `loadAttributions` bound to the local
587
591
  * filesystem (Phase-4 per-entry attribution objects at
588
- * `{artifactsDir}/runs/{runId}/attribution/*.json`), and no-op cache
589
- * reader/writer (Plan-06 CLI command will wire the real cache seam).
592
+ * `{artifactsDir}/runs/{runId}/attribution/*.json`), and the real
593
+ * `diagnosisWriter` that emits the Diagnosis through a post-hoc-flagged
594
+ * artifact writer AND patches the report doc's
595
+ * `summary.artifactManifest.diagnosis` slot. Two steps because `ailf interpret`
596
+ * runs after the report doc has already been published — the pipeline path's
597
+ * publish-report-step.ts:187 lifts the in-memory run manifest into the doc at
598
+ * end-of-run, but that step never fires for a deferred command.
599
+ *
600
+ * The post-hoc writer is built with `writerSource: "post-hoc"` so the D0050
601
+ * guard accepts the diagnosis descriptor (`writePolicy: "post-hoc"`). Without
602
+ * this, every emit would be rejected at runtime.
603
+ *
604
+ * `diagnosisReader` is still a no-op shim: the Studio data path uses the
605
+ * artifact-manifest entry (populated by the writer + patch) plus a signed-URL
606
+ * fetch, so reader-side cache wiring is deferred to a follow-up W-item.
607
+ * Without the reader, `ailf interpret --refresh` cache hits are not yet served
608
+ * from GCS — they recompute.
590
609
  *
591
610
  * Plan-06 API/CLI consumers import this function from the composition root
592
611
  * and pass `ctx` from `createAppContext(config)`.
593
612
  */
594
- export function getDiagnosisRunner(ctx) {
613
+ export function getDiagnosisRunner(ctx, opts) {
595
614
  const artifactsDir = ctx.config.artifactsDir ?? DIAGNOSIS_LOCAL_ARTIFACTS_DIR;
596
- // No-op cache shimsPlan 06 wires the real cache.
615
+ // Post-hoc artifact writerbuilt with the same fanout/remote/local layering
616
+ // as the pipeline writer but flagged so the D0050 guard accepts post-hoc
617
+ // descriptors. Construction is per-runner so the AccumulatingArtifactWriter's
618
+ // internal manifest doesn't carry state between unrelated interpret runs.
619
+ // Tests inject their own writer via opts.artifactWriter; the production
620
+ // CLI / pipeline callers never pass it.
621
+ const postHocArtifactWriter = opts?.artifactWriter ??
622
+ createArtifactWriter(ctx.config, ctx.logger, undefined, "post-hoc");
623
+ // No-op reader — see JSDoc above. The Studio data path is manifest-driven,
624
+ // not reader-driven, so the writer + patch alone unblock Phase 7.
597
625
  const diagnosisReader = async (_path) => null;
598
- const diagnosisWriter = async (_path, _diagnosis) => { };
626
+ // Real writer two-step persistence:
627
+ // 1. Emit the diagnosis payload through the post-hoc writer; the descriptor's
628
+ // `objectPath: diagnosisPathBuilder()` derives the storage path from
629
+ // `{runId, reportId, compoundVersion}`.
630
+ // 2. Patch the published report doc's `summary.artifactManifest.diagnosis`
631
+ // slot with the returned ArtifactRef, so Studio's slim-shape GROQ
632
+ // projection surfaces the entry. (The pipeline path runs this lift via
633
+ // publish-report-step.ts; that step never fires for a deferred command,
634
+ // hence the explicit patch here.)
635
+ //
636
+ // Errors are caught and logged rather than thrown — the diagnosis runner
637
+ // separates "compute" from "persist". Failed persistence should not panic
638
+ // the runner; the computed cards still surface to API/CLI callers in-memory.
639
+ // ReportStore.patchArtifactManifest is itself non-fatal on Sanity failure,
640
+ // so it does not need its own try/catch.
641
+ const diagnosisWriter = async (_descriptorPath, diagnosis) => {
642
+ let ref;
643
+ try {
644
+ // Anchor the diagnosis to the REPORT's run, not the post-hoc CLI's
645
+ // session run. `ctx.runId` is freshly generated per interpret
646
+ // invocation; the report doc's `provenance.runId` is what Studio
647
+ // and the signing endpoint look up. Using `assoc(ctx, ...)` would
648
+ // bind `run` to ctx.runId — the path would be writeable but
649
+ // unreachable from the Studio side.
650
+ ref = await postHocArtifactWriter.emit("diagnosis", { run: diagnosis.runId, report: diagnosis.reportId }, diagnosis);
651
+ }
652
+ catch (error) {
653
+ ctx.logger.warn("diagnosis-emit-failed", {
654
+ reportId: diagnosis.reportId,
655
+ error: error instanceof Error ? error.message : String(error),
656
+ });
657
+ return;
658
+ }
659
+ if (!ref)
660
+ return;
661
+ if (!ctx.reportStore) {
662
+ ctx.logger.warn("diagnosis-emit: no reportStore on context", {
663
+ reportId: diagnosis.reportId,
664
+ });
665
+ return;
666
+ }
667
+ await ctx.reportStore.patchArtifactManifest(diagnosis.reportId, "diagnosis", ref);
668
+ };
599
669
  return createDiagnosisRunner({
600
670
  llm: ctx.llmClient,
601
671
  model: modelId("anthropic:claude-opus-4-6"),
@@ -215,7 +215,6 @@ export class GapAnalysisStep {
215
215
  ...(documentManifest !== undefined && { documentManifest }),
216
216
  failureModes: failureModeReport,
217
217
  lowScoringJudgments,
218
- recommendations: gapReport,
219
218
  scores: enrichedScores,
220
219
  ...(testResults !== undefined && { testResults }),
221
220
  };
@@ -15,7 +15,7 @@
15
15
  * @see docs/design-docs/report-store/domain-model.md
16
16
  */
17
17
  import type { SanityClient } from "@sanity/client";
18
- import type { SynthesisCostTelemetry } from "./_vendor/ailf-core/index.d.ts";
18
+ import type { ArtifactRef, ArtifactType, SynthesisCostTelemetry } from "./_vendor/ailf-core/index.d.ts";
19
19
  import type { ComparisonReport, ISOTimestamp, LineageQuery, Report, ReportId, ReportProvenance, ScoreSummary } from "./pipeline/types.js";
20
20
  /**
21
21
  * Result of an auto-comparison, bundling the ComparisonReport with the
@@ -134,6 +134,20 @@ export declare class ReportStore {
134
134
  * Document _id is `report-${reportId}` (see `toSanityReportDoc` L559).
135
135
  */
136
136
  patchSynthesis(reportId: ReportId, telemetry: SynthesisCostTelemetry): Promise<void>;
137
+ /**
138
+ * Patch a single artifact-manifest entry onto a published report.
139
+ *
140
+ * Used by deferred commands like `ailf interpret` whose post-hoc writer
141
+ * produces a new ArtifactRef *after* the report doc was published. The
142
+ * pipeline path lifts the full manifest into the doc at publish time
143
+ * (publish-report-step.ts:187); this method is the post-hoc equivalent
144
+ * for a single slot.
145
+ *
146
+ * Non-fatal on Sanity failure — mirrors `patchSynthesis` (L423).
147
+ *
148
+ * Document _id is `report-${reportId}` (see `toSanityReportDoc` L559).
149
+ */
150
+ patchArtifactManifest(reportId: ReportId, slot: ArtifactType, ref: ArtifactRef): Promise<void>;
137
151
  /**
138
152
  * Query error arrays from the last N reports for chronic failure detection.
139
153
  *
@@ -327,6 +327,31 @@ export class ReportStore {
327
327
  console.warn(` ⚠️ Failed to patch synthesis telemetry on report ${reportId}: ${error instanceof Error ? error.message : String(error)}`);
328
328
  }
329
329
  }
330
+ /**
331
+ * Patch a single artifact-manifest entry onto a published report.
332
+ *
333
+ * Used by deferred commands like `ailf interpret` whose post-hoc writer
334
+ * produces a new ArtifactRef *after* the report doc was published. The
335
+ * pipeline path lifts the full manifest into the doc at publish time
336
+ * (publish-report-step.ts:187); this method is the post-hoc equivalent
337
+ * for a single slot.
338
+ *
339
+ * Non-fatal on Sanity failure — mirrors `patchSynthesis` (L423).
340
+ *
341
+ * Document _id is `report-${reportId}` (see `toSanityReportDoc` L559).
342
+ */
343
+ async patchArtifactManifest(reportId, slot, ref) {
344
+ try {
345
+ await this.client
346
+ .patch(`report-${reportId}`)
347
+ .setIfMissing({ "summary.artifactManifest": {} })
348
+ .set({ [`summary.artifactManifest.${slot}`]: ref })
349
+ .commit();
350
+ }
351
+ catch (error) {
352
+ console.warn(` ⚠️ Failed to patch artifactManifest.${slot} on report ${reportId}: ${error instanceof Error ? error.message : String(error)}`);
353
+ }
354
+ }
330
355
  /**
331
356
  * Query error arrays from the last N reports for chronic failure detection.
332
357
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ailf",
3
- "version": "6.1.0",
3
+ "version": "6.1.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"