/**
 * Artifact registry — single source of truth for AILF's external artifact types.
 *
 * Every artifact that lives in GCS (or on the local filesystem after W0050)
 * declares itself here exactly once: association axes, layout, path builder,
 * entry schema, mime, cap, and (for per-entry layouts) format/parse helpers.
 * Eval writers, the API Gateway's signing endpoint, and the Studio hook all
 * consume this same record.
 *
 * Adding a new artifact type = one entry here. No call-site changes needed in
 * the generic writer / signer / hook — they all iterate the registry.
 *
 * ## Association axes (D0033 / W0049)
 *
 * Each descriptor declares the pipeline dimensions it is evidence about. At
 * module load a structural invariant rejects descriptors that declare an
 * unbounded axis (`task`, `model`, `trial`) but a `"bulk"` layout — such a
 * shape would serialize as a single JSON array that blows past the object-
 * size cap at scale. The invariant converts that class of mistake into a
 * process-won't-start error rather than a silent data bug.
 *
 * @see docs/decisions/D0032-run-anchored-artifact-store.md
 * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md
 * @see docs/design-docs/unified-run-artifacts.md (§ M1, § M5)
 */
import { z } from "zod";
/**
 * Opt-in marker for the bulk-versioned-with-report-axis carve-out
 * (Plan 01-03 / ITER2-BLOCKER-01). Any descriptor that needs to combine
 * `layout: "bulk"` with a `report` axis (e.g. the post-hoc diagnosis
 * artifact) must set
 * `pathSafetyMarker: BULK_VERSIONED_WITH_REPORT_AXIS` to opt in to the
 * carve-out in `assertValidArtifactDescriptor`.
 *
 * `Symbol.for(...)` is registry-keyed: it survives minification (the
 * key is a string literal that minifiers cannot rename) and yields
 * cross-module-instance equality by spec. This makes it bundler-stable
 * — a deliberate replacement for any source-text reflection on the
 * descriptor's `objectPath` closure body.
 *
 * The marker alone does NOT trigger the carve-out — the four structural
 * conditions (bulk + versionedBy + run+report axes) must also hold.
 * Conversely, the structural conditions alone do NOT trigger the
 * carve-out without the marker. This is an explicit opt-in.
 */
export const BULK_VERSIONED_WITH_REPORT_AXIS = Symbol.for("ailf.artifactRegistry.bulkVersionedWithReportAxis");
// ---------------------------------------------------------------------------
// Path + key helpers
// ---------------------------------------------------------------------------
/**
 * File extension per MIME. Kept in sync with the `ArtifactMime` union at the
 * type level — adding a new mime requires adding a case here, and the L1
 * contract test in slice 4 verifies every descriptor's path ends with the
 * correct extension for its mime.
 */
function mimeExtension(mime) {
    switch (mime) {
        case "application/json":
            return "json";
        case "application/x-ndjson":
            return "ndjson";
        case "text/markdown":
            return "md";
        case "application/yaml":
            return "yaml";
    }
}
function bulkPathBuilder(slug, mime) {
    const ext = mimeExtension(mime);
    return (runId) => `runs/${runId}/${slug}.${ext}`;
}
function perEntryPathBuilder(slug, mime) {
    const ext = mimeExtension(mime);
    return (runId, entryKey) => {
        if (entryKey === undefined) {
            throw new Error(`Artifact "${slug}" uses per-entry layout; an entry key is required`);
        }
        const sanitized = sanitizeEntryKey(entryKey);
        return `runs/${runId}/${slug}/${sanitized}.${ext}`;
    };
}
/**
 * Bulk-shaped path builder with a `{version}` segment appended to the
 * filename stem (D0050). Used by descriptors that opt into the versioning
 * axis via `versionedBy`. Multiple versions coexist under the same run
 * prefix as siblings:
 *
 *   `runs/{runId}/{slug}-v1.{ext}`
 *   `runs/{runId}/{slug}-v2.{ext}`
 *
 * The version segment is sanitized via `sanitizeEntryKey` so versions
 * containing `/` or wire separators don't accidentally nest into
 * subdirectories. Empty / whitespace-only versions are rejected — a
 * versioned descriptor with no version is a programmer error, not silently
 * collapsed to an unversioned path.
 */
export function versionedPathBuilder(slug, mime) {
    const ext = mimeExtension(mime);
    return (runId, _entryKey, version) => {
        if (version === undefined || version.trim() === "") {
            throw new Error(`Artifact "${slug}" uses versioned layout; a non-empty version is required`);
        }
        if (hasControlChars(version)) {
            throw new Error(`Artifact "${slug}" version must not contain control characters`);
        }
        const sanitized = sanitizeEntryKey(version);
        return `runs/${runId}/${slug}-${sanitized}.${ext}`;
    };
}
/**
 * Synchronous SHA-256 over a UTF-8-encoded string returning a lowercase
 * hex digest. Implemented inline because `@sanity/ailf-core` is the
 * domain kernel — it intentionally avoids a `@types/node` dependency
 * (matching the existing `byteLengthUtf8` rationale below) and the
 * Web Crypto `crypto.subtle.digest` API is async-only, which would
 * force `ArtifactObjectPath` to become async.
 *
 * Used only for the diagnosis descriptor's `cardSetShortHash` (the
 * filename suffix) — collision resistance, not authentication, is the
 * relevant property. FIPS 180-4 reference implementation.
 */
function sha256Hex(input) {
    const K = [
        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
        0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
        0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
        0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
        0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
        0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
        0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
        0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
        0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
        0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
    ];
    // UTF-8 encode (without TextEncoder reliance — kernel runs in both Node
    // and the browser; both have TextEncoder by spec, but staying explicit
    // mirrors the byteLengthUtf8 helper below).
    const bytes = [];
    for (let i = 0; i < input.length; i++) {
        let c = input.charCodeAt(i);
        if (c < 0x80) {
            bytes.push(c);
        }
        else if (c < 0x800) {
            bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
        }
        else if (c >= 0xd800 && c < 0xdc00 && i + 1 < input.length) {
            // surrogate pair
            const c2 = input.charCodeAt(i + 1);
            const cp = 0x10000 + (((c & 0x3ff) << 10) | (c2 & 0x3ff));
            bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
            i++;
        }
        else {
            bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
        }
    }
    // Pre-processing: padding
    const bitLen = bytes.length * 8;
    bytes.push(0x80);
    while (bytes.length % 64 !== 56)
        bytes.push(0);
    // 64-bit big-endian length (high 32 bits zero — input < 2^32 bits is the
    // realistic ceiling here).
    bytes.push(0, 0, 0, 0);
    bytes.push((bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff);
    let h0 = 0x6a09e667;
    let h1 = 0xbb67ae85;
    let h2 = 0x3c6ef372;
    let h3 = 0xa54ff53a;
    let h4 = 0x510e527f;
    let h5 = 0x9b05688c;
    let h6 = 0x1f83d9ab;
    let h7 = 0x5be0cd19;
    const W = new Array(64);
    for (let chunk = 0; chunk < bytes.length; chunk += 64) {
        for (let i = 0; i < 16; i++) {
            const j = chunk + i * 4;
            W[i] =
                ((bytes[j] << 24) |
                    (bytes[j + 1] << 16) |
                    (bytes[j + 2] << 8) |
                    bytes[j + 3]) >>>
                    0;
        }
        for (let i = 16; i < 64; i++) {
            const a = W[i - 15];
            const b = W[i - 2];
            const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
            const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
            W[i] = (W[i - 16] + s0 + W[i - 7] + s1) >>> 0;
        }
        let a = h0;
        let b = h1;
        let c = h2;
        let d = h3;
        let e = h4;
        let f = h5;
        let g = h6;
        let h = h7;
        for (let i = 0; i < 64; i++) {
            const S1 = ((e >>> 6) | (e << 26)) ^
                ((e >>> 11) | (e << 21)) ^
                ((e >>> 25) | (e << 7));
            const ch = (e & f) ^ (~e & g);
            const t1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
            const S0 = ((a >>> 2) | (a << 30)) ^
                ((a >>> 13) | (a << 19)) ^
                ((a >>> 22) | (a << 10));
            const mj = (a & b) ^ (a & c) ^ (b & c);
            const t2 = (S0 + mj) >>> 0;
            h = g;
            g = f;
            f = e;
            e = (d + t1) >>> 0;
            d = c;
            c = b;
            b = a;
            a = (t1 + t2) >>> 0;
        }
        h0 = (h0 + a) >>> 0;
        h1 = (h1 + b) >>> 0;
        h2 = (h2 + c) >>> 0;
        h3 = (h3 + d) >>> 0;
        h4 = (h4 + e) >>> 0;
        h5 = (h5 + f) >>> 0;
        h6 = (h6 + g) >>> 0;
        h7 = (h7 + h) >>> 0;
    }
    const toHex = (n) => (n >>> 0).toString(16).padStart(8, "0");
    return (toHex(h0) +
        toHex(h1) +
        toHex(h2) +
        toHex(h3) +
        toHex(h4) +
        toHex(h5) +
        toHex(h6) +
        toHex(h7));
}
/**
 * Two-axis post-hoc path builder for the diagnosis descriptor (Plan 01-03,
 * D-09). Filename: `diagnosis-{diagnosisVersion}-{cardSetShortHash}.json`
 * where `cardSetShortHash = sha256(cardVersion).slice(0, 12)`.
 *
 * The compound version argument is `${diagnosisVersion}|${cardVersion}`
 * to fit the existing 3-arg `objectPath` signature without growing the
 * shared `ArtifactObjectPath` shape. Callers should use
 * `encodeDiagnosisPathVersion()` rather than building the compound
 * string by hand.
 *
 * The diagnosis descriptor opts into the bulk-versioned-with-report-axis
 * carve-out via `pathSafetyMarker: BULK_VERSIONED_WITH_REPORT_AXIS`
 * (ITER2-BLOCKER-01). The builder itself does not need to know about
 * the marker — it just builds the path; the carve-out is a registration-
 * time concern in `assertValidArtifactDescriptor`.
 */
export function diagnosisPathBuilder() {
    return (runId, reportId, version) => {
        if (!reportId)
            throw new Error("diagnosisPathBuilder requires reportId axis");
        if (!version)
            throw new Error("diagnosisPathBuilder requires compound version");
        if (hasControlChars(reportId))
            throw new Error("diagnosisPathBuilder reportId must not contain control characters");
        const sep = version.indexOf("|");
        if (sep <= 0)
            throw new Error("diagnosisPathBuilder version must be ${diagnosisVersion}|${cardVersion}");
        const dv = version.slice(0, sep);
        const cv = version.slice(sep + 1);
        if (hasControlChars(dv))
            throw new Error("diagnosisPathBuilder diagnosisVersion must not contain control characters");
        // Sanitize reportId and diagnosisVersion segments — `/` would otherwise
        // create unintended GCS subdirectories and break the
        // `runs/{runId}/{reportId}/diagnosis-…` path-prefix containment
        // guarantee. Mirrors `versionedPathBuilder` (above).
        const sanitizedReportId = sanitizeEntryKey(reportId);
        const sanitizedDv = sanitizeEntryKey(dv);
        const cardSetShortHash = sha256Hex(cv).slice(0, 12);
        return `runs/${runId}/${sanitizedReportId}/diagnosis-${sanitizedDv}-${cardSetShortHash}.json`;
    };
}
/**
 * Pack the diagnosis descriptor's two version segments into the existing
 * single-string `version` slot of `ArtifactObjectPath`. Separator is `|`;
 * `diagnosisVersion` MUST NOT contain `|` (the function rejects that case).
 */
export function encodeDiagnosisPathVersion(diagnosisVersion, cardVersion) {
    if (diagnosisVersion === "")
        throw new Error("encodeDiagnosisPathVersion: diagnosisVersion must be a non-empty string");
    if (cardVersion === "")
        throw new Error("encodeDiagnosisPathVersion: cardVersion must be a non-empty string");
    if (diagnosisVersion.includes("|"))
        throw new Error("diagnosisVersion must not contain '|'");
    return `${diagnosisVersion}|${cardVersion}`;
}
/**
 * Convert an entry key (wire format, e.g. `{taskId}::{modelId}`) to a
 * filename-safe component.
 *
 * - `::` → `--` so the wire separator doesn't show up in the filename.
 * - `/` → `_` so task names like "Content Lake with @sanity/client" don't
 *   create unintended GCS subdirectories and so `ls` against the per-entry
 *   directory shows one row per entry.
 *
 * Single colons (`:`) are preserved — modelIds like
 * `anthropic:messages:claude-opus-4-6` are valid GCS object names.
 *
 * NOTE: this mapping is not bijective. A taskId containing literal `--`
 * combined with a modelId could in theory collide with one whose taskId
 * contains `::`. In practice, production taskIds don't exercise these
 * combinations.
 */
function sanitizeEntryKey(key) {
    return key.replace(/::/g, "--").replace(/\//g, "_");
}
/**
 * Reject ASCII control characters and DEL in raw entry keys.
 *
 * Used at the top of every `parseEntryKey`. An authenticated caller can
 * otherwise embed CRLF in a URL-path entryKey, reach `console.warn` via
 * the non-blocking upload-failure path, and forge lines in shared log
 * aggregators (W0049 review finding I4 / security-audit LOW).
 *
 * GCS also rejects most control characters in object names, so this
 * catches the class at the boundary rather than waiting for a 400 from
 * the signed-URL PUT.
 */
// eslint-disable-next-line no-control-regex
const CONTROL_CHAR_PATTERN = /[\x00-\x1f\x7f]/;
function hasControlChars(raw) {
    return CONTROL_CHAR_PATTERN.test(raw);
}
/**
 * Order of axes in the canonical key representation. `run` is never included
 * in the entry key because the run dimension lives in the path prefix
 * (`runs/{runId}/…`).
 */
const KEY_AXIS_ORDER = [
    "mode",
    "task",
    "model",
    "grader",
    "trial",
    "category",
];
/**
 * Build a filename-safe entry key from association values by concatenating
 * the axis values in `KEY_AXIS_ORDER` with `--`. Axes not listed in the
 * descriptor's `association.axes` are skipped.
 *
 * For descriptors with bounded-only axes (only `run`) that are per-entry
 * (e.g. `sinkResults`), `assoc.name` is used as the key.
 */
function formatKeyFromAxes(axes) {
    const keyAxes = KEY_AXIS_ORDER.filter((a) => axes.includes(a));
    return (assoc) => {
        if (keyAxes.length === 0) {
            if (!assoc.name) {
                throw new Error(`formatEntryKey: descriptor with axes [${axes.join(", ")}] requires assoc.name`);
            }
            return sanitizeEntryKey(assoc.name);
        }
        const parts = keyAxes.map((axis) => {
            const raw = assoc[axis];
            if (raw === undefined || raw === null || raw === "") {
                throw new Error(`formatEntryKey: missing required axis "${axis}" in association`);
            }
            return String(raw);
        });
        return sanitizeEntryKey(parts.join("--"));
    };
}
/**
 * Strict parser that accepts only the canonical N-segment key matching the
 * descriptor's axis count (minus `run`). Used by most per-entry descriptors.
 */
function parseKeyByAxes(type, axes) {
    const expected = KEY_AXIS_ORDER.filter((a) => axes.includes(a)).length;
    return (raw) => {
        if (hasControlChars(raw)) {
            return {
                ok: false,
                reason: `Entry key for "${type}" must not contain control characters`,
            };
        }
        const sanitized = sanitizeEntryKey(raw);
        if (expected === 0) {
            if (!sanitized) {
                return {
                    ok: false,
                    reason: `Entry key for "${type}" must be a non-empty identifier`,
                };
            }
            return { ok: true, sanitized: sanitized };
        }
        const parts = sanitized.split("--");
        if (parts.length !== expected || parts.some((p) => !p)) {
            return {
                ok: false,
                reason: `Entry key "${raw}" for "${type}" must match ${expected}-segment form (${KEY_AXIS_ORDER.filter((a) => axes.includes(a)).join("--")}) with non-empty segments`,
            };
        }
        return { ok: true, sanitized: sanitized };
    };
}
/**
 * testOutputs parser. Accepts both the new 3-segment form
 * (`mode--task--model`) and the legacy 2-segment form (`task--model`); the
 * latter emits a one-time-per-process deprecation warning so noisy logs do
 * not mask the signal.
 *
 * The separator is `--` (post-sanitization). The legacy wire format
 * (`task::model`) still works because `sanitizeEntryKey` maps `::` → `--`
 * before the split — producers that haven't migrated keep functioning.
 *
 * Deprecation scheduled for W0052.
 */
let warnedLegacyTestOutputsKey = false;
function parseTestOutputsEntryKey(raw) {
    if (hasControlChars(raw)) {
        return {
            ok: false,
            reason: `Entry key for "testOutputs" must not contain control characters`,
        };
    }
    const sanitized = sanitizeEntryKey(raw);
    const parts = sanitized.split("--");
    if (parts.length === 3 && parts.every((p) => p.length > 0)) {
        return { ok: true, sanitized: sanitized };
    }
    if (parts.length === 2 && parts.every((p) => p.length > 0)) {
        if (!warnedLegacyTestOutputsKey) {
            warnedLegacyTestOutputsKey = true;
            console.warn("legacy testOutputs entry key (2-segment) is deprecated");
        }
        return { ok: true, sanitized: sanitized };
    }
    return {
        ok: false,
        reason: `Entry key "${raw}" for "testOutputs" must match {mode}--{task}--{model} (3-segment) or legacy {task}--{model} (2-segment) with non-empty segments`,
    };
}
/** Test-only reset for the legacy-key warning flag. Not exported publicly. */
export function __resetLegacyTestOutputsWarning() {
    warnedLegacyTestOutputsKey = false;
}
// ---------------------------------------------------------------------------
// Entry schemas
// ---------------------------------------------------------------------------
const testOutputEntrySchema = z.object({
    responseOutput: z.string(),
    responseOutputTruncated: z.boolean(),
});
/**
 * Preview shape for `testOutputs` manifest entries (W0051 / D0033 M7). A
 * Studio list view can render the truncated response + truncation flag
 * without a signed-URL round trip.
 */
const testOutputPreviewSchema = z.object({
    responsePreview: z.string().max(280),
    truncated: z.boolean(),
});
/**
 * Preview shape for `failureModes` per-category manifest entries (W0051
 * Slice 2 / D0033 M7). `failureModes` is keyed by `{mode, category}` after
 * Slice 2 — one entry per classified FailureModeType inside each mode.
 *
 * Severity is derived at extract time from the entry's `count` using the
 * bucketing in `severityForCount`. The bucket thresholds are a first-pass
 * heuristic; callers can override by supplying an explicit `severity` on
 * the entry payload if more nuanced signals become available.
 */
const failureModePreviewSchema = z.object({
    category: z.string(),
    severity: z.enum(["low", "medium", "high", "critical"]),
    titlePreview: z.string().max(120),
});
/**
 * Preview shape for `symbolPreflight` manifest entries (W0198 Phase 4).
 *
 * The deterministic-lane report can be tens of KB on candidates that
 * import many bindings. The Studio rollup row only needs the three lane
 * counts and the deduction total to flag whether a deduction was applied
 * and how much — drill-down hydrates the full per-binding finding list
 * from the external artifact.
 */
const symbolPreflightPreviewSchema = z.object({
    exists: z.number().int().nonnegative(),
    missing: z.number().int().nonnegative(),
    unresolved: z.number().int().nonnegative(),
    deductionTotal: z.number().nonnegative(),
});
/**
 * Preview shape for `graderJudgments` manifest entries (W0051 / D0033 M7).
 * List views render score + a short reason excerpt; drill-down hydrates
 * the full `{ reason, pass, dimensionScores }` from the external artifact.
 *
 * `score` is kept as `number` rather than `int(0..100)` so legacy judgments
 * with fractional scores (pre-W0048 rubrics produced 0–1 values) don't
 * reject at read time; the score-normalization step is upstream of emit.
 */
const graderJudgmentPreviewSchema = z.object({
    score: z.number(),
    reasonPreview: z.string().max(280),
    dimensionScores: z.record(z.string(), z.number()).optional(),
});
/**
 * Preview shape for `rawResults` manifest entries. A per-call projection
 * over the Promptfoo result payload — cost, latency, pass/fail, and token
 * usage — so a Studio list row can render without signing the full
 * artifact (which routinely runs to tens of KB of response text).
 *
 * Projected fields mirror top-level Promptfoo result keys (`success`,
 * `score`, `cost`, `latencyMs`) and `response.finishReason` /
 * `response.tokenUsage.total` for the two useful nested numbers.
 */
const rawResultPreviewSchema = z.object({
    passed: z.boolean(),
    score: z.number().optional(),
    cost: z.number().nonnegative().optional(),
    latencyMs: z.number().int().nonnegative().optional(),
    totalTokens: z.number().int().nonnegative().optional(),
    finishReason: z.string().max(40).optional(),
});
/**
 * Preview shape for the run-scoped `scoreSummary` bulk artifact. Projects
 * the composite score, run-level cost/token totals, the weakest area, and
 * the top 3 scoring areas — enough for a Studio rollup row without pulling
 * the full summary (which carries enriched per-test data and can exceed
 * 100 KB on multi-area runs).
 */
const scoreSummaryPreviewSchema = z.object({
    composite: z.number(),
    testCount: z.number().int().nonnegative().optional(),
    lowestArea: z.string().max(60).optional(),
    lowestScore: z.number().optional(),
    totalCost: z.number().nonnegative().optional(),
    totalTokens: z.number().int().nonnegative().optional(),
    topAreas: z
        .array(z.object({
        feature: z.string().max(60),
        score: z.number(),
    }))
        .max(3)
        .optional(),
});
/**
 * Preview shape for `renderedPrompts` manifest entries (W0061 / D0033 M7).
 * Lets the Studio PromptReplayDrawer surface a quick-look pane — prompt
 * char count, the variant label (e.g. "Baseline (No Docs)" / "Gold (with
 * docs)") as a secondary signal, and a short leading snippet — without
 * round-tripping the full rendered prompt (which can run 50+ KB on
 * multi-turn conversations).
 */
const renderedPromptPreviewSchema = z.object({
    promptCharCount: z.number().int().nonnegative(),
    label: z.string().max(60).optional(),
    snippet: z.string().max(120),
});
/**
 * Preview shape for `graderPrompts` manifest entries (W0061 / D0033 M7).
 * `rubricName` maps to the classified dimension (e.g. "correctness",
 * "completeness"); char count and snippet cover the rubric text itself so
 * the Studio drawer can render a preview before the full assertion payload
 * is signed and fetched.
 */
const graderPromptPreviewSchema = z.object({
    rubricCharCount: z.number().int().nonnegative(),
    rubricName: z.string().max(60).optional(),
    snippet: z.string().max(120),
});
/**
 * Preview shape for the run-scoped `pipelineContext` bulk artifact (W0063 /
 * D0033 M7). Lets the Studio Overview tab render a Pipeline Execution header
 * row (step count, wall-clock, failed-step badge, quality-gate badge, cache
 * hit count) without fetching the full context payload — `config` and per-
 * step detail only land when the panel is expanded.
 *
 * Bounds chosen so the worst-case preview fits comfortably under 384 bytes:
 *   - `failedSteps` is capped at 5 entries with each name ≤ 40 chars. Real
 *     step names ("fetch-docs", "calculate-scores", "gap-analysis") are 10–
 *     25 chars; 40 is a defensive ceiling. The array cap exists because
 *     `fitPreviewToCap` only shortens string fields — an unbounded array
 *     could push the preview over cap and force it to drop entirely.
 *     5 is a triage ceiling: the panel shows "showed 5 of N failed steps"
 *     when `failedSteps.length < stepCount - successCount`, and the full
 *     per-step list is available in the drilldown payload.
 *   - `belowCritical` and `cacheHits` are optional — absent on old runs,
 *     skipped pipelines, or runs without remote-cache telemetry.
 */
const pipelineContextPreviewSchema = z.object({
    stepCount: z.number().int().nonnegative(),
    totalDurationMs: z.number().nonnegative(),
    failedSteps: z.array(z.string().max(40)).max(5),
    belowCritical: z.boolean().optional(),
    cacheHits: z.number().int().nonnegative().optional(),
});
// Aspirational: most payload shapes are still loose. Tightening per-type as
// consumers stabilize is explicitly a W0050/W0051 concern — W0049 fixes the
// structural shape around them without changing the payload contracts.
const unknownEntry = z.unknown();
/**
 * Truncate a string to at most `maxChars` code units. Code-unit-based
 * truncation is safe for the preview use-case — previews are display-bound
 * approximations and a rare mid-surrogate cut manifests as a replacement
 * glyph, not data corruption. Pure function.
 */
function truncateString(s, maxChars) {
    return s.length <= maxChars ? s : s.slice(0, maxChars);
}
/**
 * Narrow check that a value is a plain object whose entries are all number-
 * valued strings. Used by preview extractors to safely include optional
 * per-dimension score fields without trusting the caller's runtime shape.
 */
function isStringNumberRecord(value) {
    if (value === null || typeof value !== "object" || Array.isArray(value))
        return false;
    for (const v of Object.values(value)) {
        if (typeof v !== "number")
            return false;
    }
    return true;
}
/**
 * Bucket a classified-judgment count into a severity tier. Thresholds are
 * count-based: a category with 10+ judgments is critical triage; 5–9 is
 * high; 2–4 medium; 0–1 low. Exposed so producers can mirror this when
 * supplying explicit severity values on entries that have richer signal
 * (e.g. per-dimension aggregate).
 */
function severityForCount(count) {
    if (count >= 10)
        return "critical";
    if (count >= 5)
        return "high";
    if (count >= 2)
        return "medium";
    return "low";
}
/**
 * Render a FailureModeType-style kebab-case category id as Title Case for
 * preview display (e.g. `"missing-docs"` → `"Missing Docs"`).
 */
function titleCaseCategory(id) {
    return id
        .split("-")
        .map((w) => (w.length === 0 ? w : w[0].toUpperCase() + w.slice(1)))
        .join(" ");
}
function buildDescriptor(input) {
    const objectPath = input.objectPath ??
        (input.versionedBy
            ? versionedPathBuilder(input.slug, input.mime)
            : input.layout === "bulk"
                ? bulkPathBuilder(input.slug, input.mime)
                : perEntryPathBuilder(input.slug, input.mime));
    const formatEntryKey = input.layout === "per-entry" ? formatKeyFromAxes(input.axes) : undefined;
    const parseEntryKey = input.layout === "per-entry"
        ? (input.parseEntryKey ?? parseKeyByAxes(input.type, input.axes))
        : undefined;
    return {
        type: input.type,
        layout: input.layout,
        slug: input.slug,
        association: { axes: input.axes },
        entrySchema: input.entrySchema,
        mime: input.mime,
        capBytes: input.capBytes,
        truncation: input.truncation,
        optional: input.optional,
        writePolicy: input.writePolicy,
        versionedBy: input.versionedBy,
        pathSafetyMarker: input.pathSafetyMarker,
        objectPath,
        extractPathArgs: input.extractPathArgs,
        formatEntryKey,
        parseEntryKey,
        manifestPreview: input.manifestPreview,
    };
}
// ---------------------------------------------------------------------------
// The registry — 21 live descriptors
// ---------------------------------------------------------------------------
/**
 * The canonical artifact descriptor for every artifact type. Iterate with
 * `Object.values(ARTIFACT_REGISTRY)` or look up by `ARTIFACT_REGISTRY[type]`.
 *
 * Axes, layout, and caps come from docs/design-docs/unified-run-artifacts.md
 * § M5. The mapping is verified by the L1 contract tests.
 */
export const ARTIFACT_REGISTRY = {
    // -- Run-scoped bulk artifacts (one per run) ------------------------------
    runManifest: buildDescriptor({
        type: "runManifest",
        slug: "run-manifest",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1_000_000,
    }),
    scoreSummary: buildDescriptor({
        type: "scoreSummary",
        slug: "score-summary",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1_000_000,
        manifestPreview: {
            schema: scoreSummaryPreviewSchema,
            extract: (entry) => {
                const e = entry;
                const avgScore = typeof e.overall?.avgScore === "number" ? e.overall.avgScore : 0;
                const totalCost = typeof e.overall?.cost?.total === "number"
                    ? e.overall.cost.total
                    : undefined;
                const rawTotalTokens = e.overall?.cost?.totalTokens;
                const totalTokens = typeof rawTotalTokens === "number" &&
                    Number.isFinite(rawTotalTokens) &&
                    rawTotalTokens >= 0
                    ? Math.trunc(rawTotalTokens)
                    : undefined;
                const scores = Array.isArray(e.scores) ? e.scores : [];
                const areas = [];
                for (const s of scores) {
                    if (s === null || typeof s !== "object")
                        continue;
                    const rec = s;
                    if (typeof rec.feature !== "string")
                        continue;
                    if (typeof rec.totalScore !== "number")
                        continue;
                    const testCount = typeof rec.testCount === "number" ? rec.testCount : undefined;
                    areas.push({
                        feature: rec.feature,
                        totalScore: rec.totalScore,
                        ...(testCount !== undefined ? { testCount } : {}),
                    });
                }
                const topAreas = areas
                    .slice()
                    .sort((a, b) => b.totalScore - a.totalScore)
                    .slice(0, 3)
                    .map((a) => ({ feature: a.feature, score: a.totalScore }));
                const testCount = areas.reduce((sum, a) => sum + (a.testCount ?? 0), 0);
                const lowestArea = typeof e.lowestArea === "string" ? e.lowestArea : undefined;
                const lowestScore = typeof e.lowestScore === "number" ? e.lowestScore : undefined;
                return {
                    composite: Math.round(avgScore),
                    ...(testCount > 0 ? { testCount } : {}),
                    ...(lowestArea === undefined ? {} : { lowestArea }),
                    ...(lowestScore === undefined ? {} : { lowestScore }),
                    ...(totalCost === undefined ? {} : { totalCost }),
                    ...(totalTokens === undefined ? {} : { totalTokens }),
                    ...(topAreas.length > 0 ? { topAreas } : {}),
                };
            },
            capBytes: 512,
        },
    }),
    pipelineResult: buildDescriptor({
        type: "pipelineResult",
        slug: "pipeline-result",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1_000_000,
    }),
    pipelineContext: buildDescriptor({
        type: "pipelineContext",
        slug: "pipeline-context",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 64_000,
        manifestPreview: {
            schema: pipelineContextPreviewSchema,
            extract: (entry) => {
                // Producer shape from `capturePipelineContext` in
                // packages/eval/src/orchestration/pipeline-orchestrator.ts:
                //   { config, state: { belowCritical, remoteCacheHits, ... },
                //     steps: [{ name, status: "success"|"failed"|"skipped",
                //               durationMs? }] }
                //
                // `config` and everything else on `state` are drilldown-only and
                // intentionally absent from the preview — they're what the panel
                // fetches lazily when expanded.
                const e = entry;
                const stepsRaw = Array.isArray(e.steps) ? e.steps : [];
                let totalDurationMs = 0;
                const failedSteps = [];
                let stepCount = 0;
                for (const raw of stepsRaw) {
                    if (raw === null || typeof raw !== "object")
                        continue;
                    stepCount += 1;
                    const s = raw;
                    if (typeof s.durationMs === "number" &&
                        Number.isFinite(s.durationMs) &&
                        s.durationMs >= 0) {
                        totalDurationMs += s.durationMs;
                    }
                    if (s.status === "failed" &&
                        typeof s.name === "string" &&
                        failedSteps.length < 5) {
                        failedSteps.push(truncateString(s.name, 40));
                    }
                }
                const belowCritical = typeof e.state?.belowCritical === "boolean"
                    ? e.state.belowCritical
                    : undefined;
                const cacheHitsRaw = e.state?.remoteCacheHits;
                const cacheHits = Array.isArray(cacheHitsRaw)
                    ? cacheHitsRaw.length
                    : undefined;
                return {
                    stepCount,
                    totalDurationMs,
                    failedSteps,
                    ...(belowCritical === undefined ? {} : { belowCritical }),
                    ...(cacheHits === undefined ? {} : { cacheHits }),
                };
            },
            capBytes: 384,
        },
    }),
    documentManifest: buildDescriptor({
        type: "documentManifest",
        slug: "document-manifest",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 256_000,
    }),
    prComment: buildDescriptor({
        type: "prComment",
        slug: "pr-comment",
        layout: "bulk",
        axes: ["run"],
        entrySchema: z.string(),
        mime: "text/markdown",
        capBytes: 256_000,
        optional: true,
    }),
    readinessReport: buildDescriptor({
        type: "readinessReport",
        slug: "readiness-report",
        layout: "bulk",
        axes: ["run"],
        entrySchema: z.string(),
        mime: "text/markdown",
        capBytes: 256_000,
        optional: true,
    }),
    reportSnapshot: buildDescriptor({
        type: "reportSnapshot",
        slug: "report-snapshot",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 10_000_000,
    }),
    autoComparison: buildDescriptor({
        type: "autoComparison",
        slug: "auto-comparison",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 4_000_000,
        optional: true,
    }),
    gapReport: buildDescriptor({
        type: "gapReport",
        slug: "gap-report",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1_000_000,
        optional: true,
    }),
    // -- Run-scoped per-entry artifacts (keyed by assoc.name) -----------------
    sinkResults: buildDescriptor({
        type: "sinkResults",
        slug: "sink-results",
        layout: "per-entry",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 64_000,
    }),
    callbackRequest: buildDescriptor({
        type: "callbackRequest",
        slug: "callback-request",
        layout: "per-entry",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 64_000,
        optional: true,
    }),
    callbackResponse: buildDescriptor({
        type: "callbackResponse",
        slug: "callback-response",
        layout: "per-entry",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 64_000,
        optional: true,
    }),
    // -- Run × Mode ------------------------------------------------------------
    configSnapshot: buildDescriptor({
        type: "configSnapshot",
        slug: "config-snapshot",
        layout: "per-entry",
        axes: ["run", "mode"],
        entrySchema: z.string(),
        mime: "application/yaml",
        capBytes: 256_000,
    }),
    evalConfigGenerated: buildDescriptor({
        type: "evalConfigGenerated",
        slug: "eval-config-generated",
        layout: "per-entry",
        axes: ["run", "mode"],
        entrySchema: z.string(),
        mime: "application/yaml",
        capBytes: 256_000,
        optional: true,
    }),
    comparisonReport: buildDescriptor({
        type: "comparisonReport",
        slug: "comparison-report",
        layout: "per-entry",
        axes: ["run", "mode"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1_000_000,
        optional: true,
    }),
    discoveryReport: buildDescriptor({
        type: "discoveryReport",
        slug: "discovery-report",
        layout: "per-entry",
        axes: ["run", "mode"],
        entrySchema: z.string(),
        mime: "text/markdown",
        capBytes: 1_000_000,
        optional: true,
    }),
    failureModes: buildDescriptor({
        type: "failureModes",
        slug: "failure-modes",
        layout: "per-entry",
        axes: ["run", "mode", "category"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1_000_000,
        optional: true,
        manifestPreview: {
            schema: failureModePreviewSchema,
            extract: (entry) => {
                const e = entry;
                const category = typeof e.category === "string" ? e.category : "unknown";
                const count = typeof e.count === "number" ? e.count : 0;
                const title = typeof e.title === "string" ? e.title : titleCaseCategory(category);
                return {
                    category,
                    severity: severityForCount(count),
                    titlePreview: truncateString(title, 120),
                };
            },
            capBytes: 256,
        },
    }),
    // -- Run × Mode × Task(+…) -------------------------------------------------
    renderedPrompts: buildDescriptor({
        type: "renderedPrompts",
        slug: "rendered-prompts",
        layout: "per-entry",
        axes: ["run", "mode", "task", "model"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1_000_000,
        manifestPreview: {
            schema: renderedPromptPreviewSchema,
            extract: (entry) => {
                // Producer shape from `emit-eval-results.ts`:
                //   { prompt: { raw: string, label: string, config: {...} },
                //     provider: { id: string, label: string } }
                // The prompt text lives at `prompt.raw`; `prompt.label` carries the
                // human-readable variant name ("Baseline (No Docs)" / "Gold (with
                // docs)") and is a more useful secondary signal than a
                // provider-level format hint (which isn't exposed here).
                const e = entry;
                const raw = typeof e.prompt?.raw === "string" ? e.prompt.raw : "";
                const label = typeof e.prompt?.label === "string"
                    ? truncateString(e.prompt.label, 60)
                    : undefined;
                return {
                    promptCharCount: raw.length,
                    ...(label === undefined ? {} : { label }),
                    snippet: truncateString(raw, 120),
                };
            },
            capBytes: 256,
        },
    }),
    rawResults: buildDescriptor({
        type: "rawResults",
        slug: "raw-results",
        layout: "per-entry",
        axes: ["run", "mode", "task", "model"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1_000_000,
        manifestPreview: {
            schema: rawResultPreviewSchema,
            extract: (entry) => {
                const e = entry;
                const passed = e.success === true;
                const score = typeof e.score === "number" ? e.score : undefined;
                const cost = typeof e.cost === "number" && e.cost >= 0 ? e.cost : undefined;
                const rawLatency = e.latencyMs;
                const latencyMs = typeof rawLatency === "number" &&
                    Number.isFinite(rawLatency) &&
                    rawLatency >= 0
                    ? Math.trunc(rawLatency)
                    : undefined;
                const rawTotalTokens = e.response?.tokenUsage?.total;
                const totalTokens = typeof rawTotalTokens === "number" &&
                    Number.isFinite(rawTotalTokens) &&
                    rawTotalTokens >= 0
                    ? Math.trunc(rawTotalTokens)
                    : undefined;
                const finishReason = typeof e.response?.finishReason === "string"
                    ? truncateString(e.response.finishReason, 40)
                    : undefined;
                return {
                    passed,
                    ...(score === undefined ? {} : { score }),
                    ...(cost === undefined ? {} : { cost }),
                    ...(latencyMs === undefined ? {} : { latencyMs }),
                    ...(totalTokens === undefined ? {} : { totalTokens }),
                    ...(finishReason === undefined ? {} : { finishReason }),
                };
            },
            capBytes: 256,
        },
    }),
    testOutputs: buildDescriptor({
        type: "testOutputs",
        slug: "test-outputs",
        layout: "per-entry",
        axes: ["run", "mode", "task", "model"],
        entrySchema: testOutputEntrySchema,
        mime: "application/json",
        capBytes: 1_000_000,
        parseEntryKey: parseTestOutputsEntryKey,
        manifestPreview: {
            schema: testOutputPreviewSchema,
            extract: (entry) => {
                const e = entry;
                const raw = typeof e.responseOutput === "string" ? e.responseOutput : "";
                return {
                    responsePreview: truncateString(raw, 280),
                    truncated: typeof e.responseOutputTruncated === "boolean"
                        ? e.responseOutputTruncated
                        : false,
                };
            },
            capBytes: 320,
        },
    }),
    graderPrompts: buildDescriptor({
        type: "graderPrompts",
        slug: "grader-prompts",
        layout: "per-entry",
        axes: ["run", "mode", "task", "model", "grader"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 512_000,
        manifestPreview: {
            schema: graderPromptPreviewSchema,
            extract: (entry) => {
                const e = entry;
                const rubricText = typeof e.assertion?.value === "string" ? e.assertion.value : "";
                const rubricName = typeof e.dimension === "string"
                    ? truncateString(e.dimension, 60)
                    : undefined;
                return {
                    rubricCharCount: rubricText.length,
                    ...(rubricName === undefined ? {} : { rubricName }),
                    snippet: truncateString(rubricText, 120),
                };
            },
            capBytes: 256,
        },
    }),
    graderJudgments: buildDescriptor({
        type: "graderJudgments",
        slug: "grader-judgments",
        layout: "per-entry",
        axes: ["run", "mode", "task", "model", "grader"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 512_000,
        manifestPreview: {
            schema: graderJudgmentPreviewSchema,
            extract: (entry) => {
                const e = entry;
                const score = typeof e.score === "number" ? e.score : 0;
                const reasonText = typeof e.reason === "string" ? e.reason : "";
                const dimensionScores = isStringNumberRecord(e.dimensionScores)
                    ? e.dimensionScores
                    : undefined;
                return {
                    score,
                    reasonPreview: truncateString(reasonText, 280),
                    ...(dimensionScores === undefined ? {} : { dimensionScores }),
                };
            },
            capBytes: 512,
        },
    }),
    symbolPreflight: buildDescriptor({
        type: "symbolPreflight",
        slug: "symbol-preflight",
        layout: "per-entry",
        axes: ["run", "mode", "task", "model"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 256_000,
        manifestPreview: {
            schema: symbolPreflightPreviewSchema,
            extract: (entry) => {
                const e = entry;
                const findings = Array.isArray(e.findings) ? e.findings : [];
                let exists = 0;
                let missing = 0;
                let unresolved = 0;
                for (const f of findings) {
                    if (f && typeof f === "object" && "result" in f) {
                        if (f.result === "exists")
                            exists++;
                        else if (f.result === "missing")
                            missing++;
                        else if (f.result === "unresolved")
                            unresolved++;
                    }
                }
                const deductionTotal = typeof e.deduction?.total === "number" ? e.deduction.total : 0;
                return { exists, missing, unresolved, deductionTotal };
            },
            capBytes: 96,
        },
    }),
    traces: buildDescriptor({
        type: "traces",
        slug: "traces",
        layout: "per-entry",
        axes: ["run", "mode", "task", "model", "trial"],
        entrySchema: unknownEntry,
        mime: "application/x-ndjson",
        capBytes: 10_000_000,
        truncation: "trial-oversize",
    }),
    // -- Plan 01-03 — Actionability ladder Phase 1 ---------------------------
    diagnosis: buildDescriptor({
        type: "diagnosis",
        slug: "diagnosis",
        layout: "bulk",
        axes: ["run", "report"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 5 * 1024 * 1024,
        writePolicy: "post-hoc",
        versionedBy: "diagnosisVersion",
        objectPath: diagnosisPathBuilder(),
        // The diagnosis path builder takes (runId, reportId, version). The
        // reportId comes from association.report; the version is a compound
        // `diagnosisVersion|cardVersion` synthesized from the payload's
        // `inputs` field via `encodeDiagnosisPathVersion`. Writers spread
        // this beyond `runId` when calling `objectPath`.
        extractPathArgs: (assoc, payload) => {
            const reportId = typeof assoc.report === "string" ? assoc.report : undefined;
            const diag = payload;
            const dv = diag?.inputs?.diagnosisVersion;
            const cv = diag?.inputs?.cardVersion;
            const version = dv && cv ? encodeDiagnosisPathVersion(dv, cv) : undefined;
            return [reportId, version];
        },
        // Defense-in-depth: this descriptor's axes (`run`, `report`) are both
        // bounded, so the `assertValidArtifactDescriptor` unbounded-axis rule
        // does not fire and the carve-out is never consulted at module load
        // for THIS descriptor. The marker is set so any future additions to
        // this descriptor (or a new diagnosis-shaped descriptor that gains an
        // unbounded axis like `task`) opt into the carve-out by default rather
        // than tripping the bounded-axis rule. The marker also documents
        // intent: this descriptor is the canonical reference for the
        // bulk-versioned-with-report-axis pattern (ITER2-BLOCKER-01).
        pathSafetyMarker: BULK_VERSIONED_WITH_REPORT_AXIS, // ITER2-BLOCKER-01 — explicit opt-in
    }),
    perEntryAttribution: buildDescriptor({
        type: "perEntryAttribution",
        slug: "attribution",
        layout: "per-entry",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1 * 1024 * 1024,
        writePolicy: "pipeline",
    }),
    attributionMeta: buildDescriptor({
        type: "attributionMeta",
        slug: "attribution-meta",
        layout: "bulk",
        axes: ["run"],
        entrySchema: unknownEntry,
        mime: "application/json",
        capBytes: 1 * 1024 * 1024,
        writePolicy: "pipeline",
    }),
};
/** All artifact types in declaration order. */
export const ARTIFACT_TYPES = Object.keys(ARTIFACT_REGISTRY);
/**
 * Type guard — validates that an arbitrary string is a known artifact type.
 * Useful at API Gateway boundaries where the type comes from a URL parameter.
 */
export function isArtifactType(value) {
    return value in ARTIFACT_REGISTRY;
}
// ---------------------------------------------------------------------------
// Module-load invariant (D0033 / W0049 / D0050)
// ---------------------------------------------------------------------------
/**
 * Unbounded axes — dimensions whose cardinality grows with a run. A bulk
 * artifact fanning across these cannot bound its payload; the registry
 * forbids that shape at import time.
 *
 * **Layout rule (D0050).** Bulk descriptors must declare *only* bounded
 * axes — fanning a single JSON across an unbounded axis (`task`, `model`,
 * `trial`) violates the size cap at scale. Per-entry descriptors *may*
 * declare unbounded axes; the per-entry layout naturally produces one
 * object per axis tuple, so unboundedness becomes the file count, not
 * the file size. The existing `testOutputs` per-entry descriptor has
 * carried unbounded `task`+`model` axes since W0048 and is the precedent
 * D0050 formalizes for attribution.
 */
const UNBOUNDED_AXES = [
    "task",
    "model",
    "trial",
];
/**
 * The bulk-versioned-with-report-axis carve-out (Plan 01-03 /
 * ITER2-BLOCKER-01) is an explicit opt-in: a descriptor must set
 * `pathSafetyMarker === BULK_VERSIONED_WITH_REPORT_AXIS` AND meet the
 * four structural conditions:
 *
 *   1. desc.pathSafetyMarker === BULK_VERSIONED_WITH_REPORT_AXIS  (gate)
 *   2. desc.layout === "bulk"                                     (sanity)
 *   3. Boolean(desc.versionedBy)                                  (sanity)
 *   4. desc.association.axes.includes("run")                      (sanity)
 *   5. desc.association.axes.includes("report")                   (sanity)
 *
 * Marker without structure → fails (the descriptor misapplied the marker).
 * Structure without marker → fails (the descriptor must opt in).
 *
 * The marker is the SOLE detection mechanism. There is no source-text
 * reflection on the descriptor's path-builder closure body — `Symbol.for(...)`
 * is bundler-stable by spec and survives minification.
 */
function isBulkVersionedWithReportAxisCarveOut(desc) {
    return (desc.pathSafetyMarker === BULK_VERSIONED_WITH_REPORT_AXIS &&
        desc.layout === "bulk" &&
        Boolean(desc.versionedBy) &&
        desc.association.axes.includes("run") &&
        desc.association.axes.includes("report"));
}
/**
 * Structural check run against a single descriptor. Exported so L1 contract
 * tests can construct an invalid descriptor inline and assert the throw.
 */
export function assertValidArtifactDescriptor(desc) {
    const carveOutApplies = isBulkVersionedWithReportAxisCarveOut(desc);
    const hasUnboundedAxis = desc.association.axes.some((a) => UNBOUNDED_AXES.includes(a));
    if (hasUnboundedAxis && desc.layout !== "per-entry" && !carveOutApplies) {
        throw new Error(`Artifact ${desc.type}: association contains unbounded axis (${desc.association.axes
            .filter((a) => UNBOUNDED_AXES.includes(a))
            .join(", ")}) but layout is "${desc.layout}". Unbounded axes require layout "per-entry".`);
    }
    if (desc.capBytes <= 0) {
        throw new Error(`Artifact ${desc.type}: capBytes must be > 0 (got ${desc.capBytes})`);
    }
    if (desc.layout === "per-entry" && !desc.formatEntryKey) {
        throw new Error(`Artifact ${desc.type}: per-entry descriptors must declare formatEntryKey`);
    }
    // D0050 — versioned descriptors are bulk-shaped only in v0; per-entry +
    // versioned is a future extension and rejected at module load so a
    // half-wired descriptor doesn't ship by accident. Bulk-versioned-with-
    // report-axis (the diagnosis case) is permitted only via the explicit
    // pathSafetyMarker opt-in checked above.
    if (desc.versionedBy && desc.layout !== "bulk") {
        throw new Error(`Artifact ${desc.type}: versionedBy is only supported on bulk descriptors (got layout "${desc.layout}")`);
    }
}
// Fire the invariant at import time — a bad descriptor kills the process
// before any producer can silently serialize an oversized JSON array.
for (const desc of Object.values(ARTIFACT_REGISTRY)) {
    assertValidArtifactDescriptor(desc);
}
// ---------------------------------------------------------------------------
// Write-policy guard (D0050)
// ---------------------------------------------------------------------------
/**
 * Thrown when a writer's identity (`writeSource`) doesn't match a
 * descriptor's `writePolicy`. Pipeline writers can't emit `"post-hoc"`
 * descriptors (the post-hoc artifact would land mid-run, before the run
 * finalizes); post-hoc writers can't emit `"pipeline"` descriptors
 * (those should have been written by the pipeline itself).
 *
 * The error type is intentionally distinct from `Error` so CI can match
 * on the class and surface mismatches clearly in failure logs.
 */
export class WritePolicyMismatchError extends Error {
    code = "WRITE_POLICY_MISMATCH";
    artifactType;
    descriptorPolicy;
    writerSource;
    constructor(opts) {
        super(`Artifact "${opts.artifactType}" has writePolicy="${opts.descriptorPolicy}" but writer is "${opts.writerSource}". ` +
            `Pipeline writers cannot emit post-hoc descriptors and post-hoc writers cannot emit pipeline descriptors.`);
        this.name = "WritePolicyMismatchError";
        this.artifactType = opts.artifactType;
        this.descriptorPolicy = opts.descriptorPolicy;
        this.writerSource = opts.writerSource;
    }
}
/**
 * Resolve a descriptor's effective write policy. Defaults to `"pipeline"`
 * when unset — preserves backward compatibility with every pre-D0050
 * descriptor that doesn't declare the field.
 */
export function resolveWritePolicy(desc) {
    return desc.writePolicy ?? "pipeline";
}
/**
 * Writer-side guard. Call at the top of `emit()` / `appendNdjson()` in
 * every artifact writer that physically writes bytes (the in-memory test
 * doubles don't need it). Throws `WritePolicyMismatchError` on
 * mismatch; returns silently on a match. Pure function — no I/O, safe
 * to invoke from any layer.
 */
export function assertWritePolicyMatches(writerSource, descriptor) {
    const descriptorPolicy = resolveWritePolicy(descriptor);
    if (descriptorPolicy !== writerSource) {
        throw new WritePolicyMismatchError({
            artifactType: descriptor.type,
            descriptorPolicy,
            writerSource,
        });
    }
}
// ---------------------------------------------------------------------------
// Slim-shape preview for post-hoc artifacts (D0050)
// ---------------------------------------------------------------------------
/**
 * Slim-shape preview for `"post-hoc"` descriptors. Replaces the
 * fixed-path semantics that pipeline-written artifacts use (a single
 * known path per descriptor) with `present: boolean` plus an optional
 * `latestVersion: string` — necessary because:
 *
 *   - A post-hoc artifact may have zero versions (never written) or
 *     multiple versions (regenerated). A fixed `path` cannot encode
 *     either.
 *   - Slim-shape consumers (Studio rollups) want to know "is there a
 *     diagnosis for this run?" without enumerating versioned siblings.
 *
 * Post-hoc writers populate `latestVersion` after a successful write
 * (last-write-wins per-version semantics, per D0050 open-question
 * resolution). The full versioned payload is fetched via the
 * descriptor's `objectPath(runId, undefined, latestVersion)`.
 */
export const postHocSlimPreviewSchema = z.object({
    present: z.boolean(),
    latestVersion: z.string().optional(),
});
/**
 * Build a post-hoc slim-shape preview. `latestVersion` is omitted when
 * absent rather than emitted as `undefined`, matching the optional-field
 * convention used elsewhere in the registry.
 */
export function buildPostHocSlimPreview(opts) {
    return opts.latestVersion === undefined
        ? { present: opts.present }
        : { present: opts.present, latestVersion: opts.latestVersion };
}
// ---------------------------------------------------------------------------
// Manifest preview helper (W0051 / D0033 M7)
// ---------------------------------------------------------------------------
/**
 * Build the inline preview for a manifest entry at write time. Returns
 * `undefined` when the descriptor has no `manifestPreview` declaration,
 * when extraction throws, when the schema rejects the extracted shape, or
 * when cap-enforcement cannot bring the serialized preview under the
 * descriptor's `capBytes` budget.
 *
 * Failure is non-fatal: preview is triage metadata, never critical-path data.
 * The full payload still lands in the external artifact regardless.
 *
 * Cap enforcement (hard truncation) iteratively shortens the longest string
 * field on the preview object by ~10% per pass until the JSON-serialized
 * form fits under `capBytes` or no string remains to trim. Nested objects
 * are not recursed — previews are intentionally shallow (a handful of
 * top-level fields).
 */
export function buildManifestPreview(descriptor, payload) {
    const decl = descriptor.manifestPreview;
    if (!decl)
        return undefined;
    let extracted;
    try {
        extracted = decl.extract(payload);
    }
    catch (err) {
        console.warn(`  ⚠️  manifestPreview.extract("${descriptor.type}") threw: ${errMessage(err)} — dropping preview`);
        return undefined;
    }
    const parsed = decl.schema.safeParse(extracted);
    if (!parsed.success) {
        console.warn(`  ⚠️  manifestPreview schema rejected "${descriptor.type}" preview — dropping preview`);
        return undefined;
    }
    const fitted = fitPreviewToCap(parsed.data, decl.capBytes);
    if (!fitted) {
        console.warn(`  ⚠️  manifestPreview for "${descriptor.type}" exceeds capBytes=${decl.capBytes} and cannot be truncated — dropping preview`);
        return undefined;
    }
    return fitted;
}
function errMessage(err) {
    return err instanceof Error ? err.message : String(err);
}
/**
 * Hard-truncate string fields of `preview` until `JSON.stringify(preview)`
 * fits within `capBytes`. Operates on a shallow clone to preserve purity.
 * Returns the fitted preview, or `undefined` if no amount of shortening
 * brings the preview under cap (e.g. the non-string fields alone exceed it).
 */
function fitPreviewToCap(preview, capBytes) {
    if (preview === null || typeof preview !== "object") {
        return byteLengthUtf8(JSON.stringify(preview)) <= capBytes
            ? preview
            : undefined;
    }
    const clone = { ...preview };
    let bytes = byteLengthUtf8(JSON.stringify(clone));
    if (bytes <= capBytes)
        return preview; // already fits; original returned
    // Repeatedly trim the longest string field by ~10% of its length (min 4)
    // until we fit or no trimmable string is left.
    // Bound the loop to guard against pathological schemas.
    for (let pass = 0; pass < 256; pass++) {
        const longestKey = findLongestStringKey(clone);
        if (longestKey === null)
            return undefined;
        const current = clone[longestKey];
        if (current.length <= 1) {
            // Drop this field entirely — it can't be shortened further.
            delete clone[longestKey];
        }
        else {
            const trimBy = Math.max(4, Math.ceil(current.length * 0.1));
            clone[longestKey] = current.slice(0, current.length - trimBy);
        }
        bytes = byteLengthUtf8(JSON.stringify(clone));
        if (bytes <= capBytes)
            return clone;
    }
    return undefined;
}
/**
 * UTF-8 byte length of a string. Implemented without Node's `Buffer` so
 * `@sanity/ailf-core` stays dependency-free of `@types/node` — the kernel
 * runs in both Node and the browser (Studio).
 */
function byteLengthUtf8(s) {
    let bytes = 0;
    for (let i = 0; i < s.length; i++) {
        const c = s.charCodeAt(i);
        if (c < 0x80)
            bytes += 1;
        else if (c < 0x800)
            bytes += 2;
        else if (c >= 0xd800 && c < 0xdc00) {
            // high surrogate — a 4-byte UTF-8 sequence; skip the paired low surrogate
            bytes += 4;
            i++;
        }
        else
            bytes += 3;
    }
    return bytes;
}
function findLongestStringKey(obj) {
    let best = null;
    for (const [k, v] of Object.entries(obj)) {
        if (typeof v === "string" && (best === null || v.length > best.len)) {
            best = { key: k, len: v.length };
        }
    }
    return best === null ? null : best.key;
}
