/**
 * # cli/backend/hosted — the SIM FUNNEL client (kestrel-585 / kestrel-vcn).
 *
 * A LIGHT (node+bun, fetch-only) client for the platform's hosted "one-command sim"
 * funnel — the three calls the `sim` verb drives, plus the proof read that carries the
 * graded metrics:
 *
 *   - `GET  /catalog`               → the hosted catalog ({ entries: HostedCatalogEntry[] }),
 *     each entry a story `title` + its `artifact_id` (the dataset the sim runs against).
 *   - `POST /capabilities/trial`    → mint an anonymous trial capability (its `capability`
 *     field is the `Authorization: Bearer` token).
 *   - `POST /simulate`  {source, dataset:{artifact_id}}  → a COMPLETED Operation directly
 *     (artifacts + metered receipts inline), or **402 OfferResponse** at the paid boundary.
 *   - `GET  /proof/{artifact_id}`   → the signed grade proof, whose `result.metrics` carry
 *     order_count / fill_count / realized_pnl.
 *
 * This is a SEPARATE face from {@link ./remote.ts RemoteBackend} (the M1 `/sim` + SSE
 * contract): the funnel's `/simulate` returns the finished Operation in one shot rather
 * than over an SSE stream, and its `/catalog` carries marketing `title`s the M1
 * `CatalogEntry` (content-addressed only) does not. The 402 body IS the M1
 * {@link ./wire.ts WireOfferResponse}, so gating reuses `asOfferResponse` + the existing
 * `render/offer.ts` — one Offer dialect for the whole CLI.
 *
 * Slug derivation ({@link slugifyTitle}) is a pure, deterministic kebab-case of the title;
 * the marketing→catalog {@link SCENARIO_ALIASES} table lets the site's published slugs
 * resolve to the catalog's own slugs (site-is-spec).
 */

import type { WireOfferResponse, WireOffer, WireResumeAffordance, WireSettlementAffordance } from "./wire.ts";
import { asOfferResponse, asWireOffer, asSettlementResume, asSettlementAffordance } from "./wire.ts";
import type { Gated } from "./index.ts";
import type { Scope } from "../../protocol/index.ts";
// The published verify-set path is a WIRE constant homed in the dependency-free protocol package
// (OSS-ADR-0046 §2, kestrel-s67u) — imported so this transport can never drift from the ONE path
// the keys are actually served at (bead kestrel-markets-fjq7).
import { VERIFY_KEYS_PATH } from "../../protocol/attestation.ts";
import type { CertifiedGradeCoveredFields, JsonValue } from "../../protocol/attestation.ts";
import type { GradeResult } from "../../protocol/index.ts";
import { CliError, EXIT } from "../errors.ts";
import type { ProblemDetails } from "../../client/index.ts";
import { idemKey } from "../../client/platform-wire.ts";

/** The default hosted funnel base — the ONE definition, owned by
 *  {@link ../../client/platform-wire.ts platform-wire} and re-exported so the `sim` verb's
 *  base resolution stays byte-identical to every other face (kestrel-z473.2). */
export { DEFAULT_API } from "../../client/platform-wire.ts";

/** The web origin the shareable proof URL is rendered against (never the API host). */
export const PROOF_WEB_BASE = "https://kestrel.markets";

type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;

/* ─────────────────────────── hosted wire shapes ─────────────────────────── */

/** One hosted catalog entry — a curated scenario. `title` is the human name the slug is
 *  derived from; `artifact_id` is the dataset the sim runs against. */
export interface HostedCatalogEntry {
  readonly artifact_id: string;
  readonly title: string;
  readonly instrument: string;
  readonly period: { readonly start: string; readonly end: string };
  readonly free: boolean;
  readonly description: string;
  readonly frame_count: number;
  readonly content_hash?: string;
}

/** `GET /catalog` body. */
export interface HostedCatalog {
  readonly entries: readonly HostedCatalogEntry[];
}

/** The trial capability mint (`capability` is the bearer token). */
export interface HostedTrial {
  readonly capability: string;
  readonly subject_commitment?: string;
  readonly expiry?: string;
  readonly scopes?: readonly string[];
  readonly rate_limit?: { readonly requests_per_minute?: number; readonly compute_quota?: string };
}

/** A metering or grade receipt on a completed Operation. */
export interface HostedReceipt {
  readonly kind: string; // "metered" | "grade" | …
  readonly node?: string;
  readonly amount?: number;
  readonly cost?: number;
  readonly markup?: number;
  readonly budget_remaining?: number;
  readonly root?: string;
  readonly signature?: string;
}

/** The unique signed Grade receipt guaranteed by {@link verifiedHostedOperation}. */
export interface HostedGradeReceipt extends HostedReceipt {
  readonly kind: "grade";
  readonly root: string;
  readonly signature: string;
}

/** An artifact reference on a completed Operation (the grade carries `kind:"grade"`). */
export interface HostedArtifact {
  readonly artifact_id: string;
  readonly kind: string;
  readonly content_hash?: string;
  readonly proof_url?: string;
}

/** The durable artifact kinds carried by a verified Simulation evidence bundle. */
export type HostedEvidenceKind = "preflight" | "agent_call" | "session_bus" | "certified_grade" | "manifest";

/** A private, content-addressed R2 artifact reference returned by the platform. */
export interface HostedEvidenceRef {
  readonly artifact_id: string;
  readonly kind: HostedEvidenceKind;
  readonly content_hash: string;
  readonly byte_length: number;
  readonly classification: "private";
}

/**
 * The minimum durable evidence for a completed Simulation. Inference-free runs have an
 * empty `callRefs`; model-backed runs carry one `agent_call` reference per captured call.
 */
export interface HostedEvidenceBundle {
  readonly busRef: HostedEvidenceRef;
  readonly gradeRef: HostedEvidenceRef;
  readonly callRefs: readonly HostedEvidenceRef[];
  readonly manifestRef: HostedEvidenceRef;
}

/** `POST /simulate` wire body. Evidence stays optional here because the client validates
 * the untrusted response before exposing a successful Operation to callers. */
export interface HostedOperation {
  readonly operation_id: string;
  readonly intent_hash: string;
  readonly status: string; // "completed" | "running" | "failed" | …
  readonly checkpoint?: string;
  readonly cursor?: string;
  readonly artifacts: readonly HostedArtifact[];
  readonly receipts: readonly HostedReceipt[];
  readonly evidence?: HostedEvidenceBundle;
  /** Legacy/degraded responses may describe missing persistence instead of proving it. */
  readonly evidence_status?: { readonly state: string; readonly detail: string };
}

/** A completed Operation whose private R2 evidence bundle passed the wire validator. */
export interface VerifiedHostedOperation extends HostedOperation {
  readonly status: "completed";
  readonly evidence: HostedEvidenceBundle;
  readonly evidence_status?: never;
}

/**
 * The self-declared Spend cap forwarded on `POST /simulate` as `spend.budget` (bead kestrel-4dz5).
 * `budget` is a finite, non-negative USD ceiling the caller OPTS INTO — the CLI never auto-pays; it
 * declares a cap and the platform honors it (clamping DOWN to its trial ceiling, never up). This is
 * the one documented trigger for the paid boundary from the CLI face. `ceiling` (optional per-turn
 * cap) is reserved on the wire but not yet surfaced by a CLI flag.
 */
export interface Spend {
  readonly budget: number;
  readonly ceiling?: number;
}

/**
 * A SPEND-BOUNDARY suspension surfaced as DATA (kestrel-e8e7, boundary UX kestrel-54j). A
 * bounded increment paused at the Spend boundary (`status:"suspended"`, `resume.requires_settlement`)
 * with a settleable {@link WireOffer} attached inline. It is a legitimate 402-class boundary — the
 * CLI surfaces the itemized Offer + the resume affordance and exits `5` PAYMENT_REQUIRED, never a
 * misleading 5xx. Distinct from the pre-drive data-access 402 ({@link WireOfferResponse}): the
 * suspended body carries the Offer + `resume` inline and no `settlement_methods`.
 */
export interface SettlementRequired {
  readonly operationId: string;
  readonly offer: WireOffer;
  readonly resume: WireResumeAffordance;
  /**
   * The wired settle rail this Offer advertises (bead kestrel-markets-1gr8): the EXACT
   * `POST /api/operations/{id}/settlement` URL an agent pays on. Present from wave-3 platform
   * responses; absent on a response minted before the rail was wired, in which case the caller
   * surfaces the resume affordance alone (never a fabricated settle URL).
   */
  readonly settlement?: WireSettlementAffordance;
}

/**
 * The three terminal outcomes of {@link HostedFunnel.simulate}: a completed Operation
 * (`gated:false`), the pre-drive data-access 402 Offer (`gated:true`), or the Spend-boundary
 * suspension surfaced as a payment-required Offer (`settlementRequired`). The last two both
 * exit `5`; only the first proceeds to the proof read.
 */
export type HostedSimulation =
  | Gated<VerifiedHostedOperation>
  | { readonly settlementRequired: SettlementRequired };

/** `GET /proof/{id}` body — the signed grade. `result.metrics` carry the graded line. */
export interface HostedProof {
  readonly proof_id: string;
  readonly kind: "grade";
  readonly result: {
    readonly subjectSessionId: string;
    readonly metrics: Record<string, number>;
    /**
     * The platform's computed explanation of WHY a run placed no orders (kestrel-kglw) — the
     * highest-value teaching moment of a 0-order run. Stamped by the grader on the certified
     * grade (gate unsatisfied / trigger never crossed / budget absent / series unavailable, …),
     * so the CLI SURFACES it rather than recomputing it. Optional: absent on a run that traded.
     */
    readonly diagnostics?: { readonly never_fired_reason?: string };
  };
  readonly root: string;
  readonly signature: string;
  readonly verification: {
    readonly verified: true;
    readonly status: "verified";
    readonly algorithm: "ed25519";
    readonly kid: string;
    readonly epoch: number;
  };
  readonly evView?: string;
  readonly issuedAt?: string;
}

/**
 * One PUBLISHED grade verify key from `/.well-known/kestrel-markets` `grade_verify_keys`
 * — public material only. `status:"active"` currently signs; `accept-only` is retained
 * through a rotation-overlap window and still verifies. A `verify` selects the key by the
 * proof's own `kid` and trusts BOTH statuses; a `kid` absent from this set is UNVERIFIABLE
 * (retired), never a false tampering verdict.
 */
export interface PublishedVerifyKey {
  readonly kid: string;
  readonly epoch: number;
  readonly algorithm: "ed25519";
  readonly public_jwk: { readonly kty: string; readonly crv: string; readonly x: string };
  readonly status: "active" | "accept-only";
}

/**
 * The lean signature envelope of a PUBLISHED proof (`GET /proof/{id}`), the subset the
 * zero-trust {@link ../commands/verify.ts verify} verb re-checks locally: the content `root`
 * the signature attests over, the base64url Ed25519 `signature`, and the `kid`/`epoch` that
 * select the published verify key. `verificationClaim` is the proof body's OWN self-asserted
 * verdict — carried only so `verify` can prove it does NOT trust it (it re-verifies the
 * crypto independently).
 */
export interface PublishedProof {
  readonly proof_id: string;
  readonly root: string;
  readonly signature: string;
  readonly kid: string;
  readonly epoch: number;
  /** The proof body's OWN self-asserted `verification.verified` — for display/contrast only. */
  readonly verificationClaim: boolean;
  /**
   * The signature-covered grade fields the platform published alongside the proof (OSS-ADR-0051),
   * when present. When the proof carries them, `verify` recomputes `certifiedRoot(coveredFields)` and
   * binds it to the signed `root` — catching a tampered PUBLISHED NUMBER whose old signature still
   * validates over the old root. ADDITIVE-OPTIONAL: absent (or malformed) reads UNKNOWN and the
   * recompute leg is skipped, leaving the signature check as the guarantee (never a silent false).
   */
  readonly coveredFields?: CertifiedGradeCoveredFields;
}

/**
 * The OPEN-RECOMPUTATION evidence bundle of a PUBLISHED proof (`GET /proof/{id}/evidence`), the
 * durable inputs an outsider needs to re-project the Blotter LOCALLY and reproduce the hosted
 * certified result byte-identically (platform launch gate G10; OSS half kestrel-8kvs, platform half
 * kestrel-markets-tk82; ADR-0011 "OSS certification = the open re-projection computation").
 *
 * THE CONTRACT (`kestrel.evidence-bundle/v1`) — agreed byte-for-byte with the platform half:
 *   - `bus`     — the full canonical session-Bus JSONL text (the recorded truth; the Bus is the
 *     substrate, the Blotter its deterministic projection). The CLI folds it with the SAME published
 *     projector (`readBusText` → `project` → `serialize`) the platform ran.
 *   - `blotter` — the platform's PUBLISHED canonical Blotter serialization: the EXACT bytes of
 *     `serialize(project(bus))` (deterministic machine-JSON, lexicographic keys, single trailing
 *     newline — ADR-0011). This is the comparand `certify` byte-compares its local re-projection
 *     against; equality is the open-recomputation determinism leg (L1 minimum).
 *   - `engine_version` / `fill_model` — the pinned versions the run was graded under, carried so the
 *     later judged-Grade recomputation leg (L3) can pin the same code. Optional at L1.
 *
 * The bundle carries NO signature or private material — it is the public, re-projectable input for a
 * FREE-TIER proof. Cross-repo determinism: same published npm package + same `bus` ⇒ byte-identical
 * `serialize(project(bus))` ⇒ it must equal `blotter`.
 */
export interface PublishedEvidenceBundle {
  readonly proof_id: string;
  readonly bus: string;
  readonly blotter: string;
  readonly engine_version?: string;
  readonly fill_model?: string;
  /**
   * The PUBLISHED grade numbers (OSS-ADR-0051), when the bundle carries them: `certify` grades the
   * re-projected tape (`grade(reproject(bus))`) and binds the result to these numbers — closing the
   * numbers↔tape leg. ADDITIVE-OPTIONAL (absent → the leg reads UNKNOWN, never a fabricated pass).
   */
  readonly publishedGrade?: GradeResult;
  /**
   * The signature-covered grade fields (OSS-ADR-0051), when present: `certify` recomputes
   * `certifiedRoot(coveredFields)` and binds it to {@link root} — closing the numbers↔root leg. A
   * tampered published number breaks this recompute (RED). ADDITIVE-OPTIONAL.
   */
  readonly coveredFields?: CertifiedGradeCoveredFields;
  /** The published signed grade `root` (with its `sha256:` display prefix), when present — the comparand
   * the `certifiedRoot(coveredFields)` recompute binds to. ADDITIVE-OPTIONAL. */
  readonly root?: string;
}

/* ───────────────────────── slug derivation + aliases ────────────────────── */

/**
 * Deterministic, stable kebab-case of a scenario title: lowercase, fold accents, collapse
 * every run of non-alphanumerics to a single `-`, trim leading/trailing `-`. Pure — the
 * same title always slugs to the same value across releases (the shareable-command
 * property). `"Meme-stock short squeeze"` → `"meme-stock-short-squeeze"`.
 */
export function slugifyTitle(title: string): string {
  return title
    .normalize("NFKD")
    .replace(/[̀-ͯ]/g, "") // strip combining marks
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

/**
 * Marketing → catalog slug aliases (site-is-spec). Public copy is an executable contract,
 * even when its memorable slug differs from the catalog title's deterministic slug. These
 * explicit aliases preserve the byte-for-byte public command while the result still names
 * the canonical catalog slug. Additive: a new marketing name never changes an existing
 * catalog slug, and an unknown name still fails closed rather than selecting a default.
 */
export const SCENARIO_ALIASES: Readonly<Record<string, string>> = {
  "wsb-gme-meme-short-squeeze": "meme-stock-short-squeeze",
  "wsb-gme-short-squeeze": "meme-stock-short-squeeze",
  // The site's scenario-card slugs (kestrel-585: the cards are the acceptance tests —
  // every card's `sim <slug>` must run). Cards whose slug already equals the catalog's
  // own (fomc-rate-decision-whipsaw, mean-reversion-range-fade, multi-day-trend-swing,
  // small-cap-spike-and-fade) need no row here.
  "covid-crash-march-2020": "pandemic-volatility-crash",
  "chip-earnings-blowout": "ai-chip-earnings-blowout",
  "intraday-opening-drive": "intraday-opening-drive-momentum",
};

/** A slug resolved against the catalog: the matched entry + the canonical slug + whether
 *  an alias was used to reach it. */
export interface ResolvedScenario {
  readonly entry: HostedCatalogEntry;
  readonly canonicalSlug: string;
  readonly requestedSlug: string;
  readonly viaAlias: boolean;
}

/**
 * Resolve a lowercase scenario slug against the catalog (exact slug of the title, after
 * alias expansion). Returns `null` when no title slugs to it — the caller turns that into
 * a loud NOT_FOUND with near-misses, never a silent default.
 */
export function resolveScenarioSlug(
  slug: string,
  entries: readonly HostedCatalogEntry[],
): ResolvedScenario | null {
  const canonical = SCENARIO_ALIASES[slug] ?? slug;
  const viaAlias = canonical !== slug;
  for (const entry of entries) {
    if (slugifyTitle(entry.title) === canonical) {
      return { entry, canonicalSlug: canonical, requestedSlug: slug, viaAlias };
    }
  }
  return null;
}

/** The scenario slug of every catalog entry, in catalog order (menu + near-miss source). */
export function catalogSlugs(entries: readonly HostedCatalogEntry[]): string[] {
  return entries.map((e) => slugifyTitle(e.title));
}

/**
 * Up to `limit` near-miss slugs for an unknown input, ranked deterministically by
 * shared hyphen-token count, then common-prefix length, then alphabetically. A pure
 * suggestion helper for the NOT_FOUND hint — never throws.
 */
export function nearMissSlugs(
  input: string,
  entries: readonly HostedCatalogEntry[],
  limit = 3,
): string[] {
  const inputTokens = new Set(input.split("-").filter(Boolean));
  const scored = catalogSlugs(entries).map((slug) => {
    const shared = slug.split("-").filter((t) => inputTokens.has(t)).length;
    let prefix = 0;
    while (prefix < slug.length && prefix < input.length && slug[prefix] === input[prefix]) prefix += 1;
    return { slug, shared, prefix };
  });
  scored.sort((a, b) => b.shared - a.shared || b.prefix - a.prefix || (a.slug < b.slug ? -1 : 1));
  return scored.slice(0, limit).map((s) => s.slug);
}

/* ──────────────────────────── the funnel client ────────────────────────── */

export interface HostedFunnelOptions {
  readonly baseUrl: string;
  readonly fetch?: FetchLike;
}

/** The hosted sim-funnel HTTP client. Fetch-injectable (tests pass an in-process face). */
export class HostedFunnel {
  private readonly base: string;
  private readonly fetch: FetchLike;

  constructor(opts: HostedFunnelOptions) {
    this.base = opts.baseUrl.replace(/\/+$/, "");
    const f = opts.fetch ?? (globalThis.fetch as FetchLike | undefined);
    if (f === undefined)
      throw new CliError({
        code: "RUNTIME_UNAVAILABLE",
        exit: EXIT.RUNTIME_UNAVAILABLE,
        message: "global fetch unavailable — need node ≥18 or bun for `kestrel sim`",
      });
    this.fetch = f;
  }

  /**
   * The absolute URL of the PUBLISHED verify-set (`/.well-known/kestrel-markets` `grade_verify_keys`),
   * resolved at this client's API base. `verify` names it on a non-VERIFIED verdict so a skeptical
   * reader can independently re-adjudicate the signature against the same key material, instead of
   * guessing where the key is published (bead kestrel-markets-5bjn/fjq7).
   */
  get verifyKeysUrl(): string {
    return `${this.base}${VERIFY_KEYS_PATH}`;
  }

  /** `GET /catalog` — the hosted catalog (public, no auth). */
  async catalog(): Promise<HostedCatalog> {
    const res = await this.fetch(`${this.base}/catalog`, { headers: { accept: "application/json" } });
    if (!res.ok) throw httpErr(res.status, "GET /catalog failed");
    const body = (await res.json()) as HostedCatalog | HostedCatalogEntry[];
    // Accept both `{entries:[…]}` (canonical) and a bare array, fail-closed on anything else.
    const entries = Array.isArray(body) ? body : body?.entries;
    if (!Array.isArray(entries)) throw httpErr(502, "GET /catalog returned no entries[]");
    return { entries: verifiedCatalogEntries(entries) };
  }

  /**
   * `POST /capabilities/trial` — mint an anonymous trial capability.
   *
   * `opts.copyToken`, when present, is forwarded VERBATIM as the single opaque header
   * `X-Kestrel-Copy-Token` — exactly like the `Idempotency-Key` this client already
   * forwards. The OSS CLI never parses, interprets, or documents its meaning: a
   * copy-token is an opaque pass-through nonce whose attribution semantics live entirely
   * in the managed platform (growth-wave0 §n04e.1 repo-boundary ruling). Absent → the
   * header is omitted and behaviour is unchanged.
   */
  async mintTrial(opts: { readonly copyToken?: string } = {}): Promise<HostedTrial> {
    const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json" };
    if (opts.copyToken !== undefined && opts.copyToken !== "") headers["x-kestrel-copy-token"] = opts.copyToken;
    const res = await this.fetch(`${this.base}/capabilities/trial`, {
      method: "POST",
      headers,
      body: JSON.stringify({}),
    });
    if (!res.ok) throw httpErr(res.status, "trial capability mint failed");
    return verifiedHostedTrial(await safeJson(res));
  }

  /**
   * `GET /.well-known/kestrel-markets` → the PUBLISHED grade verify-set (`grade_verify_keys`):
   * the public Ed25519 halves (`{kid, epoch, algorithm, public_jwk, status}`) an anonymous
   * third party uses to INDEPENDENTLY re-verify a certified grade's signature. This is the
   * zero-trust verification input for the `verify` verb — the CLI fetches these keys itself
   * and never trusts a proof body's own `verification.verified` self-assertion.
   */
  async verifyKeys(opts: { readonly copyToken?: string } = {}): Promise<PublishedVerifyKey[]> {
    const headers: Record<string, string> = { accept: "application/json" };
    if (opts.copyToken !== undefined && opts.copyToken !== "") headers["x-kestrel-copy-token"] = opts.copyToken;
    const res = await this.fetch(`${this.base}${VERIFY_KEYS_PATH}`, { headers });
    if (!res.ok) throw httpErr(res.status, `GET ${VERIFY_KEYS_PATH} failed`);
    return verifiedVerifyKeys(await safeJson(res));
  }

  /**
   * `GET /proof/{id}` — fetch a PUBLISHED proof's signature envelope for independent
   * verification. Unlike {@link proof}, this takes NO Operation/receipt to cross-link (the
   * caller is verifying a proof it did not necessarily produce), so it validates only the
   * lean shape the local signature check needs: `root`, `signature`, and the `verification`
   * key selector (`kid`/`epoch`). A 404 is surfaced as `null` (NOT_FOUND is a verdict, not a
   * crash). The `copyToken` is forwarded opaquely on the read, if supplied.
   */
  async publishedProof(id: string, opts: { readonly copyToken?: string } = {}): Promise<PublishedProof | null> {
    const headers: Record<string, string> = { accept: "application/json" };
    if (opts.copyToken !== undefined && opts.copyToken !== "") headers["x-kestrel-copy-token"] = opts.copyToken;
    const res = await this.fetch(`${this.base}/proof/${encodeURIComponent(id)}`, { headers });
    if (res.status === 404) return null;
    if (!res.ok) throw httpErr(res.status, `GET /proof/${id} failed`);
    return verifiedPublishedProof(await safeJson(res), id);
  }

  /**
   * `GET /proof/{id}/evidence` — fetch a PUBLISHED proof's OPEN-RECOMPUTATION evidence bundle
   * (the {@link PublishedEvidenceBundle} the `certify` verb re-projects locally, gate G10). A 404
   * is surfaced as `null` (a proof with no re-projectable free-tier bundle is a verdict, not a
   * crash — e.g. a paid episode or a retention-expired artifact). The untrusted body is validated
   * before it is handed back: a 2xx is not proof of a usable bundle. The `copyToken` is forwarded
   * opaquely, exactly like the other reads.
   */
  async evidenceBundle(id: string, opts: { readonly copyToken?: string } = {}): Promise<PublishedEvidenceBundle | null> {
    const headers: Record<string, string> = { accept: "application/json" };
    if (opts.copyToken !== undefined && opts.copyToken !== "") headers["x-kestrel-copy-token"] = opts.copyToken;
    const res = await this.fetch(`${this.base}/proof/${encodeURIComponent(id)}/evidence`, { headers });
    if (res.status === 404) return null;
    if (!res.ok) throw httpErr(res.status, `GET /proof/${id}/evidence failed`);
    return verifiedEvidenceBundle(await safeJson(res), id);
  }

  /**
   * `POST /simulate` — run the scenario under `source`. Returns the COMPLETED Operation
   * directly (gated:false), the structured pre-drive 402 Offer as DATA (gated:true), or a
   * Spend-boundary suspension surfaced as a payment-required Offer (settlementRequired) —
   * never a browser redirect, and never a misleading 5xx at a legitimate settlement boundary.
   */
  async simulate(
    req: { readonly source: string; readonly artifactId: string; readonly spend?: Spend },
    bearer: string,
  ): Promise<HostedSimulation> {
    // The caller's self-declared Spend cap (`--budget`, bead kestrel-4dz5) rides `spend.budget`
    // — the ONLY documented 402 trigger on POST /simulate. Included ONLY when declared, so the
    // free default path is byte-identical to before. The platform HONORS a declared budget as a
    // cap (estimate≡meter; it clamps DOWN to its trial ceiling, never up): a tiny budget on a
    // metered dataset pauses the increment at the Spend boundary and returns the settleable Offer.
    const body = {
      source: req.source,
      dataset: { artifact_id: req.artifactId },
      ...(req.spend !== undefined ? { spend: req.spend } : {}),
    };
    const encoded = JSON.stringify(body);
    const idempotencyKey = idemKey("POST", "/simulate", body);
    let pendingOperationId: string | undefined;
    for (let attempt = 0; attempt < 2; attempt += 1) {
      const res = await this.fetch(`${this.base}/simulate`, {
        method: "POST",
        headers: {
          "content-type": "application/json",
          accept: "application/json",
          authorization: `Bearer ${bearer}`,
          "idempotency-key": idempotencyKey,
        },
        body: encoded,
      });
      if (res.status === 402) {
        if (pendingOperationId !== undefined)
          throw evidenceError("evidence-finalize replay crossed an unexpected payment boundary");
        const offer = asOfferResponse(await safeJson(res));
        if (offer === null) throw httpErr(402, "402 body was not a structured OfferResponse");
        return { gated: true, payment: offer };
      }
      if (!res.ok) throw await httpErrFrom(res, "POST /simulate failed");
      const value = await safeJson(res);
      // The SPEND BOUNDARY (kestrel-e8e7): the increment paused at `status:"suspended"` with a
      // settleable Offer + a `requires_settlement` resume inline (HTTP 201, not 402). It is a
      // legitimate 402-class boundary, so surface the Offer as DATA (exit 5), NOT the evidence
      // verifier's "operation is not completed" 502. A suspension WITHOUT a settleable Offer
      // (the bare-id fallback / a genuinely incomplete op) is NOT one and falls through to the
      // fail-closed evidence check below — this never blanket-maps every suspension to a 402.
      const settlement = settlementRequiredOperation(value);
      if (settlement !== null) {
        if (pendingOperationId !== undefined)
          throw evidenceError("evidence-finalize replay crossed an unexpected settlement boundary");
        return { settlementRequired: settlement };
      }
      const pending = evidencePendingOperation(value);
      if (pending !== null) {
        if (pendingOperationId !== undefined && pending.operation_id !== pendingOperationId)
          throw evidenceError("evidence-finalize replay changed Operation identity");
        pendingOperationId = pending.operation_id;
        if (attempt === 1)
          throw evidenceError(
            `R2 evidence remained pending after one effect-once finalize-only retry: ${pending.evidence_status.detail}`,
          );
        continue;
      }
      const op = verifiedHostedOperation(value);
      if (pendingOperationId !== undefined && op.operation_id !== pendingOperationId)
        throw evidenceError("evidence-finalize replay changed Operation identity");
      return { gated: false, value: op };
    }
    throw evidenceError("R2 evidence finalize retry exhausted");
  }

  /** `GET /proof/{artifact_id}` — require a re-verified Grade bound to this Operation. */
  async proof(
    artifact: HostedArtifact,
    receipt: HostedGradeReceipt,
    operationId: string,
    bearer?: string,
  ): Promise<HostedProof> {
    const headers: Record<string, string> = { accept: "application/json" };
    if (bearer !== undefined) headers["authorization"] = `Bearer ${bearer}`;
    const res = await this.fetch(`${this.base}/proof/${encodeURIComponent(artifact.artifact_id)}`, { headers });
    if (!res.ok) throw httpErr(res.status, `GET /proof/${artifact.artifact_id} failed`);
    return verifiedHostedProof(await safeJson(res), artifact, receipt, operationId);
  }
}

/* ─────────────────────── receipt / metric extraction ───────────────────── */

/** The metered-receipt summary the renderer prints: count + the last budget_remaining. */
export interface ReceiptsSummary {
  readonly meteredCount: number;
  readonly budgetRemaining: number | null;
}

/** Summarize an Operation's receipts: how many metered draws, and the budget left after
 *  the last one (`null` when no metered receipt carried a budget). */
export function summarizeReceipts(receipts: readonly HostedReceipt[]): ReceiptsSummary {
  let meteredCount = 0;
  let budgetRemaining: number | null = null;
  for (const r of receipts) {
    if (r.kind === "metered") {
      meteredCount += 1;
      if (typeof r.budget_remaining === "number") budgetRemaining = r.budget_remaining;
    }
  }
  return { meteredCount, budgetRemaining };
}

/** The graded subset the renderer shows, read from the proof's `result.metrics`. */
export interface GradedMetrics {
  readonly orderCount: number;
  readonly fillCount: number;
  readonly realizedPnl: number;
}

/**
 * Read the grader's never-fired reason off a signed proof (kestrel-kglw): the certified explanation
 * of WHY a run placed no orders, so the CLI SURFACES it (never recomputes it). Returns the reason
 * string only when the proof carries a non-empty one; `undefined` otherwise (a run that traded, a
 * still-settling proof, or a platform that stamped none) — so a caller can gate its 0-order line on it.
 */
export function neverFiredReasonOf(proof: HostedProof | null): string | undefined {
  const reason = proof?.result.diagnostics?.never_fired_reason;
  return typeof reason === "string" && reason.trim().length > 0 ? reason : undefined;
}

/** Read `order_count` / `fill_count` / `realized_pnl` from a proof's metrics (0 when absent). */
export function gradedMetrics(metrics: Record<string, number> | undefined): GradedMetrics {
  const m = metrics ?? {};
  return {
    orderCount: numOr0(m["order_count"]),
    fillCount: numOr0(m["fill_count"]),
    realizedPnl: numOr0(m["realized_pnl"]),
  };
}

/** The unique Grade artifact guaranteed by {@link verifiedHostedOperation}. */
export function gradeArtifactOf(op: VerifiedHostedOperation): HostedArtifact {
  return op.artifacts.find((artifact) => artifact.kind === "grade")!;
}

/** The unique Grade receipt guaranteed by {@link verifiedHostedOperation}. */
export function gradeReceiptOf(op: VerifiedHostedOperation): HostedGradeReceipt {
  return op.receipts.find((receipt) => receipt.kind === "grade")! as HostedGradeReceipt;
}

/** The shareable web proof URL for a grade artifact (`kestrel.markets/proof/<id>`). */
export function proofWebUrl(artifactId: string): string {
  return `${PROOF_WEB_BASE}/proof/${artifactId}`;
}

/* ──────────────────────────────── helpers ──────────────────────────────── */

function numOr0(v: unknown): number {
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
}

const ARTIFACT_ID_RE = /^art_[0-9a-f]{24}$/;
const CONTENT_HASH_RE = /^sha256:[0-9a-f]{64}$/;
const OPERATION_ID_RE = /^op_[A-Za-z0-9][A-Za-z0-9_-]*$/;

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function evidenceError(detail: string): CliError {
  return httpErr(502, `POST /simulate did not return verified R2 evidence: ${detail}`);
}

function operationError(detail: string): CliError {
  return httpErr(502, `POST /simulate returned no verified public Grade artifact: ${detail}`);
}

function catalogError(detail: string): CliError {
  return httpErr(502, `GET /catalog returned invalid curated scenarios: ${detail}`);
}

function nonEmptyString(value: unknown): value is string {
  return typeof value === "string" && value.trim().length > 0;
}

/** Validate the untrusted curated catalog before any slug can select a dataset. */
function verifiedCatalogEntries(values: readonly unknown[]): HostedCatalogEntry[] {
  const entries = values.map((value, index) => {
    if (!isRecord(value)) throw catalogError(`entries[${index}] is not an object`);
    if (!nonEmptyString(value["artifact_id"])) throw catalogError(`entries[${index}].artifact_id is empty`);
    if (!nonEmptyString(value["title"])) throw catalogError(`entries[${index}].title is empty`);
    if (slugifyTitle(value["title"]).length === 0)
      throw catalogError(`entries[${index}].title does not produce a canonical slug`);
    if (!nonEmptyString(value["instrument"])) throw catalogError(`entries[${index}].instrument is empty`);
    const period = value["period"];
    if (!isRecord(period) || !nonEmptyString(period["start"]) || !nonEmptyString(period["end"]))
      throw catalogError(`entries[${index}].period must name start and end`);
    if (typeof value["free"] !== "boolean") throw catalogError(`entries[${index}].free must be boolean`);
    if (typeof value["description"] !== "string") throw catalogError(`entries[${index}].description must be string`);
    if (!Number.isSafeInteger(value["frame_count"]) || (value["frame_count"] as number) <= 0)
      throw catalogError(`entries[${index}].frame_count must be a positive integer`);
    if (value["content_hash"] !== undefined && typeof value["content_hash"] !== "string")
      throw catalogError(`entries[${index}].content_hash must be string when present`);
    return value as unknown as HostedCatalogEntry;
  });

  const slugs = new Set<string>();
  const artifacts = new Set<string>();
  for (const entry of entries) {
    const slug = slugifyTitle(entry.title);
    if (slugs.has(slug)) throw catalogError(`duplicate catalog canonical slug ${JSON.stringify(slug)}`);
    if (artifacts.has(entry.artifact_id))
      throw catalogError(`duplicate catalog artifact id ${JSON.stringify(entry.artifact_id)}`);
    slugs.add(slug);
    artifacts.add(entry.artifact_id);
  }
  return entries;
}

function trialError(detail: string): CliError {
  return httpErr(502, `POST /capabilities/trial returned an unusable trial capability: ${detail}`);
}

/**
 * The Simulation scopes the funnel's trial bearer actually exercises: `sim` authorizes
 * POST /simulate, `grade` authorizes GET /proof. `data` is deliberately NOT required —
 * /catalog is public (no auth), so demanding it would reject a correctly-narrowed trial.
 */
const REQUIRED_TRIAL_SCOPES = ["sim", "grade"] as const satisfies readonly Scope[];

/**
 * Scopes an anonymous trial may never carry (see {@link ../../protocol/index.ts TrialCapability}:
 * a trial excludes commerce, paper, broker, wallet and live; ADR-0034 keeps `broker`/`live`
 * behind the human-gated live boundary). One of these on a trial mint means the platform
 * handed back something that is not a trial — refuse it rather than spend it.
 */
const FORBIDDEN_TRIAL_SCOPES = ["paper", "broker", "live"] as const satisfies readonly Scope[];

/**
 * Validate the untrusted trial mint before its token is spent at POST /simulate. A 2xx is not
 * proof of a usable trial: the bearer must be a real token, and any ADVERTISED `scopes` are a
 * claim about what that bearer may do, so they are held to the trial boundary — every
 * Simulation scope the funnel exercises present, no authority scope a trial may never hold.
 * `scopes` is optional on the wire and an absent list asserts nothing, so it is not an
 * authority claim to screen; unknown scopes pass, because the vocabulary is extensible
 * (protocol/index.ts) and only authority is named here.
 */
function verifiedHostedTrial(value: unknown): HostedTrial {
  if (!isRecord(value)) throw trialError("body is missing");
  if (!nonEmptyString(value["capability"])) throw trialError("capability token is empty");
  const scopes = value["scopes"];
  if (scopes === undefined) return value as unknown as HostedTrial;
  if (!Array.isArray(scopes) || !scopes.every((s) => typeof s === "string"))
    throw trialError("scopes must be an array of strings when advertised");
  for (const required of REQUIRED_TRIAL_SCOPES)
    if (!scopes.includes(required))
      throw trialError(`advertised scopes lack the required Simulation scope ${JSON.stringify(required)}`);
  for (const forbidden of FORBIDDEN_TRIAL_SCOPES)
    if (scopes.includes(forbidden))
      throw trialError(`advertised scopes carry the forbidden authority scope ${JSON.stringify(forbidden)}`);
  return value as unknown as HostedTrial;
}

function proofError(detail: string): CliError {
  return httpErr(502, `GET /proof returned no verified Grade proof: ${detail}`);
}

interface EvidencePendingOperation {
  readonly operation_id: string;
  readonly status: "running";
  readonly evidence_status: { readonly state: "pending"; readonly detail: string };
  readonly offer: null;
  readonly resume: {
    readonly method: "POST";
    readonly path: "/api/simulate";
    readonly operation_id: string;
    readonly cursor: string;
    readonly requires_settlement: false;
    readonly detail: string;
  };
}

/** Only this exact non-terminal state is safe to replay: ticks/calls are already captured and
 * the platform promises the identical request retries private R2 finalization only. */
function evidencePendingOperation(value: unknown): EvidencePendingOperation | null {
  if (!isRecord(value) || value["status"] !== "running" || !OPERATION_ID_RE.test(String(value["operation_id"] ?? "")))
    return null;
  const status = value["evidence_status"];
  if (!isRecord(status) || status["state"] !== "pending" || typeof status["detail"] !== "string") return null;
  if (!Array.isArray(value["artifacts"]) || value["artifacts"].length !== 0) return null;
  if (!Array.isArray(value["receipts"]) || value["evidence"] !== undefined) return null;
  if (value["offer"] !== null) return null;
  const resume = value["resume"];
  if (
    !isRecord(resume) ||
    resume["method"] !== "POST" ||
    resume["path"] !== "/api/simulate" ||
    resume["operation_id"] !== value["operation_id"] ||
    typeof value["cursor"] !== "string" ||
    resume["cursor"] !== value["cursor"] ||
    resume["requires_settlement"] !== false ||
    typeof resume["detail"] !== "string"
  )
    return null;
  return value as unknown as EvidencePendingOperation;
}

/**
 * Recognize the SPEND-BOUNDARY suspension EXACTLY (kestrel-e8e7): `status:"suspended"`, a
 * well-formed operation id, a settleable inline {@link WireOffer} bound to it, and a
 * settlement-gated `resume` (canonical `POST /api/simulate`, `requires_settlement:true`) whose
 * `cursor` matches the body's. Anything short of all four — a bare `{offer_id, operation_id}`
 * fallback, a null offer, a non-canonical resume — returns `null`, so the caller's existing
 * fail-closed evidence path handles it (a genuinely incomplete op stays a typed failure).
 */
function settlementRequiredOperation(value: unknown): SettlementRequired | null {
  if (!isRecord(value) || value["status"] !== "suspended") return null;
  const opId = value["operation_id"];
  if (typeof opId !== "string" || !OPERATION_ID_RE.test(opId)) return null;
  const offer = asWireOffer(value["offer"]);
  if (offer === null || offer.operation_id !== opId) return null;
  const resume = asSettlementResume(value["resume"], opId);
  if (resume === null) return null;
  if (typeof value["cursor"] === "string" && resume.cursor !== value["cursor"]) return null;
  // The wired settle rail (bead kestrel-markets-1gr8) rides a top-level `settlement` affordance
  // on wave-3 responses. It is ADDITIVE — a response minted before the rail existed carries none,
  // so a null decode leaves the boundary detection intact and the render falls back to `resume`.
  const settlement = asSettlementAffordance(value["settlement"]);
  return { operationId: opId, offer, resume, ...(settlement !== null ? { settlement } : {}) };
}

function gradeArtifact(value: unknown): HostedArtifact {
  if (!isRecord(value) || value["kind"] !== "grade") throw operationError("kind must be grade");
  if (typeof value["artifact_id"] !== "string" || !ARTIFACT_ID_RE.test(value["artifact_id"]))
    throw operationError("artifact_id is malformed");
  if (typeof value["content_hash"] !== "string" || !CONTENT_HASH_RE.test(value["content_hash"]))
    throw operationError("content_hash is malformed");
  if (value["proof_url"] !== undefined && value["proof_url"] !== `/proof/${value["artifact_id"]}`)
    throw operationError("proof_url does not name the Grade artifact");
  return value as unknown as HostedArtifact;
}

function gradeReceipt(value: unknown, artifact: HostedArtifact): HostedGradeReceipt {
  if (!isRecord(value) || value["kind"] !== "grade") throw operationError("Grade receipt kind must be grade");
  if (value["root"] !== artifact.content_hash)
    throw operationError("Grade receipt root does not match the Grade artifact");
  if (typeof value["signature"] !== "string" || value["signature"].length === 0)
    throw operationError("Grade receipt signature is missing");
  return value as unknown as HostedGradeReceipt;
}

/** Validate one untrusted private R2 reference, including its expected semantic kind. */
function evidenceRef(value: unknown, expectedKind: HostedEvidenceKind, field: string): HostedEvidenceRef {
  if (!isRecord(value)) throw evidenceError(`${field} is missing`);
  if (value["kind"] !== expectedKind) throw evidenceError(`${field}.kind must be ${expectedKind}`);
  if (typeof value["artifact_id"] !== "string" || !ARTIFACT_ID_RE.test(value["artifact_id"]))
    throw evidenceError(`${field}.artifact_id is malformed`);
  if (typeof value["content_hash"] !== "string" || !CONTENT_HASH_RE.test(value["content_hash"]))
    throw evidenceError(`${field}.content_hash is malformed`);
  if (!Number.isSafeInteger(value["byte_length"]) || (value["byte_length"] as number) <= 0)
    throw evidenceError(`${field}.byte_length must be a positive integer`);
  if (value["classification"] !== "private") throw evidenceError(`${field}.classification must be private`);
  return value as unknown as HostedEvidenceRef;
}

/**
 * Turn the untrusted success body into the only Operation shape callers may treat as a
 * successful Simulation. A 2xx response is insufficient: completion and all durable R2
 * artifacts must be independently well-shaped, and a degraded `evidence_status` is never
 * accepted as a substitute for those references.
 */
export function verifiedHostedOperation(value: unknown): VerifiedHostedOperation {
  if (!isRecord(value) || !OPERATION_ID_RE.test(String(value["operation_id"] ?? "")))
    throw httpErr(502, "POST /simulate returned malformed Operation id");
  if (value["status"] !== "completed") throw evidenceError("operation is not completed");
  if ("evidence_status" in value) throw evidenceError("legacy evidence_status is not durable proof");
  const raw = value["evidence"];
  if (!isRecord(raw)) throw evidenceError("bundle is missing");
  const calls = raw["callRefs"];
  if (!Array.isArray(calls)) throw evidenceError("callRefs must be an array");
  if (calls.length !== 0)
    throw evidenceError(`inference-free sim unexpectedly captured ${calls.length} model call(s)`);
  const artifacts = value["artifacts"];
  if (!Array.isArray(artifacts)) throw operationError("artifacts must be an array");
  const grades = artifacts.filter((artifact) => isRecord(artifact) && artifact["kind"] === "grade");
  if (grades.length !== 1) throw operationError(`expected exactly one Grade artifact, received ${grades.length}`);
  const grade = gradeArtifact(grades[0]);
  if (!Array.isArray(value["receipts"])) throw operationError("receipts must be an array");
  const gradeReceipts = value["receipts"].filter((receipt) => isRecord(receipt) && receipt["kind"] === "grade");
  if (gradeReceipts.length !== 1)
    throw operationError(`expected exactly one Grade receipt, received ${gradeReceipts.length}`);
  gradeReceipt(gradeReceipts[0], grade);
  const evidence: HostedEvidenceBundle = {
    busRef: evidenceRef(raw["busRef"], "session_bus", "busRef"),
    gradeRef: evidenceRef(raw["gradeRef"], "certified_grade", "gradeRef"),
    callRefs: calls.map((ref, index) => evidenceRef(ref, "agent_call", `callRefs[${index}]`)),
    manifestRef: evidenceRef(raw["manifestRef"], "manifest", "manifestRef"),
  };
  return { ...(value as unknown as HostedOperation), status: "completed", evidence } as VerifiedHostedOperation;
}

/** Validate the public Grade projection and cross-link it to its Operation artifact. */
export function verifiedHostedProof(
  value: unknown,
  artifact: HostedArtifact,
  receipt: HostedGradeReceipt,
  operationId: string,
): HostedProof {
  if (!isRecord(value)) throw proofError("body is missing");
  if (value["proof_id"] !== artifact.artifact_id) throw proofError("proof_id does not match the Grade artifact");
  if (value["kind"] !== "grade") throw proofError("kind must be grade");
  if (value["root"] !== artifact.content_hash) throw proofError("root does not match the Grade artifact");
  if (value["signature"] !== receipt.signature) throw proofError("signature does not match the Grade receipt");
  const verification = value["verification"];
  if (
    !isRecord(verification) ||
    verification["verified"] !== true ||
    verification["status"] !== "verified" ||
    verification["algorithm"] !== "ed25519" ||
    typeof verification["kid"] !== "string" ||
    !Number.isSafeInteger(verification["epoch"])
  )
    throw proofError("signature did not re-verify");
  const result = value["result"];
  if (!isRecord(result) || result["subjectSessionId"] !== operationId)
    throw proofError("subjectSessionId does not match the Operation");
  const metrics = result["metrics"];
  if (!isRecord(metrics)) throw proofError("metrics are missing");
  for (const name of ["order_count", "fill_count"] as const) {
    if (!Number.isSafeInteger(metrics[name]) || (metrics[name] as number) < 0)
      throw proofError(`metric ${name} must be a nonnegative integer`);
  }
  if (typeof metrics["realized_pnl"] !== "number" || !Number.isFinite(metrics["realized_pnl"]))
    throw proofError("metric realized_pnl is missing or non-finite");
  return value as unknown as HostedProof;
}

function verifyKeysError(detail: string): CliError {
  return httpErr(502, `GET ${VERIFY_KEYS_PATH} returned an unusable verify-set: ${detail}`);
}

/**
 * Validate the untrusted `grade_verify_keys` set before a key can be trusted to verify a
 * proof. Each entry must name a `kid`, the ed25519 algorithm, a well-formed OKP/Ed25519
 * public JWK (with the base64url `x` scalar), and a known `status`. An entry that is
 * malformed is dropped fail-closed (a broken key can never MINT a false `verified`), never
 * a throw that would deny an otherwise-usable set; a totally malformed body throws.
 */
export function verifiedVerifyKeys(value: unknown): PublishedVerifyKey[] {
  const set = isRecord(value) ? value["grade_verify_keys"] : undefined;
  if (!Array.isArray(set)) throw verifyKeysError("grade_verify_keys is not an array");
  const out: PublishedVerifyKey[] = [];
  for (const k of set) {
    if (!isRecord(k)) continue;
    if (!nonEmptyString(k["kid"]) || k["algorithm"] !== "ed25519") continue;
    if (k["status"] !== "active" && k["status"] !== "accept-only") continue;
    const jwk = k["public_jwk"];
    if (!isRecord(jwk) || jwk["kty"] !== "OKP" || jwk["crv"] !== "Ed25519" || !nonEmptyString(jwk["x"])) continue;
    if (!Number.isSafeInteger(k["epoch"])) continue;
    out.push({
      kid: k["kid"],
      epoch: k["epoch"] as number,
      algorithm: "ed25519",
      public_jwk: { kty: "OKP", crv: "Ed25519", x: jwk["x"] as string },
      status: k["status"] as "active" | "accept-only",
    });
  }
  return out;
}

function publishedProofError(detail: string): CliError {
  return httpErr(502, `GET /proof returned an unverifiable proof body: ${detail}`);
}

/**
 * Decode the OPTIONAL signature-covered grade fields a published proof may carry (OSS-ADR-0051), so
 * `verify` can recompute `certifiedRoot(coveredFields)` and bind the published NUMBERS to the signed
 * `root`. FAIL-CLOSED / additive-optional: a wholly absent block, or one whose required members are
 * missing or ill-typed, returns `undefined` — the recompute leg reads UNKNOWN and is skipped, never a
 * fabricated pass. Only a WELL-FORMED covered-field set is admitted as a recompute basis; a present
 * block that decodes must carry every required member with the right shape. The structured members
 * (`result`, `supportPartition`, `counterfactual`) are opaque JSON — carried verbatim into the hash.
 */
function asCertifiedCoveredFields(value: unknown): CertifiedGradeCoveredFields | undefined {
  if (!isRecord(value)) return undefined;
  if (value["kind"] !== "grade") return undefined;
  if (!isRecord(value["result"])) return undefined;
  if (!nonEmptyString(value["evView"])) return undefined;
  if (!isRecord(value["supportPartition"])) return undefined;
  if (!nonEmptyString(value["pinnedDataRef"])) return undefined;
  if (!nonEmptyString(value["pinnedFillModelVersion"])) return undefined;
  if (!nonEmptyString(value["judge"])) return undefined;
  if (!nonEmptyString(value["issuedAt"])) return undefined;
  if (!Array.isArray(value["boundRoots"]) || !value["boundRoots"].every((r) => typeof r === "string")) return undefined;
  if (!nonEmptyString(value["kid"])) return undefined;
  if (!Number.isSafeInteger(value["epoch"])) return undefined;
  if (typeof value["replayable"] !== "boolean") return undefined;
  // Optional members: admitted only when the RIGHT type; a present-but-ill-typed optional voids the
  // whole block (fail-closed) rather than silently dropping a field the signature covered.
  if (value["pinnedProvenanceRef"] !== undefined && !nonEmptyString(value["pinnedProvenanceRef"])) return undefined;
  if (value["pinnedAuthorProvenanceRef"] !== undefined && !nonEmptyString(value["pinnedAuthorProvenanceRef"])) return undefined;
  return {
    kind: "grade",
    result: value["result"] as JsonValue,
    evView: value["evView"],
    supportPartition: value["supportPartition"] as JsonValue,
    pinnedDataRef: value["pinnedDataRef"],
    ...(value["pinnedProvenanceRef"] !== undefined ? { pinnedProvenanceRef: value["pinnedProvenanceRef"] as string } : {}),
    ...(value["pinnedAuthorProvenanceRef"] !== undefined ? { pinnedAuthorProvenanceRef: value["pinnedAuthorProvenanceRef"] as string } : {}),
    pinnedFillModelVersion: value["pinnedFillModelVersion"],
    judge: value["judge"],
    issuedAt: value["issuedAt"],
    boundRoots: value["boundRoots"] as string[],
    ...(value["counterfactual"] !== undefined ? { counterfactual: value["counterfactual"] as JsonValue } : {}),
    kid: value["kid"],
    epoch: value["epoch"] as number,
    replayable: value["replayable"],
  };
}

/**
 * Validate the signature envelope of an untrusted PUBLISHED proof — the fields the local signature
 * check consumes PLUS the optional signature-covered grade fields (OSS-ADR-0051). `verify` re-checks
 * the Ed25519 signature over `${GRADE_SIGN_PREFIX}.<root>` against the independently-published verify
 * key AND, when the proof carries the covered fields, recomputes `certifiedRoot(coveredFields)` and
 * binds it to the signed root — so a proof whose signature re-verifies but whose PUBLISHED NUMBERS no
 * longer rebuild that root is caught (formerly the platform-only root↔payload binding, now
 * outsider-runnable). A proof whose signature does not re-verify is UNVERIFIED regardless of what its
 * own `verification.verified` claims. The covered-field block is additive-optional (absent/malformed →
 * the recompute leg is UNKNOWN, never a fabricated pass).
 */
export function verifiedPublishedProof(value: unknown, id: string): PublishedProof {
  if (!isRecord(value)) throw publishedProofError("body is missing");
  if (value["proof_id"] !== undefined && value["proof_id"] !== id)
    throw publishedProofError("proof_id does not match the requested id");
  if (!nonEmptyString(value["root"])) throw publishedProofError("root is missing");
  if (!nonEmptyString(value["signature"])) throw publishedProofError("signature is missing");
  const verification = value["verification"];
  if (!isRecord(verification)) throw publishedProofError("verification block is missing");
  if (!nonEmptyString(verification["kid"])) throw publishedProofError("verification.kid is missing");
  if (!Number.isSafeInteger(verification["epoch"])) throw publishedProofError("verification.epoch is missing");
  const coveredFields = asCertifiedCoveredFields(value["coveredFields"]);
  return {
    proof_id: id,
    root: value["root"] as string,
    signature: value["signature"] as string,
    kid: verification["kid"] as string,
    epoch: verification["epoch"] as number,
    verificationClaim: verification["verified"] === true,
    ...(coveredFields !== undefined ? { coveredFields } : {}),
  };
}

function evidenceBundleError(detail: string): CliError {
  return httpErr(502, `GET /proof/{id}/evidence returned an unusable evidence bundle: ${detail}`);
}

/**
 * Decode the OPTIONAL published grade numbers a bundle may carry (OSS-ADR-0051), so `certify` can bind
 * `grade(reproject(bus))` to them. FAIL-CLOSED / additive-optional: absent, or a block whose
 * `subjectSessionId` is not a string or whose `metrics` are not all finite numbers, returns `undefined`
 * — the numbers↔tape leg reads UNKNOWN, never a fabricated bind.
 */
function asPublishedGrade(value: unknown): GradeResult | undefined {
  if (!isRecord(value)) return undefined;
  if (!nonEmptyString(value["subjectSessionId"])) return undefined;
  const metrics = value["metrics"];
  if (!isRecord(metrics)) return undefined;
  for (const v of Object.values(metrics)) if (typeof v !== "number" || !Number.isFinite(v)) return undefined;
  return { subjectSessionId: value["subjectSessionId"], metrics: metrics as Readonly<Record<string, number>> };
}

/**
 * Validate the untrusted OPEN-RECOMPUTATION evidence bundle before `certify` re-projects it. A 2xx
 * is insufficient: the re-projectable inputs must be well-shaped or the byte-identity check is
 * meaningless. Fail-closed (never a silent default): a missing `bus` or `blotter`, a wrong `schema`,
 * or a `proof_id` that does not name the requested id is a loud 502 — the CLI must never fabricate a
 * REPRODUCED verdict over a malformed bundle. The optional pinned-version fields are only shape-checked.
 */
export function verifiedEvidenceBundle(value: unknown, id: string): PublishedEvidenceBundle {
  if (!isRecord(value)) throw evidenceBundleError("body is missing");
  if (value["schema"] !== "kestrel.evidence-bundle/v1")
    throw evidenceBundleError(`schema must be "kestrel.evidence-bundle/v1"`);
  if (value["proof_id"] !== undefined && value["proof_id"] !== id)
    throw evidenceBundleError("proof_id does not match the requested id");
  if (!nonEmptyString(value["bus"])) throw evidenceBundleError("bus (session-Bus JSONL text) is missing");
  if (!nonEmptyString(value["blotter"])) throw evidenceBundleError("blotter (published Blotter bytes) is missing");
  if (value["engine_version"] !== undefined && typeof value["engine_version"] !== "string")
    throw evidenceBundleError("engine_version must be a string when present");
  if (value["fill_model"] !== undefined && typeof value["fill_model"] !== "string")
    throw evidenceBundleError("fill_model must be a string when present");
  const publishedGrade = asPublishedGrade(value["publishedGrade"]);
  const coveredFields = asCertifiedCoveredFields(value["coveredFields"]);
  return {
    proof_id: id,
    bus: value["bus"],
    blotter: value["blotter"],
    ...(typeof value["engine_version"] === "string" ? { engine_version: value["engine_version"] } : {}),
    ...(typeof value["fill_model"] === "string" ? { fill_model: value["fill_model"] } : {}),
    ...(publishedGrade !== undefined ? { publishedGrade } : {}),
    ...(coveredFields !== undefined ? { coveredFields } : {}),
    ...(nonEmptyString(value["root"]) ? { root: value["root"] } : {}),
  };
}

async function safeJson(res: Response): Promise<unknown> {
  try {
    return await res.json();
  } catch {
    return null;
  }
}

/** Map an HTTP status to the CLI exit taxonomy (mirrors {@link ./remote.ts httpErr}). */
function httpErr(status: number, m: string): CliError {
  const exit = status === 404 ? EXIT.NOT_FOUND : status >= 500 ? EXIT.RUNTIME_UNAVAILABLE : EXIT.GENERIC;
  return new CliError({ code: `HTTP_${status}`, exit, message: `${m} (HTTP ${status})` });
}

/** The per-field character bound on a passed-through server diagnostic — a hostile (or MITM'd) error
 *  page must not be able to balloon the CLI's error line. 2 KiB comfortably holds every real platform
 *  refusal `detail` (e.g. the full `[opening-short-uncovered]` teaching); anything longer is truncated
 *  with a marker, never dropped silently. */
const MAX_PROBLEM_FIELD = 2048;

/**
 * Parse a non-2xx body into {@link ProblemDetails}, or `undefined` when there is nothing problem-shaped
 * to surface. Accepts `application/problem+json` AND any JSON object bearing a `title` / `detail` / `code`
 * (or a remediation) — the platform's refusals are problem-shaped regardless of the exact content-type.
 * FAIL-SAFE: any absent / non-JSON / non-problem-shaped body returns `undefined` (the caller degrades to
 * the bare transport message) and this NEVER throws inside error handling.
 *
 * This is the funnel face's own copy of the parser, deliberately self-contained per the per-backend-helper
 * convention (this light node+bun funnel client, {@link ./remote.ts readProblemCode} for the M1 remote
 * backend, and {@link ../../client/index.ts readProblem} for the SDK client each carry their own). It
 * mirrors the SDK client's `readProblem` (kestrel-3w9r) so a typed `/simulate` refusal reads its full
 * teaching on the CLI's ONLY surface (kestrel-markets-dd1z).
 */
async function readProblem(res: Response): Promise<ProblemDetails | undefined> {
  try {
    const body: unknown = await res.json();
    if (body === null || typeof body !== "object") return undefined;
    const b = body as Record<string, unknown>;
    const str = (v: unknown): string | undefined => {
      if (typeof v !== "string" || v.length === 0) return undefined;
      return v.length > MAX_PROBLEM_FIELD ? `${v.slice(0, MAX_PROBLEM_FIELD)}… [truncated]` : v;
    };
    const title = str(b["title"]);
    const detail = str(b["detail"]);
    const code = str(b["code"]);
    const remediation = str(b["remediation"]) ?? str(b["remedy"]) ?? str(b["fix"]);
    if (title === undefined && detail === undefined && code === undefined && remediation === undefined) {
      return undefined; // nothing problem-shaped — degrade to the bare transport line
    }
    return {
      ...(title !== undefined ? { title } : {}),
      ...(detail !== undefined ? { detail } : {}),
      ...(code !== undefined ? { code } : {}),
      ...(remediation !== undefined ? { remediation } : {}),
      status: res.status,
    };
  } catch {
    return undefined; // absent / non-JSON / truncated body → degrade, never throw
  }
}

/**
 * {@link httpErr} ENRICHED with any `application/problem+json` (or problem-shaped JSON) diagnostics the
 * server sent on a non-2xx `/simulate` response — the reason the wire already carried. Before this, a
 * typed 422 (e.g. `code=validation_failed`, `[opening-short-uncovered]`, whose `detail` NAMES the
 * atomic-exec gap and offers remediation) collapsed to a bare `POST /simulate failed (HTTP 422)` on the
 * options quant's ONLY surface — the engine cure was invisible, indistinguishable from a silent choke
 * (kestrel-markets-dd1z). Now the server's `title` — `detail` is folded into the message (so the human
 * and text frames teach), the `remediation` becomes the hint line, and the structured {@link ProblemDetails}
 * ride on the error so `fail` can surface them additively on `--json`. The transport `exit`/`code` are
 * UNCHANGED (this is the render fix, not the code-taxonomy fix — that is kestrel-markets-huzy). FAIL-SAFE:
 * an absent / unparseable / non-problem body degrades to the bare {@link httpErr}, never throwing.
 */
async function httpErrFrom(res: Response, m: string): Promise<CliError> {
  const problem = await readProblem(res);
  const base = httpErr(res.status, m);
  if (problem === undefined) return base;
  const parts = [problem.title, problem.detail].filter((s): s is string => s !== undefined);
  const suffix = parts.length > 0 ? `: ${parts.join(" — ")}` : "";
  return new CliError({
    code: base.code,
    exit: base.exit,
    message: `${base.message}${suffix}`,
    ...(problem.remediation !== undefined ? { hint: problem.remediation } : {}),
    problem,
  });
}

export type { WireOfferResponse };
