/**
 * AEGIS SOAR (AS/SOAR/2026/001) — M3 CHUNK 2 LOCKED-DOOR STR FINALIZER battery.
 * Spec/plan: docs/regulatory/AEGIS_SOAR_M2_INVESTIGATION_PLAN.md lineage +
 * the chunk-2 session plan (Design B: maker prepare / checker approve-and-dispose
 * / scoped read). Drives M3 chunk 2 to DEV-VERIFIED, then HOLD at the dev gate.
 *
 * Built on the AS/PLATFORM/2026/002 proof-harness toolkit (scripts/lib/proof-
 * harness.ts) — real-route driving (login → csrf → POST/GET), owner (BYPASSRLS)
 * GROUND-TRUTH read-back, role-switched RLS-subject reads (rlsCountById), durable-
 * row read-back (NEVER the 200), raw-plaintext leak probes, per-actor RFC-5737 IPs,
 * carry-list user cleanup. Does NOT import server/index.ts or server/routes.ts (no
 * second boot); the immutable hash-chained audit rows are intentionally preserved.
 *
 * THE THREE BINDING CRITERIA — each proven behaviorally, PAIRED (Rule 18), each
 * verdict stating HOW it was checked (Rule 19):
 *   1. No fabricated FIA ack — a successful finalize disposes the case yet leaves a
 *      PREPARED (not-filed) STR with fia_reference/filed_at/acknowledged_at NULL
 *      (green); a direct DB attempt to set acknowledged_at on a PREPARED row is
 *      refused by the fsr_no_fabricated_ack CHECK (red).
 *   2. Two-eyes maker != checker — the preparer self-approving is 409 SELF_APPROVAL
 *      (red); a DIFFERENT senior checker disposes the case (green).
 *   3. Tipping-off — STR readable only by admin/risk_manager (+scoped auditor read)
 *      (positive); auditor cannot mutate (R1/R2 403) and anon is rejected (route
 *      negative); a sibling tenant sees the row NOWHERE (route 404 + rlsCountById=0,
 *      RLS negative) while the owner ground-truth confirms the row DOES exist in A.
 *
 * Plus the supporting controls: anti-stale hash (4), held-by-lock concurrency single-
 * winner (5), and the M3 state/DTO fence — disposition is HARD-DERIVED, never from
 * the payload (6).
 *
 * PII discipline: the free-text narrative + rationale are AES-256-GCM at rest; the
 * battery asserts the raw plaintext appears in NO encrypted column, NO event payload,
 * NO approval.reason, and NO audit-chain details.
 */

import {
  ProofRun,
  verdict,
  login,
  sessionClient,
  call,
  getCsrf,
  Jar,
  mkSeed,
  seedUser,
  deactivateUsers,
  ownerQuery,
  ownerCount,
  rlsCountById,
  leakProbe,
  allocateActorIps,
  endOwnerPool,
  randomUUID,
  createHash,
  sleep,
  type RouteResult,
  type Session,
  type Seed,
} from "./lib/proof-harness";

const SERVER = "http://127.0.0.1:5000";
const TENANT_A = "aegis-sovereign"; // the authed actor's real seeded dev tenant
const TENANT_B = "stanbic-ug-001"; // a DIFFERENT real seeded dev tenant
const base = Date.now();

// Unique free-text markers — must NEVER appear raw at rest or in any payload.
const RAW_NARRATIVE = `RAWNARRATIVE-DO-NOT-LEAK-${base}`;
const RAW_REASON = `RAWREASON-DO-NOT-LEAK-${base}`;
const RAW_NARRATIVE_TO = `RAWNARRATIVE-TO-DO-NOT-LEAK-${base}`;

const sha256 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex");
const ANY_HASH = sha256(`any-${base}`);
// Pull the StrGuardError reason out of the AegisException envelope:
//   { success:false, error:{ code, details:{ reason }, ... } }
function reasonOf(r: RouteResult): string | undefined {
  const error = (r.json as { error?: { details?: { reason?: unknown } } })?.error;
  const reason = error?.details?.reason;
  return typeof reason === "string" ? reason : undefined;
}

const run = new ProofRun(
  "AEGIS SOAR M3 chunk 2 — locked-door STR finalizer (3 routes) behavioral battery",
  "DEV",
);

// ── owner (BYPASSRLS) ground-truth read-back helpers ─────────────────────────
interface StrRow {
  id: string;
  tenant_id: string;
  case_id: string;
  status: string;
  subject_ref: string;
  encrypted_narrative: string | null;
  narrative_hash: string | null;
  fia_reference: string | null;
  filed_at: Date | null;
  acknowledged_at: Date | null;
  prepared_by_user_id: string;
  prepared_by_role: string;
  retention_until: Date;
}
async function strRow(tenantId: string, id: string): Promise<StrRow | undefined> {
  const rows = await ownerQuery<StrRow>(
    `SELECT id, tenant_id, case_id, status, subject_ref, encrypted_narrative, narrative_hash,
            fia_reference, filed_at, acknowledged_at, prepared_by_user_id, prepared_by_role, retention_until
       FROM fincrime_str_reports WHERE tenant_id=$1 AND id=$2`,
    [tenantId, id],
  );
  return rows[0];
}
async function strCountByCase(tenantId: string, caseId: string): Promise<number> {
  return ownerCount("fincrime_str_reports", "tenant_id=$1 AND case_id=$2", [tenantId, caseId]);
}
interface CaseRow {
  state: string;
  disposition: string | null;
  disposition_reason: string | null;
  dispositioned_at: Date | null;
  str_filed: boolean;
}
async function caseRow(tenantId: string, id: string): Promise<CaseRow | undefined> {
  const rows = await ownerQuery<CaseRow>(
    `SELECT state, disposition, disposition_reason, dispositioned_at, str_filed
       FROM fincrime_cases WHERE tenant_id=$1 AND id=$2`,
    [tenantId, id],
  );
  return rows[0];
}
interface ApprovalRow {
  id: string;
  approver_user_id: string;
  approver_role: string;
  approval_type: string;
  report_hash: string;
  reason: string | null;
}
async function approvalsForReport(tenantId: string, strReportId: string): Promise<ApprovalRow[]> {
  return ownerQuery<ApprovalRow>(
    `SELECT id, approver_user_id, approver_role, approval_type, report_hash, reason
       FROM fincrime_str_approvals WHERE tenant_id=$1 AND str_report_id=$2 ORDER BY approved_at`,
    [tenantId, strReportId],
  );
}
interface EventRow {
  event_type: string;
  from_state: string | null;
  to_state: string | null;
  payload_hash: string;
  audit_chain_entry_id: number | null;
}
async function eventsFor(tenantId: string, caseId: string): Promise<EventRow[]> {
  return ownerQuery<EventRow>(
    `SELECT event_type, from_state, to_state, payload_hash, audit_chain_entry_id
       FROM fincrime_case_events WHERE tenant_id=$1 AND case_id=$2 ORDER BY created_at`,
    [tenantId, caseId],
  );
}
interface EvidenceRow {
  evidence_type: string;
  opaque_ref: string | null;
  encrypted_note: string | null;
  payload_hash: string;
}
async function evidenceFor(tenantId: string, caseId: string): Promise<EvidenceRow[]> {
  return ownerQuery<EvidenceRow>(
    `SELECT evidence_type, opaque_ref, encrypted_note, payload_hash
       FROM fincrime_case_evidence WHERE tenant_id=$1 AND case_id=$2 ORDER BY created_at`,
    [tenantId, caseId],
  );
}
async function attemptCount(action: string, caseId: string): Promise<number> {
  const rows = await ownerQuery<{ n: number }>(
    "SELECT count(*)::int AS n FROM audit_chain_entries WHERE action=$1 AND resource=$2",
    [action, `fincrime_case:${caseId}`],
  );
  return Number(rows[0]?.n ?? 0);
}

// raw-narrative leak probe across every at-rest surface (str row + events + audit).
async function narrativeLeak(tenantId: string, strId: string, caseId: string, needle: string) {
  return leakProbe(needle, [
    { label: "str.encrypted_narrative", table: "fincrime_str_reports", col: "encrypted_narrative", whereCol: "id", whereVal: strId },
    { label: "event.event_payload", table: "fincrime_case_events", col: "event_payload", whereCol: "case_id", whereVal: caseId },
    { label: "audit.details", table: "audit_chain_entries", col: "details", whereCol: "resource", whereVal: `fincrime_case:${caseId}` },
  ]);
}
// raw-reason leak probe (evidence + events + approval.reason + audit).
async function reasonLeak(tenantId: string, strId: string, caseId: string, needle: string) {
  return leakProbe(needle, [
    { label: "evidence.encrypted_note", table: "fincrime_case_evidence", col: "encrypted_note", whereCol: "case_id", whereVal: caseId },
    { label: "event.event_payload", table: "fincrime_case_events", col: "event_payload", whereCol: "case_id", whereVal: caseId },
    { label: "approval.reason", table: "fincrime_str_approvals", col: "reason", whereCol: "str_report_id", whereVal: strId },
    { label: "audit.details", table: "audit_chain_entries", col: "details", whereCol: "resource", whereVal: `fincrime_case:${caseId}` },
  ]);
}

// ── synthetic case fixtures (precondition setup, not the system under test) ────
const createdCaseIds = new Set<string>();
async function seedCase(tenantId: string, tag: string, state: string): Promise<string> {
  const id = randomUUID();
  await ownerQuery(
    `INSERT INTO fincrime_cases (id, tenant_id, source_type, source_ref, source_signal_hash, subject_ref, state)
     VALUES ($1, $2, 'MANUAL_REFERRAL', $3, $4, $5, $6)`,
    [
      id,
      tenantId,
      `ext-${tag}-${base}`,
      sha256(`${tag}-${base}-${randomUUID()}`),
      `opaque:m3-${tag}-${base}`,
      state,
    ],
  );
  createdCaseIds.add(id);
  return id;
}
const seedPending = (t: string, tag: string) => seedCase(t, tag, "PENDING_MLCO_REVIEW");
const seedOpen = (t: string, tag: string) => seedCase(t, tag, "OPEN");

// ── seeded synthetic users (carry-list cleanup in finally) ────────────────────
const ips = allocateActorIps(6, base);
const makerSeed = mkSeed("risk_manager", "m3-maker", "rm", base); // the STR preparer
const adminSeed = mkSeed("admin", "m3-checker", "ad", base); // a DIFFERENT checker
const rm2Seed = mkSeed("risk_manager", "m3-checker2", "rm2", base); // 2nd valid checker (concurrency)
const audSeed = mkSeed("auditor", "m3-aud", "au", base); // scoped read; mutation denied
const bSeniorSeed = mkSeed("risk_manager", "m3-bsenior", "bs", base); // TENANT_B cross-tenant
const allSeeds: Seed[] = [makerSeed, adminSeed, rm2Seed, audSeed, bSeniorSeed];

async function main(): Promise<void> {
  console.log("=".repeat(82));
  console.log("AEGIS SOAR M3 chunk 2 — locked-door STR finalizer behavioral battery (DEV gate)");
  console.log("spec AS/SOAR/2026/001 | env: DEV |", new Date().toISOString());
  console.log("=".repeat(82));

  try {
    await seedUser(makerSeed, TENANT_A, "FINCRIME M3 BATTERY maker (test, delete ok)");
    await seedUser(adminSeed, TENANT_A, "FINCRIME M3 BATTERY checker-admin (test, delete ok)");
    await seedUser(rm2Seed, TENANT_A, "FINCRIME M3 BATTERY checker2 (test, delete ok)");
    await seedUser(audSeed, TENANT_A, "FINCRIME M3 BATTERY auditor (test, delete ok)");
    await seedUser(bSeniorSeed, TENANT_B, "FINCRIME M3 BATTERY tenant-B senior (test, delete ok)");

    const maker = await login(makerSeed, ips[0], SERVER);
    const admin = await login(adminSeed, ips[1], SERVER);
    const rm2 = await login(rm2Seed, ips[2], SERVER);
    const aud = await login(audSeed, ips[3], SERVER);
    const bSenior = await login(bSeniorSeed, ips[4], SERVER);
    const ANON_IP = ips[5];

    const mk = sessionClient(maker, SERVER);
    const ad = sessionClient(admin, SERVER);
    const r2 = sessionClient(rm2, SERVER);
    const au = sessionClient(aud, SERVER);
    const bs = sessionClient(bSenior, SERVER);

    const prep = (s: ReturnType<typeof sessionClient>, caseId: string, narrative: string) =>
      s.post(`/api/fincrime/cases/${caseId}/str`, { narrative });
    const fin = (s: ReturnType<typeof sessionClient>, caseId: string, body: unknown) =>
      s.post(`/api/fincrime/cases/${caseId}/finalize`, body);

    // ── LEG P1 — PREPARE POSITIVE (maker risk_manager) ───────────────────────
    {
      const c = await seedPending(TENANT_A, "prep-pos");
      const r = await prep(mk, c, RAW_NARRATIVE);
      await sleep();
      const strId = r.json?.strReportId as string;
      const row = strId ? await strRow(TENANT_A, strId) : undefined;
      const evs = await eventsFor(TENANT_A, c);
      const prepared = evs.filter((e) => e.event_type === "STR_PREPARED");
      const at = await attemptCount("FINCRIME_STR_PREPARE_ATTEMPT", c);
      const lk = await narrativeLeak(TENANT_A, strId, c, RAW_NARRATIVE);
      const nineYears = new Date(base + 9 * 365 * 24 * 3600 * 1000);
      const pass =
        r.status === 201 &&
        r.json?.status === "PREPARED" &&
        /^[0-9a-f]{64}$/.test(String(r.json?.narrativeHash)) &&
        r.json?.narrativeHash === sha256(RAW_NARRATIVE) &&
        row?.status === "PREPARED" &&
        row?.narrative_hash === sha256(RAW_NARRATIVE) &&
        row?.fia_reference === null &&
        row?.filed_at === null &&
        row?.acknowledged_at === null &&
        row?.encrypted_narrative !== null &&
        !row!.encrypted_narrative!.includes(RAW_NARRATIVE) &&
        row?.prepared_by_user_id === maker.userId &&
        row?.prepared_by_role === "risk_manager" &&
        new Date(row!.retention_until) > nineYears &&
        prepared.length === 1 &&
        at >= 1 &&
        lk.any === false;
      run.leg({
        id: "P1",
        scenario: "PREPARE POSITIVE — risk_manager prepares an STR on a PENDING_MLCO_REVIEW case",
        expectation:
          "HTTP 201 status PREPARED + 64-hex narrativeHash; durable STR PREPARED with ack cols (fia_reference/filed_at/acknowledged_at) NULL, encrypted_narrative set (raw ABSENT), prepared_by=maker, retention_until ~10y; one STR_PREPARED event; ≥1 PREPARE_ATTEMPT audit; raw narrative in NO at-rest surface",
        observed: `HTTP ${r.status}; resp.status=${r.json?.status}; row.status=${row?.status}; ack(fia/filed/ack)=${row?.fia_reference}/${row?.filed_at}/${row?.acknowledged_at}; encSet=${row?.encrypted_narrative !== null}&noLeakInEnc=${!row?.encrypted_narrative?.includes(RAW_NARRATIVE)}; preparedBy=${row?.prepared_by_user_id === maker.userId}; STR_PREPARED=${prepared.length}; attempt=${at}; leak{${JSON.stringify(lk.hits)}}`,
        verdict: pass
          ? verdict.verified("HTTP 201 + owner read-back: STR PREPARED, ack cols NULL, encrypted; raw-narrative leak probe negative on all 3 surfaces")
          : verdict.fail(),
        pass,
        signals: { http: r.status, rowStatus: row?.status, ackNull: row?.fia_reference === null && row?.filed_at === null && row?.acknowledged_at === null, hashMatch: row?.narrative_hash === sha256(RAW_NARRATIVE), leakAny: lk.any },
      });
    }

    // ── LEG P1b — PREPARE DENIAL auditor (paired with P1) ─────────────────────
    {
      const c = await seedPending(TENANT_A, "prep-aud-deny");
      const r = await prep(au, c, "x");
      await sleep();
      const n = await strCountByCase(TENANT_A, c);
      const pass = r.status === 403 && n === 0;
      run.leg({
        id: "P1b",
        scenario: "PREPARE DENIAL — authenticated auditor attempts prepare (role not admin/risk_manager)",
        expectation: "HTTP 403; ZERO STR rows for the case (paired positive: P1 risk_manager allowed)",
        observed: `HTTP ${r.status}; strRows=${n}`,
        verdict: pass ? verdict.verified("HTTP 403 + owner read-back proves no STR created") : verdict.fail(),
        pass,
        signals: { http: r.status, strRows: n },
      });
    }

    // ── LEG P1c — PREPARE DENIAL unauthenticated (valid CSRF, no session) ──────
    {
      const c = await seedPending(TENANT_A, "prep-anon-deny");
      const anonJar = new Jar();
      const anonCsrf = await getCsrf(anonJar, ANON_IP, SERVER);
      const r = await call("POST", `/api/fincrime/cases/${c}/str`, anonJar, anonCsrf, { narrative: "x" }, ANON_IP, SERVER);
      await sleep();
      const n = await strCountByCase(TENANT_A, c);
      const pass = (r.status === 401 || r.status === 403) && n === 0;
      run.leg({
        id: "P1c",
        scenario: "PREPARE DENIAL — unauthenticated (CSRF satisfied, no session)",
        expectation: "HTTP 401/403; ZERO STR rows (paired positive: P1 authenticated)",
        observed: `HTTP ${r.status}; strRows=${n}`,
        verdict: pass ? verdict.verified("rejected unauthenticated + owner read-back proves no STR created") : verdict.fail(),
        pass,
        signals: { http: r.status, strRows: n },
      });
    }

    // ── LEG P2 — PREPARE M3 FENCE (strict DTO) ────────────────────────────────
    {
      const c = await seedPending(TENANT_A, "prep-fence");
      const fenced: Array<Record<string, unknown>> = [
        { narrative: "x", status: "FILED" },
        { narrative: "x", fiaReference: "FIA-1" },
        { narrative: "x", filedAt: new Date().toISOString() },
        { narrative: "x", acknowledgedAt: new Date().toISOString() },
        { narrative: "x", disposition: "CLEARED" },
        { narrative: "x", state: "DISPOSITIONED" },
        { narrative: "x", subjectRef: "opaque:injected" },
      ];
      const statuses: number[] = [];
      for (const b of fenced) {
        const r = await mk.post(`/api/fincrime/cases/${c}/str`, b);
        statuses.push(r.status);
      }
      const nAfterFenced = await strCountByCase(TENANT_A, c);
      const rGreen = await prep(mk, c, "clean-prepare");
      await sleep();
      const nAfterGreen = await strCountByCase(TENANT_A, c);
      const pass =
        statuses.every((s) => s === 400) && nAfterFenced === 0 && rGreen.status === 201 && nAfterGreen === 1;
      run.leg({
        id: "P2",
        scenario: "PREPARE M3-FENCE — strict DTO rejects status/fiaReference/filedAt/acknowledgedAt/disposition/state/subjectRef (red) then clean prepare (green)",
        expectation: "every fenced body 400 (zero STR rows); identical clean body 201 with exactly one durable STR row — proving the rejection IS the fence, not a broken endpoint",
        observed: `fenced=[${statuses.join(",")}] rowsAfterFenced=${nAfterFenced}; clean HTTP ${rGreen.status} rows=${nAfterGreen}`,
        verdict: pass ? verdict.verified("all fenced keys 400 + clean body 201; owner read-back confirms only the clean STR landed") : verdict.fail(),
        pass,
        signals: { fencedStatuses: statuses, rowsAfterFenced: nAfterFenced, cleanHttp: rGreen.status, rowsAfterGreen: nAfterGreen },
      });
    }

    // ── LEG P3 — PREPARE guards (NOT_FOUND / STATE_CONFLICT / ALREADY_PREPARED) ─
    {
      const rNf = await prep(mk, randomUUID(), "x");
      const cOpen = await seedOpen(TENANT_A, "prep-state");
      const rState = await prep(mk, cOpen, "x");
      const nState = await strCountByCase(TENANT_A, cOpen);
      const cDup = await seedPending(TENANT_A, "prep-dup");
      const rDup1 = await prep(mk, cDup, "first");
      const rDup2 = await prep(mk, cDup, "second");
      await sleep();
      const nDup = await strCountByCase(TENANT_A, cDup);
      const pass =
        rNf.status === 404 &&
        reasonOf(rNf) === "case_not_found" &&
        rState.status === 409 &&
        reasonOf(rState) === "state_conflict" &&
        nState === 0 &&
        rDup1.status === 201 &&
        rDup2.status === 409 &&
        reasonOf(rDup2) === "already_prepared" &&
        nDup === 1;
      run.leg({
        id: "P3",
        scenario: "PREPARE guards — bogus case 404; OPEN case 409 STATE_CONFLICT; double-prepare 409 ALREADY_PREPARED",
        expectation: "NOT_FOUND→404 case_not_found; OPEN→409 state_conflict (0 STR rows); 2nd prepare→409 already_prepared (exactly 1 durable STR)",
        observed: `notfound HTTP ${rNf.status}/${reasonOf(rNf)}; state HTTP ${rState.status}/${reasonOf(rState)} rows=${nState}; dup HTTP ${rDup1.status}->${rDup2.status}/${reasonOf(rDup2)} rows=${nDup}`,
        verdict: pass ? verdict.verified("each guard returns its exact code + reason; owner read-back confirms no spurious STR rows") : verdict.fail(),
        pass,
        signals: { notFound: rNf.status, stateConflict: rState.status, stateRows: nState, dup2: rDup2.status, dupRows: nDup },
      });
    }

    // ── LEG F1 — TWO-EYES RED: preparer self-approve → 409 SELF_APPROVAL ───────
    // Shared case carried into F2 (the GREEN different-checker leg).
    const cTwoEyes = await seedPending(TENANT_A, "two-eyes");
    const prepTwoEyes = await prep(mk, cTwoEyes, RAW_NARRATIVE);
    const strIdTwoEyes = prepTwoEyes.json?.strReportId as string;
    const hashTwoEyes = prepTwoEyes.json?.narrativeHash as string;
    {
      const r = await fin(mk, cTwoEyes, { reportHash: hashTwoEyes, reason: "self-approve attempt" });
      await sleep();
      const cr = await caseRow(TENANT_A, cTwoEyes);
      const ap = await approvalsForReport(TENANT_A, strIdTwoEyes);
      const sr = await strRow(TENANT_A, strIdTwoEyes);
      const pass =
        prepTwoEyes.status === 201 &&
        r.status === 409 &&
        reasonOf(r) === "self_approval" &&
        cr?.state === "PENDING_MLCO_REVIEW" &&
        cr?.disposition === null &&
        cr?.dispositioned_at === null &&
        ap.length === 0 &&
        sr?.status === "PREPARED";
      run.leg({
        id: "F1",
        scenario: "TWO-EYES RED — the STR preparer attempts to finalize their OWN STR (maker==checker)",
        expectation: "HTTP 409 SELF_APPROVAL; case stays PENDING_MLCO_REVIEW (disposition NULL); ZERO approval rows; STR stays PREPARED (paired green: F2 different checker)",
        observed: `HTTP ${r.status}/${reasonOf(r)}; case=${cr?.state} disp=${cr?.disposition}; approvals=${ap.length}; str=${sr?.status}`,
        verdict: pass ? verdict.verified("HTTP 409 self_approval + owner read-back proves no approval, no disposition, STR unchanged") : verdict.fail(),
        pass,
        signals: { http: r.status, reason: reasonOf(r), caseState: cr?.state, approvals: ap.length },
      });
    }

    // ── LEG F2 — TWO-EYES GREEN: different checker finalizes → DISPOSITIONED ───
    // Carries criterion-1 GREEN (ack cols NULL after disposition) + the reason leak probe.
    let finalizedStrId = "";
    {
      const r = await fin(ad, cTwoEyes, { reportHash: hashTwoEyes, reason: RAW_REASON });
      await sleep();
      finalizedStrId = strIdTwoEyes;
      const cr = await caseRow(TENANT_A, cTwoEyes);
      const ap = await approvalsForReport(TENANT_A, strIdTwoEyes);
      const sr = await strRow(TENANT_A, strIdTwoEyes);
      const evs = await eventsFor(TENANT_A, cTwoEyes);
      const ev = await evidenceFor(TENANT_A, cTwoEyes);
      const disp = evs.filter((e) => e.event_type === "CASE_DISPOSITIONED");
      const appr = evs.filter((e) => e.event_type === "STR_APPROVED");
      const evAdded = evs.filter((e) => e.event_type === "EVIDENCE_ADDED");
      const noteRows = ev.filter((e) => e.evidence_type === "INVESTIGATION_NOTE" && e.encrypted_note !== null);
      const lk = await reasonLeak(TENANT_A, strIdTwoEyes, cTwoEyes, RAW_REASON);
      const pass =
        r.status === 200 &&
        r.json?.state === "DISPOSITIONED" &&
        r.json?.disposition === "STR_RECOMMENDED" &&
        r.json?.strReportId === strIdTwoEyes &&
        typeof r.json?.approvalId === "string" &&
        cr?.state === "DISPOSITIONED" &&
        cr?.disposition === "STR_RECOMMENDED" &&
        (cr?.disposition_reason ?? "").startsWith("evidence:") &&
        cr?.dispositioned_at !== null &&
        cr?.str_filed === false &&
        ap.length === 1 &&
        ap[0].approver_user_id === admin.userId &&
        ap[0].approver_user_id !== maker.userId &&
        ap[0].approver_role === "admin" &&
        ap[0].approval_type === "MLCO_AUTHORITY" &&
        ap[0].report_hash === hashTwoEyes &&
        (ap[0].reason ?? "").startsWith("evidence:") &&
        // criterion 1: disposition did NOT fabricate an FIA ack
        sr?.status === "PREPARED" &&
        sr?.fia_reference === null &&
        sr?.filed_at === null &&
        sr?.acknowledged_at === null &&
        disp.length === 1 &&
        disp[0].from_state === "PENDING_MLCO_REVIEW" &&
        disp[0].to_state === "DISPOSITIONED" &&
        appr.length === 1 &&
        evAdded.length === 1 &&
        noteRows.length === 1 &&
        lk.any === false;
      run.leg({
        id: "F2",
        scenario: "TWO-EYES GREEN — a DIFFERENT senior checker (admin) finalizes; case disposed STR_RECOMMENDED, STR stays PREPARED (criterion 1)",
        expectation:
          "HTTP 200 DISPOSITIONED + disposition STR_RECOMMENDED; durable case DISPOSITIONED (reason=evidence: pointer, str_filed false); exactly one approval (approver=admin != maker, hash matches); STR STILL PREPARED with ack cols NULL (no fabricated ack); one CASE_DISPOSITIONED(PENDING→DISPOSITIONED)+STR_APPROVED+EVIDENCE_ADDED; one encrypted note; raw reason in NO at-rest surface",
        observed: `HTTP ${r.status}; resp ${r.json?.state}/${r.json?.disposition}; case=${cr?.state}/${cr?.disposition} reason=${(cr?.disposition_reason ?? "").startsWith("evidence:")} strFiled=${cr?.str_filed}; approvals=${ap.length}(by=${ap[0]?.approver_role},!=maker=${ap[0]?.approver_user_id !== maker.userId},hash=${ap[0]?.report_hash === hashTwoEyes}); str=${sr?.status} ackNull=${sr?.fia_reference === null && sr?.filed_at === null && sr?.acknowledged_at === null}; ev{disp=${disp.length},appr=${appr.length},added=${evAdded.length}} note=${noteRows.length}; leak{${JSON.stringify(lk.hits)}}`,
        verdict: pass
          ? verdict.verified("HTTP 200 + owner read-back: disposed STR_RECOMMENDED with two-eyes approval (checker!=maker), STR remains PREPARED (ack cols NULL), raw-reason leak probe negative on all 4 surfaces")
          : verdict.fail(),
        pass,
        signals: { http: r.status, caseState: cr?.state, disposition: cr?.disposition, approvals: ap.length, checkerNeMaker: ap[0]?.approver_user_id !== maker.userId, strStatus: sr?.status, ackNull: sr?.fia_reference === null && sr?.filed_at === null && sr?.acknowledged_at === null, leakAny: lk.any },
      });
    }

    // ── LEG C1 — CRITERION 1 RED: DB refuses a forged ack on a PREPARED STR ────
    {
      let threw = false;
      let code = "";
      let constraint = "";
      try {
        await ownerQuery("UPDATE fincrime_str_reports SET acknowledged_at = NOW() WHERE id = $1", [finalizedStrId]);
      } catch (e) {
        threw = true;
        code = (e as { code?: string }).code ?? "";
        constraint = (e as { constraint?: string }).constraint ?? "";
      }
      const sr = await strRow(TENANT_A, finalizedStrId);
      const pass =
        threw &&
        code === "23514" &&
        (constraint === "fsr_no_fabricated_ack" || /fsr_no_fabricated_ack/.test(constraint)) &&
        sr?.acknowledged_at === null;
      run.leg({
        id: "C1",
        scenario: "NO-FABRICATED-ACK RED — a direct DB UPDATE setting acknowledged_at on a PREPARED STR",
        expectation: "the UPDATE is REFUSED by CHECK fsr_no_fabricated_ack (SQLSTATE 23514); acknowledged_at stays NULL (paired green: F2 ack cols NULL after a real disposition)",
        observed: `threw=${threw} code=${code} constraint=${constraint}; acknowledged_at=${sr?.acknowledged_at}`,
        verdict: pass ? verdict.verified("DB raised 23514 on fsr_no_fabricated_ack + read-back confirms acknowledged_at still NULL — a forged ack is structurally unstorable") : verdict.fail(),
        pass,
        signals: { threw, code, constraint, ackNull: sr?.acknowledged_at === null },
      });
    }

    // ── LEG ST — ANTI-STALE: wrong reportHash 409 (red) vs correct enables (green)
    {
      const c = await seedPending(TENANT_A, "anti-stale");
      const p = await prep(mk, c, "anti-stale narrative");
      const strId = p.json?.strReportId as string;
      const realHash = p.json?.narrativeHash as string;
      const rRed = await fin(ad, c, { reportHash: sha256(`WRONG-${base}`), reason: "x" });
      const crRed = await caseRow(TENANT_A, c);
      const apRed = await approvalsForReport(TENANT_A, strId);
      const rGreen = await fin(ad, c, { reportHash: realHash, reason: "correct-hash" });
      await sleep();
      const crGreen = await caseRow(TENANT_A, c);
      const pass =
        rRed.status === 409 &&
        reasonOf(rRed) === "stale_hash" &&
        crRed?.state === "PENDING_MLCO_REVIEW" &&
        apRed.length === 0 &&
        rGreen.status === 200 &&
        crGreen?.state === "DISPOSITIONED" &&
        crGreen?.disposition === "STR_RECOMMENDED";
      run.leg({
        id: "ST",
        scenario: "ANTI-STALE — finalize with a hash that does not match the report (red) vs the correct hash (green)",
        expectation: "wrong reportHash → 409 STALE_HASH (case stays PENDING, no approval); correct reportHash → 200 DISPOSITIONED STR_RECOMMENDED",
        observed: `red HTTP ${rRed.status}/${reasonOf(rRed)} case=${crRed?.state} approvals=${apRed.length}; green HTTP ${rGreen.status} case=${crGreen?.state}/${crGreen?.disposition}`,
        verdict: pass ? verdict.verified("stale hash 409 leaves the case PENDING with no approval; the correct hash disposes — the anti-stale check IS the gate") : verdict.fail(),
        pass,
        signals: { redHttp: rRed.status, redReason: reasonOf(rRed), redState: crRed?.state, greenHttp: rGreen.status, greenState: crGreen?.state },
      });
    }

    // ── LEG SF — STATE/DTO FENCE: non-PENDING 409; no-prepared 409; disposition NEVER from payload
    {
      // (a) finalize from a non-PENDING_MLCO_REVIEW (OPEN) case → 409 STATE_CONFLICT
      const cOpen = await seedOpen(TENANT_A, "fin-state");
      const rState = await fin(ad, cOpen, { reportHash: ANY_HASH, reason: "x" });
      const crState = await caseRow(TENANT_A, cOpen);
      // (b) finalize a PENDING case with NO prepared STR → 409 NO_PREPARED_STR
      const cNoPrep = await seedPending(TENANT_A, "fin-noprep");
      const rNoPrep = await fin(ad, cNoPrep, { reportHash: ANY_HASH, reason: "x" });
      const crNoPrep = await caseRow(TENANT_A, cNoPrep);
      // (c) finalize strict-DTO fence (red) then identical clean body (green) — disposition hard-derived
      const cFence = await seedPending(TENANT_A, "fin-fence");
      const pf = await prep(mk, cFence, "fence narrative");
      const hf = pf.json?.narrativeHash as string;
      const fenced: Array<Record<string, unknown>> = [
        { reportHash: hf, reason: "x", disposition: "CLEARED" },
        { reportHash: hf, reason: "x", state: "DISPOSITIONED" },
        { reportHash: hf, reason: "x", strFiled: true },
        { reportHash: hf, reason: "x", dispositionReason: "y" },
        { reportHash: hf, reason: "x", tenantId: TENANT_B },
      ];
      const fencedStatuses: number[] = [];
      for (const b of fenced) {
        const r = await fin(ad, cFence, b);
        fencedStatuses.push(r.status);
      }
      const crFenceMid = await caseRow(TENANT_A, cFence);
      const rClean = await fin(ad, cFence, { reportHash: hf, reason: "clean-finalize" });
      await sleep();
      const crClean = await caseRow(TENANT_A, cFence);
      const pass =
        rState.status === 409 &&
        reasonOf(rState) === "state_conflict" &&
        crState?.state === "OPEN" &&
        rNoPrep.status === 409 &&
        reasonOf(rNoPrep) === "no_prepared_str" &&
        crNoPrep?.state === "PENDING_MLCO_REVIEW" &&
        fencedStatuses.every((s) => s === 400) &&
        crFenceMid?.state === "PENDING_MLCO_REVIEW" &&
        rClean.status === 200 &&
        rClean.json?.disposition === "STR_RECOMMENDED" &&
        crClean?.state === "DISPOSITIONED" &&
        crClean?.disposition === "STR_RECOMMENDED";
      run.leg({
        id: "SF",
        scenario: "STATE/DTO FENCE — non-PENDING finalize 409; no-prepared-STR 409; injected disposition/state/str_filed 400 then clean body 200 (disposition hard-derived)",
        expectation: "OPEN case → 409 state_conflict (stays OPEN); PENDING+no STR → 409 no_prepared_str; every fenced finalize body 400 (case stays PENDING); clean body 200 with disposition STR_RECOMMENDED derived server-side (NEVER from payload)",
        observed: `state HTTP ${rState.status}/${reasonOf(rState)} case=${crState?.state}; noprep HTTP ${rNoPrep.status}/${reasonOf(rNoPrep)} case=${crNoPrep?.state}; fenced=[${fencedStatuses.join(",")}] mid=${crFenceMid?.state}; clean HTTP ${rClean.status} disp=${rClean.json?.disposition} case=${crClean?.state}/${crClean?.disposition}`,
        verdict: pass ? verdict.verified("non-PENDING + no-prepared finalizes 409; all fenced keys 400 leave PENDING; clean body disposes STR_RECOMMENDED (hard-derived) — the fence holds and disposition is never client-asserted") : verdict.fail(),
        pass,
        signals: { stateHttp: rState.status, noPrepHttp: rNoPrep.status, fencedStatuses, midState: crFenceMid?.state, cleanHttp: rClean.status, cleanDisp: rClean.json?.disposition },
      });
    }

    // ── LEG TO1 — TIPPING-OFF POSITIVE: senior + scoped auditor read (decrypted) ─
    const cTip = await seedPending(TENANT_A, "tipping");
    const pTip = await prep(mk, cTip, RAW_NARRATIVE_TO);
    const strIdTip = pTip.json?.strReportId as string;
    {
      const rAdmin = await ad.get(`/api/fincrime/cases/${cTip}/str`);
      const rMaker = await mk.get(`/api/fincrime/cases/${cTip}/str`);
      const rAud = await au.get(`/api/fincrime/cases/${cTip}/str`);
      const pass =
        pTip.status === 201 &&
        rAdmin.status === 200 &&
        rAdmin.json?.narrative === RAW_NARRATIVE_TO &&
        rAdmin.json?.status === "PREPARED" &&
        rAdmin.json?.narrativeHash === sha256(RAW_NARRATIVE_TO) &&
        String(rAdmin.json?.subjectRef ?? "").startsWith("opaque:") &&
        rAdmin.json?.acknowledgedAt === null &&
        rMaker.status === 200 &&
        rMaker.json?.narrative === RAW_NARRATIVE_TO &&
        rAud.status === 200 &&
        rAud.json?.narrative === RAW_NARRATIVE_TO; // auditor scoped read is ALLOWED
      run.leg({
        id: "TO1",
        scenario: "TIPPING-OFF POSITIVE — admin + risk_manager + scoped auditor GET the STR (narrative decrypted, subject opaque)",
        expectation: "each authorized senior/auditor GET → 200 with the decrypted narrative + 64-hex hash + opaque subjectRef + ack cols NULL",
        observed: `admin HTTP ${rAdmin.status} narr=${rAdmin.json?.narrative === RAW_NARRATIVE_TO} subjOpaque=${String(rAdmin.json?.subjectRef ?? "").startsWith("opaque:")} ack=${rAdmin.json?.acknowledgedAt}; maker HTTP ${rMaker.status} narr=${rMaker.json?.narrative === RAW_NARRATIVE_TO}; auditor HTTP ${rAud.status} narr=${rAud.json?.narrative === RAW_NARRATIVE_TO}`,
        verdict: pass ? verdict.verified("each authorized reader gets 200 + the correctly-decrypted narrative; subjectRef stays opaque, ack cols NULL") : verdict.fail(),
        pass,
        signals: { admin: rAdmin.status, maker: rMaker.status, auditor: rAud.status, decrypted: rAdmin.json?.narrative === RAW_NARRATIVE_TO },
      });
    }

    // ── LEG TO2 — TIPPING-OFF NEGATIVE (route): auditor cannot mutate; anon rejected
    {
      const cAudMut = await seedPending(TENANT_A, "aud-mutate");
      const rAudPrep = await au.post(`/api/fincrime/cases/${cAudMut}/str`, { narrative: "x" });
      const rAudFin = await au.post(`/api/fincrime/cases/${cTip}/finalize`, { reportHash: sha256(RAW_NARRATIVE_TO), reason: "x" });
      const crTip = await caseRow(TENANT_A, cTip);
      const nAudPrep = await strCountByCase(TENANT_A, cAudMut);
      const anonJar = new Jar();
      const anonCsrf = await getCsrf(anonJar, ANON_IP, SERVER);
      const rAnonGet = await call("GET", `/api/fincrime/cases/${cTip}/str`, anonJar, anonCsrf, undefined, ANON_IP, SERVER);
      const rAnonFin = await call("POST", `/api/fincrime/cases/${cTip}/finalize`, anonJar, anonCsrf, { reportHash: sha256(RAW_NARRATIVE_TO), reason: "x" }, ANON_IP, SERVER);
      const pass =
        rAudPrep.status === 403 &&
        nAudPrep === 0 &&
        rAudFin.status === 403 &&
        crTip?.state === "PENDING_MLCO_REVIEW" && // auditor finalize did NOT dispose
        (rAnonGet.status === 401 || rAnonGet.status === 403) &&
        (rAnonFin.status === 401 || rAnonFin.status === 403);
      run.leg({
        id: "TO2",
        scenario: "TIPPING-OFF NEGATIVE (route guard) — auditor CANNOT prepare/finalize; anon cannot read/finalize",
        expectation: "auditor R1 prepare 403 (0 STR) + auditor R2 finalize 403 (case stays PENDING); anon GET 401/403 + anon finalize 401/403",
        observed: `audPrep HTTP ${rAudPrep.status} rows=${nAudPrep}; audFin HTTP ${rAudFin.status} case=${crTip?.state}; anonGet HTTP ${rAnonGet.status}; anonFin HTTP ${rAnonFin.status}`,
        verdict: pass ? verdict.verified("auditor mutation 403 (no STR / no disposition) + anon read & finalize rejected — read access does not grant mutation; paired positive TO1") : verdict.fail(),
        pass,
        signals: { audPrep: rAudPrep.status, audFin: rAudFin.status, caseAfterAudFin: crTip?.state, anonGet: rAnonGet.status, anonFin: rAnonFin.status },
      });
    }

    // ── LEG TO3 — TIPPING-OFF NEGATIVE (RLS): a sibling tenant sees the STR NOWHERE
    {
      const rBRoute = await bs.get(`/api/fincrime/cases/${cTip}/str`); // tenant-B senior, tenant-A case
      const rlsB = await rlsCountById(TENANT_B, "fincrime_str_reports", strIdTip);
      const rlsA = await rlsCountById(TENANT_A, "fincrime_str_reports", strIdTip);
      const ownerExists = await ownerCount("fincrime_str_reports", "tenant_id=$1 AND id=$2", [TENANT_A, strIdTip]);
      const pass =
        rBRoute.status === 404 && // route: cross-tenant invisibility
        rlsB === 0 && // RLS-subject role in tenant B sees 0
        rlsA === 1 && // RLS-subject role in tenant A sees 1 (paired positive)
        ownerExists === 1; // ground truth: the row DOES exist in A
      run.leg({
        id: "TO3",
        scenario: "TIPPING-OFF NEGATIVE (RLS) — tenant-B senior GET 404; rlsCountById(B)=0 while rlsCountById(A)=1 and owner ground-truth=1",
        expectation: "cross-tenant route read 404; the STR is invisible under the app role in tenant B (RLS) yet visible in tenant A and present in owner ground truth — proving isolation is RLS, not an app filter or a missing row",
        observed: `B route HTTP ${rBRoute.status}; rlsB=${rlsB}; rlsA=${rlsA}; ownerExists=${ownerExists}`,
        verdict: pass ? verdict.verified("route 404 + role-switched rlsCountById B=0/A=1 + owner=1 — cross-tenant invisibility enforced by RLS, paired with same-tenant visibility") : verdict.fail(),
        pass,
        signals: { bRoute: rBRoute.status, rlsB, rlsA, ownerExists },
      });
    }

    // ── LEG CC — CONCURRENCY: two finalizes → exactly 1 win + 1×409; single approval
    {
      const c = await seedPending(TENANT_A, "concurrency");
      const p = await prep(mk, c, "concurrency narrative");
      const strId = p.json?.strReportId as string;
      const hash = p.json?.narrativeHash as string;
      // fire two DIFFERENT valid checkers concurrently (both != maker)
      const [rA, rB] = await Promise.all([
        fin(ad, c, { reportHash: hash, reason: "race-admin" }),
        fin(r2, c, { reportHash: hash, reason: "race-rm2" }),
      ]);
      await sleep();
      const statuses = [rA.status, rB.status].sort((x, y) => x - y);
      const cr = await caseRow(TENANT_A, c);
      const ap = await approvalsForReport(TENANT_A, strId);
      const at = await attemptCount("FINCRIME_STR_FINALIZE_ATTEMPT", c);
      const loser = rA.status === 409 ? rA : rB.status === 409 ? rB : null;
      const loserReason = loser ? reasonOf(loser) : undefined;
      const pass =
        statuses[0] === 200 &&
        statuses[1] === 409 &&
        cr?.state === "DISPOSITIONED" &&
        cr?.disposition === "STR_RECOMMENDED" &&
        ap.length === 1 && // DURABLE single-approval — the fsa unique / CAS backstop held
        ap[0].approver_user_id !== maker.userId;
      run.leg({
        id: "CC",
        scenario: "CONCURRENCY — two different checkers finalize the same STR at once (Promise.all)",
        expectation: "exactly one 200 + one 409 (CAS/lock/fsa-unique); durable read-back: case DISPOSITIONED once, exactly ONE approval row (checker != maker). NOT claiming a single audit row — attempt-before-effect may chain more than one ATTEMPT",
        observed: `statuses=[${statuses.join(",")}] loserReason=${loserReason}; case=${cr?.state}/${cr?.disposition}; approvals=${ap.length}; finalizeAttemptAudits=${at} (not gated)`,
        verdict: pass ? verdict.verified("Promise.all of two finalizes resolved to one 200 + one 409; owner read-back proves a single durable approval and one disposition — the lock/CAS/unique backstop enforces single-winner") : verdict.fail(),
        pass,
        signals: { statuses, loserReason, caseState: cr?.state, approvals: ap.length, attemptAudits: at },
      });
    }
  } finally {
    await deactivateUsers(allSeeds.map((s) => s.id)).catch(() => {});
    console.log(
      "cleanup: synthetic seed users deactivated + credentials neutralized (audit chain preserved). " +
        `synthetic DEV case rows left in place (harmless in dev): ${[...createdCaseIds].length} cases.`,
    );
    await endOwnerPool();
  }

  run.report();
  await run.writeJson().catch(() => {});
  const { allPass } = run.acceptance();
  process.exit(allPass ? 0 : 1);
}

main().catch((e) => {
  console.error("FATAL harness error:", e);
  process.exit(1);
});
