/**
 * # grade/receipts — the pure PROJECTOR that lifts agent receipts off a Session bus (kestrel-a57.5)
 *
 * `projectReceipts(bus)` is the deterministic fold from one canonical Session {@link ../bus Bus} into the
 * {@link SessionReceipts} curation record — the immutable set of every agent PROPOSAL, DECLINE, and
 * STAND-DOWN taken in the session. It mirrors the {@link ../blotter Blotter} projector's discipline
 * (ADR-0011): **a pure function of the bus** — same bus in ⇒ byte-identical receipts out, no engine-state
 * scrape, no wall clock, no RNG.
 *
 * **Curation honesty (the a57.5 doctrine).** The projector records EVERY decision, not only the armed ones.
 * A survivor-only record cannot grade curation, so declines and stand-downs are MANDATORY rows — the record
 * carries the plans the agent declined and stood down, alongside the plans it armed.
 *
 * **How a decision rides the bus (today, and the seam).** An agent decision is carried on the existing
 * **CONTROL** stream — the L2→L1 author-intent channel, whose `type` vocabulary is open (bus/types) — with
 * `type ∈ {propose, decline, stand_down}`, `target` naming the plan, and the structured
 * {@link DecisionDescriptor} (thesis axes, counterfactual, measurement versions, plan source) carried as
 * canonical JSON in the CONTROL `note`. This keeps the bus schema UNCHANGED. The note-as-JSON transport is a
 * documented SEAM: when a typed proposal envelope lands (a57.14) the descriptor moves onto a typed payload,
 * but the projector's contract — receipts are a pure projection of the bus — is unchanged. The **percept**
 * the agent acted on is projected today as the canonical bus PREFIX (`seq < proposed_seq`); the percept-v5
 * {@link ../frame Frame} is the future enrichment (see {@link ./receipt.ts} `PerceptProjection`).
 *
 * **Fail closed (AGENTS.md).** A CONTROL decision whose `note` is missing/malformed is not silently dropped:
 * it is recorded in {@link SessionReceipts.skipped} with a logged reason (a corruption cannot masquerade as a
 * well-formed decision, and it cannot fabricate a thesis). A well-formed decision whose PLAN text fails to
 * parse degrades to a STAND-DOWN receipt (plan = null) whose rationale records the escape — the curation row
 * is kept ("parse escape → STAND_DOWN").
 */

import type { BusEvent, ControlEvent, MetaEvent, Mode } from "../bus/index.ts";
import { serializeBus } from "../bus/index.ts";
import { canonicalizeJson } from "../canonical/json.ts";

import {
  ARMING_DECISIONS,
  COUNTERFACTUAL_KINDS,
  DECISION_KINDS,
  PlanParseError,
  ReceiptValidationError,
  createReceipt,
  sha256,
  type CounterfactualKind,
  type DecisionKind,
  type MeasurementVersions,
  type Percept,
  type PerceptProjection,
  type ProposalReceipt,
  type ThesisAxes,
} from "./receipt.ts";

// ─────────────────────────────────────────────────────────────────────────────
// The bus transport (CONTROL note-JSON) — a documented seam
// ─────────────────────────────────────────────────────────────────────────────

/** The CONTROL `type`s the projector reads as agent decisions. Other CONTROL actions (arm/disarm/author/
 * supersede/allocate) are ignored — they are not decisions with a thesis. */
export const DECISION_CONTROL_TYPES: ReadonlySet<string> = new Set<string>(["propose", "decline", "stand_down"]);

/** The projector that produces the percept-hash bytes today (the bus prefix). Pins meaning across
 * revisions; becomes `{ kind: "frame", ... }` when the percept-v5 Frame lands (a documented seam). */
const BUS_PREFIX_PROJECTION: PerceptProjection = { kind: "bus-prefix", renderer_version: "bus-prefix-v0" };

/**
 * The structured agent decision carried in a CONTROL event's `note` (canonical JSON). This is the SEAM
 * payload the projector decodes; when a typed proposal envelope lands (a57.14) these fields move onto a
 * typed bus payload without changing the projector's contract.
 */
export interface DecisionDescriptor {
  /** The fine-grained decision (accept/reparameterize/novel_plan/decline/stand_down). Must agree with the
   * CONTROL `type`: an arming kind under `propose`, `decline` under `decline`, `stand_down` under `stand_down`. */
  readonly decision_kind: DecisionKind;
  /** The plan SOURCE text under decision (canonicalized to the plan-hash by the builder), or `null` for a
   * candidate-less stand-down. Curation honesty: a declined plan carries its text here, never dropped. */
  readonly plan_text: string | null;
  readonly counterfactual: CounterfactualKind;
  readonly measurement_versions: MeasurementVersions;
  readonly thesis: ThesisAxes;
  /** The `ts` of the last information the agent used (≤ the decision's `ts`). Defaults to the last
   * perceived event's `ts` when absent. */
  readonly information_cutoff?: number;
  readonly rationale?: string | null;
}

/** Encode a decision descriptor as the canonical-JSON string a CONTROL `note` carries. The inverse of the
 * projector's decode; exported so a harness (and tests) write the exact bytes the projector reads. */
export function encodeDecision(d: DecisionDescriptor): string {
  return JSON.stringify(d);
}

// ─────────────────────────────────────────────────────────────────────────────
// The PRODUCER (kestrel-a57.18) — the driver-side half that WRITES what the projector reads
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The thesis a DRIVER-authored decision carries today: **UNSTATED**.
 *
 * The receipt schema demands a thesis on every row, but the Session action surface
 * ({@link import("../session/agent.ts").Action}) carries no structured thesis — an agent states its
 * intent as free-text `journal`/`note` prose, never as the gradable axes. The producer therefore states
 * NOTHING on the agent's behalf: every axis is `null` (no claim), `conviction` is `0` (the floor — the
 * receipt asserts no confidence the agent did not express), and the day-type distribution is the single
 * explicit `unstated` mass. This is the honest encoding of "no thesis was stated", never a plausible
 * thesis invented by the runtime (a fabricated claim would be exactly the "silently false" the fail-closed
 * doctrine forbids, and it would poison the very clustering the axes exist to enable).
 *
 * **Seam (a57.14).** When the typed proposal envelope lands, the agent states its OWN axes and they ride
 * here instead. A grader can tell the two apart mechanically: `day_type_probabilities.unstated === 1`.
 */
export const UNSTATED_THESIS: ThesisAxes = {
  when: { session_phase: null, regime: null, event: null, trend_state: null, volatility_state: null },
  where: { product: null, direction: null, strike: null, moneyness: null, book_state: null },
  how: { entry_method: null, size_contracts: null, reload: null, invalidation: null, exit: null },
  conviction: 0,
  day_type_probabilities: { unstated: 1 },
};

/** The commission model this engine applies to a simulated fill: **none**. A truthful, checkable pin —
 * the sim charges no commission, so a grader must not compare its EV against a commissioned run. */
export const DRIVER_COMMISSION_MODEL = "none-v0";

/** The bounded-risk envelope version the engine enforces (`budget NR` + the never-naked/intrinsic-floor
 * admission Gate). Bump when the envelope's arithmetic changes — EVs across versions refuse comparison. */
export const DRIVER_RISK_ENVELOPE = "budget-r-v1";

/** The grader version a receipt minted by this engine is measured by (the headline grader). */
export const DRIVER_GRADER = "grade-v1";

/**
 * Pin the measurement versions for a decision minted by the Session driver. Pure: a function of the run's
 * judge (`fill_model`) and the bus's fidelity only — no clock, no RNG. Everything a version could move a
 * number through is pinned, so a grade can refuse to compare EVs across versions (ADR-0006).
 */
export function driverMeasurementVersions(judge: {
  readonly fillModel: string;
  readonly fidelity: string;
}): MeasurementVersions {
  return {
    fill_model: judge.fillModel,
    commission_model: DRIVER_COMMISSION_MODEL,
    bus_fidelity: judge.fidelity,
    risk_envelope: DRIVER_RISK_ENVELOPE,
    grader: DRIVER_GRADER,
  };
}

/** One agent decision the driver is about to write onto the bus (the producer's input). `control` is the
 * coarse CONTROL marker; `decision_kind` is the fine kind — the projector re-checks the pairing
 * fail-closed, so a mismatch here is SKIPPED with a reason, never silently banked. */
export interface DecisionEmit {
  readonly control: "propose" | "decline" | "stand_down";
  /** The plan NAME the decision is about — the key the curation verdict reconciles against the bus's
   * arm record. Omitted only for a candidate-less stand-down. */
  readonly target?: string;
  readonly decision_kind: DecisionKind;
  /** The plan SOURCE under decision, or `null` for a candidate-less stand-down. Never dropped: a declined
   * or stood-down plan carries its text so the row is curation-honest. */
  readonly plan_text: string | null;
  readonly rationale?: string | null;
  readonly measurement_versions: MeasurementVersions;
  /** The agent's stated thesis. Defaults to {@link UNSTATED_THESIS} — the producer invents no claim. */
  readonly thesis?: ThesisAxes;
  /** The baseline the decision is measured against. Defaults to `null` (the flat/no-position baseline). */
  readonly counterfactual?: CounterfactualKind;
}

/**
 * Build the CONTROL event FIELDS that carry one agent decision receipt onto the Session bus — the
 * producer half of the a57.5 seam (kestrel-a57.18). The driver spreads the result into its own `emit`
 * (which stamps `ts`/`seq`), so the transport stays the unchanged CONTROL stream and the projector's
 * contract — receipts are a pure projection of the bus — is untouched.
 *
 * Pure and deterministic: same input ⇒ byte-identical `note`. It performs NO validation of its own — the
 * projector is the single validator (one definition, never a second one), and it fails closed on anything
 * this writes that does not cohere.
 */
export function decisionControlFields(d: DecisionEmit): {
  readonly type: string;
  readonly target?: string;
  readonly note: string;
} {
  const descriptor: DecisionDescriptor = {
    decision_kind: d.decision_kind,
    plan_text: d.plan_text,
    counterfactual: d.counterfactual ?? "null",
    measurement_versions: d.measurement_versions,
    thesis: d.thesis ?? UNSTATED_THESIS,
    ...(d.rationale !== undefined ? { rationale: d.rationale } : {}),
  };
  return {
    type: d.control,
    ...(d.target !== undefined ? { target: d.target } : {}),
    note: encodeDecision(descriptor),
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// The curation record
// ─────────────────────────────────────────────────────────────────────────────

/** One CONTROL decision the projector could NOT lift into a receipt — recorded, never silently dropped
 * (fail-closed). `seq` anchors it to the bus; `reason` is the logged cause. */
export interface SkippedDecision {
  readonly seq: number;
  readonly reason: string;
}

/** The session header a receipt record projects from — the opaque calendar token and the mode, read purely
 * from the opening META. */
export interface ReceiptSession {
  readonly date: string;
  readonly mode: Mode;
}

/**
 * The **curation-honesty verdict** — the structural answer to "can this record grade CURATION?" (the a57.5
 * doctrine, made mechanical rather than an honor system). A survivor-only record — arming decisions with no
 * declines/stand-downs — CANNOT distinguish a good selector from a lucky one, and a receipt set that does not
 * reconcile with the actual arm record on the bus is a self-reported narrative, not the curation-honest
 * counterpart to the {@link ../blotter Blotter}. When `gradable` is `false` a grader MUST REFUSE to score
 * curation; `reasons` names every fail-closed cause (empty iff gradable). Pure/deterministic — computed from
 * the bus and the projected receipts, never a wall clock or engine scrape.
 */
export interface CurationVerdict {
  /** `true` iff the record is safe to grade for curation: it is NOT survivor-only AND its arming receipts
   * reconcile with the bus's arm record. A grader refuses curation grading when this is `false`. */
  readonly gradable: boolean;
  /** Every fail-closed reason the record is not curation-gradable, in a stable order (empty iff gradable). */
  readonly reasons: readonly string[];
  /** Count of arming receipts (accept/reparameterize/novel_plan) — the survivors. */
  readonly armed: number;
  /** Count of the declines and stand-downs a survivor-only ledger would drop (MANDATORY rows). */
  readonly declined_or_stood_down: number;
  /** `true` iff the bus carries an arm/execution record (any `PLAN` armed transition or `CONTROL arm`);
   * when `false` the receipt channel is projected in isolation and reconciliation is not applicable. */
  readonly arm_record_present: boolean;
  /** Plan names armed on the bus (PLAN armed / CONTROL arm) with NO corresponding arming receipt — a
   * curation-hiding divergence (the engine armed something the receipt record does not own). Sorted. */
  readonly unreceipted_arms: readonly string[];
  /** Plan names an arming receipt claims it armed that the bus's arm record never armed — a self-reported
   * arm the execution log does not back. Sorted; only computed when {@link arm_record_present}. */
  readonly phantom_arms: readonly string[];
}

/**
 * The immutable curation record of one Session: every agent decision receipt, in bus order, plus the
 * decisions that failed to lift (recorded, never dropped). A pure projection of the bus — byte-stable under
 * {@link serializeReceipts}.
 */
export interface SessionReceipts {
  readonly session: ReceiptSession;
  /** Every agent decision receipt, ordered by `proposed_seq` (bus order). Carries armed, declined, AND
   * stood-down decisions — curation honesty. */
  readonly receipts: readonly ProposalReceipt[];
  /** Malformed CONTROL decisions, recorded with a logged reason (fail-closed; never silent). */
  readonly skipped: readonly SkippedDecision[];
  /** The structural verdict on whether this record can grade CURATION — survivor-only detection plus
   * reconciliation against the bus's arm record. A grader REFUSES curation grading when not `gradable`. */
  readonly curation: CurationVerdict;
}

// ─────────────────────────────────────────────────────────────────────────────
// Decode helpers (fail-closed)
// ─────────────────────────────────────────────────────────────────────────────

/** Parse a CONTROL `note` into a {@link DecisionDescriptor}, or throw with a precise reason. Total. */
function decodeDescriptor(note: string | undefined): DecisionDescriptor {
  if (note === undefined || note.length === 0) throw new ReceiptValidationError("CONTROL decision carries no note payload");
  let raw: unknown;
  try {
    raw = JSON.parse(note);
  } catch (err) {
    throw new ReceiptValidationError(`CONTROL decision note is not valid JSON: ${(err as Error).message}`);
  }
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
    throw new ReceiptValidationError("CONTROL decision note must be a JSON object");
  }
  const d = raw as Record<string, unknown>;
  if (typeof d.decision_kind !== "string" || !DECISION_KINDS.has(d.decision_kind as DecisionKind)) {
    throw new ReceiptValidationError("decision descriptor decision_kind is missing or unknown");
  }
  if (typeof d.counterfactual !== "string" || !COUNTERFACTUAL_KINDS.has(d.counterfactual as CounterfactualKind)) {
    throw new ReceiptValidationError("decision descriptor counterfactual is missing or unknown");
  }
  if (d.plan_text !== null && typeof d.plan_text !== "string") {
    throw new ReceiptValidationError("decision descriptor plan_text must be a string or null");
  }
  if (typeof d.measurement_versions !== "object" || d.measurement_versions === null) {
    throw new ReceiptValidationError("decision descriptor measurement_versions is missing");
  }
  if (typeof d.thesis !== "object" || d.thesis === null) {
    throw new ReceiptValidationError("decision descriptor thesis is missing");
  }
  return d as unknown as DecisionDescriptor;
}

/** The decision kinds the given CONTROL `type` admits (fail-closed pairing between coarse marker and fine
 * kind). Prevents a `propose` note that secretly declares a `decline`, etc. */
function admissibleKinds(controlType: string): ReadonlySet<DecisionKind> {
  switch (controlType) {
    case "propose":
      return ARMING_DECISIONS;
    case "decline":
      return new Set<DecisionKind>(["decline"]);
    case "stand_down":
      return new Set<DecisionKind>(["stand_down"]);
    default:
      return new Set<DecisionKind>();
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// projectReceipts
// ─────────────────────────────────────────────────────────────────────────────

/** The opening META of the bus, or a loud refusal (fail-closed): a well-formed bus opens with exactly one
 * META. Unlike the Blotter projector this does NOT require a GRADED bus — a receipt is authored on the input
 * tape, before any fill model grades it. */
function requireMeta(bus: readonly BusEvent[]): MetaEvent {
  const meta = bus.find((e): e is MetaEvent => e.stream === "META");
  if (meta === undefined) {
    throw new ReceiptValidationError("no META header in bus (a well-formed bus opens with exactly one META)");
  }
  return meta;
}

/**
 * Project one Session bus into its immutable {@link SessionReceipts} curation record. Pure and deterministic
 * (ADR-0011): no wall clock, no RNG, no engine-state scrape — the SAME bus yields byte-identical receipts
 * (proven by re-projecting and byte-comparing {@link serializeReceipts}). Records every armed/declined/
 * stood-down decision (curation honesty); records malformed decisions in `skipped` (fail-closed).
 */
export function projectReceipts(bus: readonly BusEvent[]): SessionReceipts {
  const meta = requireMeta(bus);
  const session: ReceiptSession = { date: meta.session_date, mode: meta.mode };

  const receipts: ProposalReceipt[] = [];
  const skipped: SkippedDecision[] = [];
  /** Plan names an arming RECEIPT claims (the `target` of a decision that projected to an arming kind). */
  const armedByReceipt = new Set<string>();

  for (const e of bus) {
    if (e.stream !== "CONTROL") continue;
    const ce = e as ControlEvent;
    if (!DECISION_CONTROL_TYPES.has(ce.type)) continue; // not a decision (arm/disarm/author/…)

    try {
      const d = decodeDescriptor(ce.note);
      if (!admissibleKinds(ce.type).has(d.decision_kind)) {
        throw new ReceiptValidationError(
          `CONTROL type ${JSON.stringify(ce.type)} is incompatible with decision_kind ${JSON.stringify(d.decision_kind)}`,
        );
      }

      // Percept — the canonical bus PREFIX the agent could have perceived (seq strictly before this
      // decision). This is the AVAILABLE representation today; the percept-v5 Frame is the seam.
      // The bus is append-only in monotonic `seq` order, so the prefix's last element is its watermark.
      const prefix = bus.filter((p) => p.seq < ce.seq);
      if (prefix.length === 0) {
        throw new ReceiptValidationError("decision precedes the session META (no percept prefix)");
      }
      const lastPerceived = prefix[prefix.length - 1]!;
      const prefixBytes = serializeBus(prefix);
      const percept: Percept = {
        sha256: sha256(prefixBytes),
        byte_count: Buffer.byteLength(prefixBytes, "utf8"),
        seq_watermark: lastPerceived.seq,
        projection: BUS_PREFIX_PROJECTION,
      };

      // The claimed information cutoff can never exceed the watermark of the percept the receipt pins:
      // the percept is the bus prefix (seq < proposed_seq), whose latest information is lastPerceived.ts.
      // A cutoff beyond it is a look-ahead provenance claim the pinned percept cannot back — fail closed.
      if (d.information_cutoff !== undefined && d.information_cutoff > lastPerceived.ts) {
        throw new ReceiptValidationError(
          `information_cutoff ${d.information_cutoff} exceeds the pinned percept's watermark ts ${lastPerceived.ts} ` +
            `(look-ahead: the decision claims information more recent than the percept it hashed)`,
        );
      }
      const information_cutoff = d.information_cutoff ?? lastPerceived.ts;

      // "parse escape → STAND_DOWN": a well-formed decision whose plan text does not parse degrades to a
      // stand-down receipt (plan = null) — the curation row is KEPT, with the escape recorded in rationale.
      let receipt: ProposalReceipt;
      try {
        receipt = createReceipt({
          session_date: meta.session_date,
          proposed_at: ce.ts,
          proposed_seq: ce.seq,
          information_cutoff,
          decision_kind: d.decision_kind,
          percept,
          plan_text: d.plan_text,
          counterfactual: d.counterfactual,
          measurement_versions: d.measurement_versions,
          thesis: d.thesis,
          rationale: d.rationale ?? null,
        });
      } catch (err) {
        if (err instanceof PlanParseError && d.plan_text !== null) {
          receipt = createReceipt({
            session_date: meta.session_date,
            proposed_at: ce.ts,
            proposed_seq: ce.seq,
            information_cutoff,
            decision_kind: "stand_down",
            percept,
            plan_text: null,
            counterfactual: d.counterfactual,
            measurement_versions: d.measurement_versions,
            thesis: d.thesis,
            rationale: `parse escape → stand-down: ${err.message}`,
          });
        } else {
          throw err;
        }
      }
      receipts.push(receipt);
      // Bind this arming receipt to the plan NAME it claims to have armed, for reconciliation against the
      // bus's real arm record. A decision that degraded to stand-down (parse escape) is NOT an arm.
      if (ARMING_DECISIONS.has(receipt.decision_kind) && ce.target !== undefined) {
        armedByReceipt.add(ce.target);
      }
    } catch (err) {
      const reason = err instanceof Error ? err.message : String(err);
      skipped.push({ seq: ce.seq, reason });
    }
  }

  receipts.sort((a, b) => a.proposed_seq - b.proposed_seq);
  skipped.sort((a, b) => a.seq - b.seq);
  const curation = assessCuration(bus, receipts, armedByReceipt);
  return { session, receipts, skipped, curation };
}

// ─────────────────────────────────────────────────────────────────────────────
// Curation-honesty verdict — survivor-only detection + arm reconciliation
// ─────────────────────────────────────────────────────────────────────────────

/** The plan names the bus's real ARM record armed: every `PLAN` armed transition and every `CONTROL arm`
 * with a `target`. This is the execution-side arm set the receipt channel must reconcile with — the same
 * canonical arm path that drives the {@link ../blotter Blotter}. Sorted, deduplicated. */
function armedOnBus(bus: readonly BusEvent[]): string[] {
  const armed = new Set<string>();
  for (const e of bus) {
    if (e.stream === "PLAN" && e.type === "lifecycle" && e.state === "armed") armed.add(e.plan);
    else if (e.stream === "CONTROL" && (e as ControlEvent).type === "arm") {
      const t = (e as ControlEvent).target;
      if (t !== undefined) armed.add(t);
    }
  }
  return [...armed].sort();
}

/**
 * Compute the {@link CurationVerdict} for a projected record (findings: survivor-only records and the
 * receipt/arm decoupling). Fail-closed and structural, NOT an honor system:
 *   • **survivor-only** — arming receipts with zero declines/stand-downs cannot grade curation; an empty
 *     record has nothing to grade. Either makes the record non-gradable.
 *   • **arm reconciliation** — when the bus carries a real arm record, the arming receipts and the bus's
 *     arm set must AGREE: an arm with no receipt hides curation; a receipt claiming an arm the bus never
 *     armed is a self-reported fiction. Either divergence makes the record non-gradable.
 * Pure and deterministic — a function of the bus and the receipts only.
 */
function assessCuration(
  bus: readonly BusEvent[],
  receipts: readonly ProposalReceipt[],
  armedByReceipt: ReadonlySet<string>,
): CurationVerdict {
  const armed = receipts.filter((r) => ARMING_DECISIONS.has(r.decision_kind)).length;
  const declined_or_stood_down = receipts.length - armed;

  const busArms = armedOnBus(bus);
  const arm_record_present = busArms.length > 0;
  const busArmSet = new Set(busArms);
  const unreceipted_arms = busArms.filter((name) => !armedByReceipt.has(name));
  const phantom_arms = arm_record_present ? [...armedByReceipt].filter((name) => !busArmSet.has(name)).sort() : [];

  const reasons: string[] = [];
  if (receipts.length === 0) {
    reasons.push("no decisions recorded — nothing to grade (curation requires declines/stand-downs)");
  } else if (declined_or_stood_down === 0) {
    reasons.push(
      `survivor-only: ${armed} arming decision(s) with no declines or stand-downs recorded — ` +
        `a survivor-only record cannot distinguish a good selector from a lucky one (declines/stand-downs are MANDATORY)`,
    );
  }
  if (unreceipted_arms.length > 0) {
    reasons.push(
      `arm record diverges: ${unreceipted_arms.length} plan(s) armed on the bus have no arming receipt ` +
        `[${unreceipted_arms.join(", ")}] — the engine armed what the receipt record does not own`,
    );
  }
  if (phantom_arms.length > 0) {
    reasons.push(
      `arm record diverges: ${phantom_arms.length} arming receipt(s) name a plan the bus never armed ` +
        `[${phantom_arms.join(", ")}] — a self-reported arm the execution log does not back`,
    );
  }

  return {
    gradable: reasons.length === 0,
    reasons,
    armed,
    declined_or_stood_down,
    arm_record_present,
    unreceipted_arms,
    phantom_arms,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Canonical serialization
// ─────────────────────────────────────────────────────────────────────────────

/** Serialize a {@link SessionReceipts} record to its canonical bytes: deterministic machine-JSON
 * (lexicographically ordered keys, compact, one trailing newline). Pure and total — the same record always
 * produces byte-identical text, the substrate a determinism check byte-compares. The key order is the one
 * {@link canonicalizeJson canonical byte order} (canonical/json); the single trailing newline is canon. */
export function serializeReceipts(record: SessionReceipts): string {
  return canonicalizeJson(record) + "\n";
}

/** The declined and stood-down receipts (the curation the agent left on the table) — the non-arming
 * decisions a survivor-only record would drop. A convenience over {@link SessionReceipts.receipts}. */
export function declinedAndStoodDown(record: SessionReceipts): readonly ProposalReceipt[] {
  return record.receipts.filter((r) => !ARMING_DECISIONS.has(r.decision_kind));
}
