/**
 * AEGIS SOAR (AS/SOAR/2026/001) — M4: REGULATOR SURFACE battery.
 * Drives the read-only, no-PII, tamper-evidence regulator-facing SOAR view to
 * DEV-VERIFIED, then HOLD at the dev gate. NO prod deploy. NO live FIA submission.
 *
 * Reuses the KYC-F4 examiner-token pattern: an admin issues a `read:fincrime`
 * examiner token; the two GET routes resolve the examiner's effective tenant from
 * the issuing admin's primary tenant (FAIL CLOSED, never cross-tenant), read the
 * case spine under runWithTenantContext (RLS), and triangulate the immutable audit
 * chain. The battery drives the REAL M1→M3 lifecycle over HTTP (ingest → start →
 * evidence → escalate-mlco → prepare STR → two-eyes finalize) so a GENUINE
 * multi-entry hash chain exists to verify — it does NOT seed a case row directly.
 *
 * Built on the AS/PLATFORM/2026/002 proof-harness toolkit (scripts/lib/proof-
 * harness.ts) — real-route driving, owner (BYPASSRLS) GROUND-TRUTH read-back,
 * role-switched RLS-subject reads (rlsCountById, run as aegis_app — NOT the owner),
 * and carry-list user cleanup. The immutable hash-chained audit rows are preserved;
 * the single tamper leg mutates one audit-chain entry's `details` and RESTORES the
 * exact original bytes in a finally block (it is a content-tamper demonstration,
 * not a durable change).
 *
 * THE FOUR BINDING CRITERIA — each PAIRED (Rule 18), each verdict stating HOW it
 * was checked (Rule 19), every value asserted against owner ground-truth (never the
 * route's own echo):
 *   1. NO-PII — the regulator payload carries ONLY allowlisted non-PII fields;
 *      paired negative: the raw subject_ref, source_ref, source_signal_hash,
 *      assigned_investigator_id, disposition_reason, prepared/approver user ids,
 *      narrative_hash / report_hash values, encrypted-narrative ciphertext, and the
 *      raw free-text (narrative / summary / note) appear NOWHERE in the payload, and
 *      no internal-shaped key name is present — while the safe fields ARE populated.
 *   2. TAMPER-EVIDENCE — untampered case → integrity.verified=true with every
 *      per-entry hash recomputing AND the exposed neighbor anchors independently
 *      re-verifiable (regulator can re-check previousHash===predecessor / successor);
 *      paired negative: a content tamper of one chain entry flips verified=false
 *      (the SHA-256 recompute catches it), and restoring the exact bytes flips it
 *      back to true — genuine hash-chain evidence, not a presence check.
 *   3. TENANT-SCOPED — tenant-A token renders the case (200) while a sibling
 *      tenant-B token reads NOWHERE (404 not-found); proven as the app role:
 *      rlsCountById(B)=0 while rlsCountById(A)=1 and owner ground-truth=1 — isolation
 *      is RLS (aegis_app), not an app filter or a missing row. List leg paired too.
 *   4. READ-ONLY FENCE — POST/PUT/PATCH/DELETE on both surfaces (with a VALID
 *      `read:fincrime` Bearer AND CSRF satisfied) return 404/405, never 2xx — so the
 *      404 means "no write route", not a hollow CSRF-403/auth-401; GET still 200; and
 *      the case row + every child count is byte-identical before/after.
 *
 * Plus M4-5 AUTHZ/SCOPE — no Bearer → 401; a valid token of the WRONG scope → 403;
 * a revoked token → 401; paired against the read:fincrime 200.
 *
 * PII discipline (Rule 1): this battery NEVER prints a decrypted narrative, a raw
 * subject ref, or any secret value.
 */

import {
  ProofRun,
  verdict,
  login,
  sessionClient,
  getCsrf,
  Jar,
  mkSeed,
  seedUser,
  deactivateUsers,
  ownerQuery,
  ownerCount,
  rlsCountById,
  allocateActorIps,
  endOwnerPool,
  createHash,
  sleep,
  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();

// Must match server/lib/fincrime/case-integrity.ts GENESIS (the chain-head anchor
// the regulator re-checks against when an entry has no predecessor).
const GENESIS = "GENESIS_BLOCK_AEGIS_CYBER_2026";

// Unique free-text markers — the encrypted disclosures; must appear NOWHERE in the
// regulator payload (the surface is no-PII; these never cross the wire).
const RAW_NARRATIVE = `RAWNARRATIVE-M4-DO-NOT-LEAK-${base}`;
const RAW_SUMMARY = `RAWSUMMARY-M4-DO-NOT-LEAK-${base}`;
const RAW_NOTE = `RAWNOTE-M4-DO-NOT-LEAK-${base}`;
const FINALIZE_REASON = `RAWREASON-M4-DO-NOT-LEAK-${base}`;

const sha256 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex");

interface JsonResult {
  status: number;
  json: any;
}

const run = new ProofRun("AEGIS SOAR M4 — Regulator Surface (read-only) battery", "DEV");

// ── owner (BYPASSRLS) ground-truth read-back — the WITHHELD secrets ───────────
interface CaseSecretsRow {
  subject_ref: string;
  source_ref: string | null;
  source_signal_hash: string;
  assigned_investigator_id: string | null;
  disposition_reason: string | null;
  audit_chain_entry_id: number | null;
  state: string;
  disposition: string | null;
  disposition_reason2: string | null;
  dispositioned_at: Date | null;
  str_filed: boolean;
  updated_at: Date;
}
async function caseSecrets(tenantId: string, id: string): Promise<CaseSecretsRow | undefined> {
  const rows = await ownerQuery<CaseSecretsRow>(
    `SELECT subject_ref, source_ref, source_signal_hash, assigned_investigator_id,
            disposition_reason, audit_chain_entry_id, state, disposition,
            disposition_reason AS disposition_reason2, dispositioned_at, str_filed, updated_at
       FROM fincrime_cases WHERE tenant_id=$1 AND id=$2`,
    [tenantId, id],
  );
  return rows[0];
}
interface StrSecretsRow {
  id: string;
  narrative_hash: string | null;
  encrypted_narrative: string | null;
  prepared_by_user_id: string;
}
async function strSecrets(tenantId: string, caseId: string): Promise<StrSecretsRow | undefined> {
  const rows = await ownerQuery<StrSecretsRow>(
    `SELECT id, narrative_hash, encrypted_narrative, prepared_by_user_id
       FROM fincrime_str_reports WHERE tenant_id=$1 AND case_id=$2
       ORDER BY created_at DESC LIMIT 1`,
    [tenantId, caseId],
  );
  return rows[0];
}
interface ApprovalSecretsRow {
  report_hash: string;
  approver_user_id: string;
}
async function approvalSecrets(tenantId: string, caseId: string): Promise<ApprovalSecretsRow | undefined> {
  const rows = await ownerQuery<ApprovalSecretsRow>(
    `SELECT report_hash, approver_user_id
       FROM fincrime_str_approvals WHERE tenant_id=$1 AND case_id=$2
       ORDER BY approved_at DESC LIMIT 1`,
    [tenantId, caseId],
  );
  return rows[0];
}
// child-row counts (for the read-only fence equality)
async function childCounts(tenantId: string, caseId: string): Promise<Record<string, number>> {
  return {
    events: await ownerCount("fincrime_case_events", "tenant_id=$1 AND case_id=$2", [tenantId, caseId]),
    evidence: await ownerCount("fincrime_case_evidence", "tenant_id=$1 AND case_id=$2", [tenantId, caseId]),
    str: await ownerCount("fincrime_str_reports", "tenant_id=$1 AND case_id=$2", [tenantId, caseId]),
    approvals: await ownerCount("fincrime_str_approvals", "tenant_id=$1 AND case_id=$2", [tenantId, caseId]),
  };
}

// Recursively collect every object key (for the structural internal-key absence check).
function collectKeys(obj: unknown, acc: string[] = []): string[] {
  if (Array.isArray(obj)) {
    for (const v of obj) collectKeys(v, acc);
  } else if (obj && typeof obj === "object") {
    for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
      acc.push(k);
      collectKeys(v, acc);
    }
  }
  return acc;
}

// ── seeded synthetic users (carry-list cleanup in finally) ────────────────────
const ips = allocateActorIps(5, base);
const makerSeed = mkSeed("risk_manager", "m4-maker", "rm", base); // lifecycle driver + STR preparer
const adminSeed = mkSeed("admin", "m4-admin", "ad", base); // checker (finalize) + token-A issuer
const adminBSeed = mkSeed("admin", "m4-adminb", "ab", base); // tenant-B token issuer (cross-tenant)
const allSeeds: Seed[] = [makerSeed, adminSeed, adminBSeed];

// raw fetch for the examiner (Bearer) surface — arbitrary method + optional CSRF.
async function examinerCall(
  method: string,
  path: string,
  opts: { rawToken?: string; csrf?: string; cookie?: string } = {},
): Promise<JsonResult> {
  const headers: Record<string, string> = { "x-forwarded-for": ips[4] };
  if (opts.rawToken) headers.authorization = `Bearer ${opts.rawToken}`;
  if (opts.cookie) headers.cookie = opts.cookie;
  if (opts.csrf) headers["x-csrf-token"] = opts.csrf;
  const res = await fetch(`${SERVER}${path}`, { method, headers });
  const json = await res.json().catch(() => ({}));
  return { status: res.status, json };
}
const examinerGet = (path: string, rawToken: string) => examinerCall("GET", path, { rawToken });

// Issue a read-only examiner token via the admin session. Returns {id, rawToken}.
async function issueToken(admin: ReturnType<typeof sessionClient>, scopes: string[]): Promise<{ id: string; rawToken: string }> {
  const r = await admin.post("/api/wave9/examiner-tokens/issue", {
    examinerName: "M4 Regulator Probe",
    examinerEmail: "m4probe@test.local",
    scopes,
    ttlHours: 1,
  });
  if (r.status !== 200 || !r.json?.rawToken) {
    throw new Error(`issueToken failed HTTP ${r.status} ${JSON.stringify(r.json).slice(0, 160)}`);
  }
  return { id: r.json.id as string, rawToken: r.json.rawToken as string };
}

// Drive the REAL M1→M3 lifecycle to DISPOSITIONED/STR_RECOMMENDED with a PREPARED
// STR + one two-eyes approval (maker prepares, a DIFFERENT admin finalizes) — the
// exact substrate the regulator surface renders, with a genuine multi-entry chain.
async function makeDisposedCaseViaLifecycle(
  mk: ReturnType<typeof sessionClient>,
  ad: ReturnType<typeof sessionClient>,
  tag: string,
): Promise<string> {
  const ing = await mk.post("/api/fincrime/ingest", {
    sourceType: "MANUAL_REFERRAL",
    sourceRef: `m4-src-${tag}-${base}`,
    subjectRef: `opaque:m4-${tag}-${base}-TEST_DELETE_OK`,
    signal: { tag, base, marker: RAW_NARRATIVE.slice(0, 0) }, // marker length 0 — no free-text in signal
  });
  if (ing.status !== 201 || !ing.json?.caseId) {
    throw new Error(`ingest failed HTTP ${ing.status} ${JSON.stringify(ing.json).slice(0, 160)}`);
  }
  const caseId = ing.json.caseId as string;
  const s = await mk.post(`/api/fincrime/cases/${caseId}/start`, { note: RAW_NOTE });
  if (s.status !== 200) throw new Error(`start failed HTTP ${s.status} for ${tag}`);
  const ev = await mk.post(`/api/fincrime/cases/${caseId}/evidence`, {
    evidenceType: "DOCUMENT_REF",
    opaqueRef: `opaque:doc-${tag}-${base}`,
  });
  if (ev.status !== 200 && ev.status !== 201) throw new Error(`evidence failed HTTP ${ev.status} for ${tag}`);
  const esc = await mk.post(`/api/fincrime/cases/${caseId}/escalate-mlco`, { summary: RAW_SUMMARY });
  if (esc.status !== 200) throw new Error(`escalate failed HTTP ${esc.status} for ${tag}`);
  const p = await mk.post(`/api/fincrime/cases/${caseId}/str`, { narrative: RAW_NARRATIVE });
  if (p.status !== 201) throw new Error(`STR prepare failed HTTP ${p.status} for ${tag}`);
  const narrativeHash = p.json?.narrativeHash as string;
  const f = await ad.post(`/api/fincrime/cases/${caseId}/finalize`, { reportHash: narrativeHash, reason: FINALIZE_REASON });
  if (f.status !== 200) throw new Error(`finalize failed HTTP ${f.status} for ${tag} ${JSON.stringify(f.json).slice(0, 160)}`);
  await sleep();
  return caseId;
}

async function main(): Promise<void> {
  console.log("=".repeat(82));
  console.log("AEGIS SOAR M4 — Regulator Surface (read-only) 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 M4 BATTERY maker (test, delete ok)");
    await seedUser(adminSeed, TENANT_A, "FINCRIME M4 BATTERY admin/checker (test, delete ok)");
    await seedUser(adminBSeed, TENANT_B, "FINCRIME M4 BATTERY tenant-B admin (test, delete ok)");

    const maker = await login(makerSeed, ips[0], SERVER);
    const admin = await login(adminSeed, ips[1], SERVER);
    const adminB = await login(adminBSeed, ips[2], SERVER);

    const mk = sessionClient(maker, SERVER);
    const ad = sessionClient(admin, SERVER);
    const adB = sessionClient(adminB, SERVER);

    const listPath = "/api/fincrime/cases"; // (internal M2 read; not the examiner surface)
    const exListPath = "/api/examiner/fincrime/cases";
    const exCasePath = (id: string) => `/api/examiner/fincrime/cases/${id}`;

    // ── GOLDEN: one fully-disposed case via the REAL lifecycle (genuine chain) ──
    const gCase = await makeDisposedCaseViaLifecycle(mk, ad, "golden");
    const cSec = await caseSecrets(TENANT_A, gCase);
    const sSec = await strSecrets(TENANT_A, gCase);
    const aSec = await approvalSecrets(TENANT_A, gCase);

    // examiner tokens
    const tokA = await issueToken(ad, ["read:fincrime"]); // tenant A (issuer admin primary = A)
    const tokB = await issueToken(adB, ["read:fincrime"]); // tenant B (issuer admin primary = B)

    // ── LEG M4-1 — NO-PII: only allowlisted fields present; secrets absent ──────
    {
      const r = await examinerGet(exCasePath(gCase), tokA.rawToken);
      const p = r.json;
      const serialized = JSON.stringify(p);
      // positive: the safe fields ARE populated
      const safePopulated =
        p?.case?.caseRef === `SOAR-${gCase.slice(0, 8)}` &&
        p?.case?.id === gCase &&
        p?.case?.subjectRefKind === "OPAQUE_EXTERNAL_REF" &&
        p?.case?.sourceType === "MANUAL_REFERRAL" &&
        p?.case?.state === "DISPOSITIONED" &&
        p?.case?.disposition === "STR_RECOMMENDED" &&
        p?.str?.status === "PREPARED" &&
        p?.str?.preparedByRole === "risk_manager" &&
        p?.str?.approverRole === "admin" &&
        p?.integrity?.verified === true;
      // negative: NO withheld secret value appears anywhere in the payload
      const secrets: Array<[string, string | null | undefined]> = [
        ["subject_ref", cSec?.subject_ref],
        ["source_ref", cSec?.source_ref],
        ["source_signal_hash", cSec?.source_signal_hash],
        ["assigned_investigator_id", cSec?.assigned_investigator_id],
        ["disposition_reason", cSec?.disposition_reason],
        ["narrative_hash", sSec?.narrative_hash],
        ["encrypted_narrative", sSec?.encrypted_narrative],
        ["prepared_by_user_id", sSec?.prepared_by_user_id],
        ["report_hash", aSec?.report_hash],
        ["approver_user_id", aSec?.approver_user_id],
        ["maker_user_id", maker.userId],
        ["admin_user_id", admin.userId],
        ["RAW_NARRATIVE", RAW_NARRATIVE],
        ["RAW_SUMMARY", RAW_SUMMARY],
        ["RAW_NOTE", RAW_NOTE],
        ["FINALIZE_REASON", FINALIZE_REASON],
      ];
      const leaked = secrets.filter(([, v]) => typeof v === "string" && v.length > 0 && serialized.includes(v)).map(([k]) => k);
      // structural: no internal-shaped key name (allowed keys like subjectRefKind / sourceType / hash are NOT flagged)
      const forbiddenKey = /userid|user_id|narrative|ciphertext|encrypted|source_signal|sourcesignal|source_ref|sourceref|investigator|disposition_reason|dispositionreason|report_hash|reporthash|narrative_hash|narrativehash/i;
      const badKeys = Array.from(new Set(collectKeys(p))).filter((k) => forbiddenKey.test(k));
      const pass = r.status === 200 && safePopulated && leaked.length === 0 && badKeys.length === 0;
      run.leg({
        id: "M4-1",
        scenario: "NO-PII — regulator payload carries only allowlisted non-PII fields; every withheld secret value + raw free-text is absent, no internal-shaped key name present",
        expectation: "HTTP 200; safe fields populated (caseRef/id/subjectRefKind/sourceType/state/disposition + str status/roles + integrity.verified); the raw subject_ref, source_ref, source_signal_hash, assigned_investigator_id, disposition_reason, prepared/approver user ids, narrative_hash/report_hash values, encrypted-narrative ciphertext, and raw narrative/summary/note/reason appear NOWHERE; no payload key matches the internal-shape regex",
        observed: `HTTP ${r.status}; safePopulated=${safePopulated}; leaked=[${leaked.join(",")}]; badKeys=[${badKeys.join(",")}]`,
        verdict: pass
          ? verdict.verified("safe fields populated AND all 16 withheld secret values + every internal-shaped key name absent from the serialized payload — by construction (storage SELECTs only safe columns; DTOs are explicit allowlists)")
          : verdict.fail(),
        pass,
        signals: { http: r.status, safePopulated, leaked, badKeys },
      });
    }

    // ── LEG M4-2 — TAMPER-EVIDENCE: untampered verified=true (+ re-verifiable);
    //    content tamper flips verified=false; exact restore flips it back. ───────
    {
      // positive
      const rOk = await examinerGet(exCasePath(gCase), tokA.rawToken);
      const pOk = rOk.json;
      const entries: any[] = Array.isArray(pOk?.integrity?.entries) ? pOk.integrity.entries : [];
      const everyEntryVerified =
        entries.length > 0 &&
        entries.every(
          (e) => e.contentVerified === true && e.linkageVerified === true && e.sourceIdBound === true && e.resourceBound === true,
        );
      // regulator independently re-verifies the local linkage from the EXPOSED anchors
      const reVerifiable =
        entries.length > 0 &&
        entries.every(
          (e) =>
            e.previousHash === (e.predecessorHash ?? GENESIS) &&
            (e.successorPreviousHash === null || e.successorPreviousHash === e.hash),
        );
      const entryCount = pOk?.integrity?.entryCount;
      const positivePass =
        rOk.status === 200 &&
        pOk?.integrity?.verified === true &&
        typeof entryCount === "number" &&
        entryCount === entries.length &&
        entryCount >= 5 &&
        everyEntryVerified &&
        reVerifiable;

      // negative: content-tamper one of THIS case's chain entries, then restore.
      const entryId = cSec?.audit_chain_entry_id;
      let origDetails: string | null = null;
      let tamperedFlag = false;
      let rTampered: JsonResult = { status: 0, json: {} };
      let rRestored: JsonResult = { status: 0, json: {} };
      try {
        if (entryId == null) throw new Error("golden case has no audit_chain_entry_id — cannot run tamper leg");
        const rows = await ownerQuery<{ details: string }>(
          "SELECT details FROM audit_chain_entries WHERE id=$1",
          [entryId],
        );
        origDetails = rows[0]?.details ?? null;
        if (origDetails == null) throw new Error(`audit_chain_entries #${entryId} not found`);
        // tamper the content (NOT the stored hash) — the SHA-256 recompute must catch it
        await ownerQuery("UPDATE audit_chain_entries SET details=$1 WHERE id=$2", [
          `__M4_TAMPER_${base}__` + origDetails,
          entryId,
        ]);
        tamperedFlag = true;
        rTampered = await examinerGet(exCasePath(gCase), tokA.rawToken);
        // restore EXACT original bytes
        await ownerQuery("UPDATE audit_chain_entries SET details=$1 WHERE id=$2", [origDetails, entryId]);
        tamperedFlag = false;
        rRestored = await examinerGet(exCasePath(gCase), tokA.rawToken);
      } finally {
        if (tamperedFlag && origDetails != null && entryId != null) {
          await ownerQuery("UPDATE audit_chain_entries SET details=$1 WHERE id=$2", [origDetails, entryId]).catch(() => {});
        }
      }

      const tamperedContentCheck = (rTampered.json?.integrity?.checks ?? []).find((c: any) => c.name === "chain_entries_content");
      const negativePass =
        rTampered.status === 200 &&
        rTampered.json?.integrity?.verified === false &&
        tamperedContentCheck?.pass === false;
      const restoredPass = rRestored.status === 200 && rRestored.json?.integrity?.verified === true;

      const pass = positivePass && negativePass && restoredPass;
      run.leg({
        id: "M4-2",
        scenario: "TAMPER-EVIDENCE — untampered integrity.verified=true (every entry's hash recomputes + neighbor anchors independently re-verifiable); a content tamper flips verified=false (SHA-256 recompute catches it); exact restore flips it back to true",
        expectation: "POSITIVE: 200, integrity.verified=true, entryCount==entries.length>=5, every entry content/linkage/sourceIdBound/resourceBound true, AND regulator re-derives linkage from the exposed hashes (previousHash==predecessor||GENESIS, successorPreviousHash==hash). NEGATIVE: tampering one entry's details → verified=false with chain_entries_content.pass=false. RESTORE: exact bytes back → verified=true again",
        observed: `positive{http=${rOk.status},verified=${pOk?.integrity?.verified},entryCount=${entryCount},entries=${entries.length},everyEntry=${everyEntryVerified},reVerifiable=${reVerifiable}}; tampered{http=${rTampered.status},verified=${rTampered.json?.integrity?.verified},contentCheck=${tamperedContentCheck?.pass}}; restored{http=${rRestored.status},verified=${rRestored.json?.integrity?.verified}}`,
        verdict: pass
          ? verdict.verified("untampered chain verifies (and is independently re-verifiable from the exposed neighbor hashes); a single content tamper flips verified=false via the SHA-256 recompute (chain_entries_content fails); restoring the exact bytes flips it back — genuine hash-chain tamper-evidence, paired three ways")
          : verdict.fail(),
        pass,
        signals: { positivePass, negativePass, restoredPass, entryCount, entries: entries.length, tamperedVerified: rTampered.json?.integrity?.verified, restoredVerified: rRestored.json?.integrity?.verified },
      });
    }

    // ── LEG M4-3 — TENANT-SCOPED: A renders; sibling B reads nowhere (RLS) ──────
    {
      const rA = await examinerGet(exCasePath(gCase), tokA.rawToken); // tenant-A token (positive)
      const rB = await examinerGet(exCasePath(gCase), tokB.rawToken); // tenant-B token, A's case (negative)
      const rlsB = await rlsCountById(TENANT_B, "fincrime_cases", gCase);
      const rlsA = await rlsCountById(TENANT_A, "fincrime_cases", gCase);
      const ownerExists = await ownerCount("fincrime_cases", "tenant_id=$1 AND id=$2", [TENANT_A, gCase]);
      // list legs (paired): A's list includes the case; B's list does NOT
      const lA = await examinerGet(exListPath, tokA.rawToken);
      const lB = await examinerGet(exListPath, tokB.rawToken);
      const aListIds: string[] = Array.isArray(lA.json?.cases) ? lA.json.cases.map((c: any) => c.id) : [];
      const bListIds: string[] = Array.isArray(lB.json?.cases) ? lB.json.cases.map((c: any) => c.id) : [];
      const pass =
        rA.status === 200 &&
        rB.status === 404 &&
        rlsB === 0 &&
        rlsA === 1 &&
        ownerExists === 1 &&
        aListIds.includes(gCase) &&
        !bListIds.includes(gCase) &&
        lA.json?.tenantId === TENANT_A &&
        lB.json?.tenantId === TENANT_B;
      run.leg({
        id: "M4-3",
        scenario: "TENANT-SCOPED — tenant-A token 200 + case in A's list; tenant-B token 404 + case NOT in B's list; rlsCountById(B)=0 while rlsCountById(A)=1 and owner ground-truth=1 (isolation is RLS as aegis_app, not an app filter or a missing row)",
        expectation: "cross-tenant detail read 404 not-found (indistinguishable from absent — no oracle); the case is invisible under the app role in tenant B (RLS) yet visible in tenant A and present in owner ground truth; the B list (resolved tenant=B) omits the A case while the A list includes it",
        observed: `A detail ${rA.status}; B detail ${rB.status}; rlsB=${rlsB}; rlsA=${rlsA}; ownerExists=${ownerExists}; aList∋gCase=${aListIds.includes(gCase)}(tenant=${lA.json?.tenantId}); bList∋gCase=${bListIds.includes(gCase)}(tenant=${lB.json?.tenantId})`,
        verdict: pass
          ? verdict.verified("tenant-A 200 paired with tenant-B 404 + role-switched rlsCountById B=0/A=1 + owner=1, and the resolved-tenant-B list omits the A case while the A list includes it — RLS-enforced isolation, proven as the non-BYPASSRLS app role")
          : verdict.fail(),
        pass,
        signals: { aDetail: rA.status, bDetail: rB.status, rlsB, rlsA, ownerExists, aHas: aListIds.includes(gCase), bHas: bListIds.includes(gCase) },
      });
    }

    // ── LEG M4-4 — READ-ONLY FENCE: no non-GET surface; rows byte-identical ─────
    {
      const before = await caseSecrets(TENANT_A, gCase);
      const countsBefore = await childCounts(TENANT_A, gCase);
      // CSRF satisfied (cookie + token) AND a valid read:fincrime Bearer — so a
      // 404/405 means "no write route", not a hollow CSRF-403 or auth-401.
      const fenceJar = new Jar();
      const fenceCsrf = await getCsrf(fenceJar, ips[4], SERVER);
      const fenceCookie = fenceJar.header();
      const methods = ["POST", "PUT", "PATCH", "DELETE"];
      const detailStatuses: number[] = [];
      const listStatuses: number[] = [];
      for (const m of methods) {
        detailStatuses.push((await examinerCall(m, exCasePath(gCase), { rawToken: tokA.rawToken, csrf: fenceCsrf, cookie: fenceCookie })).status);
        listStatuses.push((await examinerCall(m, exListPath, { rawToken: tokA.rawToken, csrf: fenceCsrf, cookie: fenceCookie })).status);
      }
      const rGet = await examinerGet(exCasePath(gCase), tokA.rawToken);
      await sleep();
      const after = await caseSecrets(TENANT_A, gCase);
      const countsAfter = await childCounts(TENANT_A, gCase);
      const all = [...detailStatuses, ...listStatuses];
      const noWriteSurface = all.every((s) => s === 404 || s === 405) && !all.some((s) => s >= 200 && s < 300);
      const rowIdentical =
        !!before && !!after &&
        before.state === after.state &&
        before.disposition === after.disposition &&
        before.disposition_reason === after.disposition_reason &&
        before.str_filed === after.str_filed &&
        before.updated_at.getTime() === after.updated_at.getTime();
      const countsIdentical = JSON.stringify(countsBefore) === JSON.stringify(countsAfter);
      const pass = noWriteSurface && rGet.status === 200 && rowIdentical && countsIdentical;
      run.leg({
        id: "M4-4",
        scenario: "READ-ONLY FENCE — POST/PUT/PATCH/DELETE on both /cases and /cases/:id (valid read:fincrime Bearer + CSRF satisfied) → 404/405, never 2xx; GET 200; case row + every child count byte-identical before/after",
        expectation: "no non-GET method on either examiner surface returns 2xx (all 404/405 — only GET is registered; with Bearer+CSRF satisfied the 404 means no write route, not a hollow CSRF/auth rejection); GET renders 200; durable case row (state/disposition/reason/str_filed/updated_at) + events/evidence/str/approvals counts unchanged",
        observed: `detail[POST,PUT,PATCH,DELETE]=[${detailStatuses.join(",")}]; list=[${listStatuses.join(",")}]; GET ${rGet.status}; rowIdentical=${rowIdentical}; countsBefore=${JSON.stringify(countsBefore)} countsAfter=${JSON.stringify(countsAfter)}`,
        verdict: pass
          ? verdict.verified("every non-GET method on both surfaces 404/405 (no write route) with Bearer+CSRF satisfied, GET 200, and owner read-back proves the case row + all child counts byte-identical — read-only fence holds (M3 write edges untouched)")
          : verdict.fail(),
        pass,
        signals: { detailStatuses, listStatuses, get: rGet.status, rowIdentical, countsIdentical },
      });
    }

    // ── LEG M4-5 — AUTHZ/SCOPE: no Bearer 401; wrong scope 403; revoked 401 ─────
    {
      const rNoBearer = await examinerCall("GET", exCasePath(gCase), {});
      const tokWrong = await issueToken(ad, ["read:kyc"]); // valid token, wrong scope
      const rWrongScope = await examinerGet(exCasePath(gCase), tokWrong.rawToken);
      const tokRevoke = await issueToken(ad, ["read:fincrime"]);
      const revokeRes = await examinerCall("DELETE", `/api/wave9/examiner-tokens/${tokRevoke.id}`, {
        cookie: admin.jar.header(),
        csrf: admin.csrf,
      });
      const rRevoked = await examinerGet(exCasePath(gCase), tokRevoke.rawToken);
      const rValid = await examinerGet(exCasePath(gCase), tokA.rawToken); // paired positive
      const pass =
        rNoBearer.status === 401 &&
        rWrongScope.status === 403 &&
        (revokeRes.status === 200 || revokeRes.status === 204) &&
        rRevoked.status === 401 &&
        rValid.status === 200;
      run.leg({
        id: "M4-5",
        scenario: "AUTHZ/SCOPE — no Bearer → 401; valid token of WRONG scope (read:kyc) → 403; revoked read:fincrime token → 401; valid read:fincrime token → 200",
        expectation: "the scope gate genuinely enforces read:fincrime: missing Bearer 401 (examiner-bearer-token-required); a valid token lacking read:fincrime 403 (invalid-examiner-token/scope); a revoked token 401; paired positive: a valid read:fincrime token 200",
        observed: `noBearer ${rNoBearer.status}; wrongScope ${rWrongScope.status}; revoke ${revokeRes.status}; revoked ${rRevoked.status}; valid ${rValid.status}`,
        verdict: pass
          ? verdict.verified("missing/ wrong-scope/ revoked tokens each rejected (401/403/401) and the valid read:fincrime token 200 — the examiner scope gate is genuinely enforced, paired against the positive")
          : verdict.fail(),
        pass,
        signals: { noBearer: rNoBearer.status, wrongScope: rWrongScope.status, revoke: revokeRes.status, revoked: rRevoked.status, valid: rValid.status },
      });
    }
  } 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); tampered audit entry restored to exact original bytes.",
    );
    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);
});
