// audit_chain_2968_test.go — REAL-DATABASE proof that the verifier can tell a
// migration-224 MIXED-VERSION ROLLOUT from TAMPERING, in both directions.
//
// Issue #2968 (P2, prod-enablement, gates the :8091 production routing switch).
// Follows #2949 (the chain_seq column) and #2963 (its review).
//
// ============================================================================
// WHAT WAS WRONG
// ============================================================================
//
// Migration 224 added `agent_audit_log.chain_seq` and made it, not
// `created_at`, the key the hash chain is ordered on. A binary PREDATING that
// migration, writing a chained row after it has run, leaves the column at its
// `NOT NULL DEFAULT 0`. Zero sorts before every backfilled row, so the walk
// starts where the hashes do not and invariant 1 fails at the first row.
//
// The chain really is unwalkable, so the break is correct and the loud posture
// is right. What was wrong is that `AuditChainBroken` paged it as
//
//	"a link whose stored hash does not match its recomputed value. Rows in
//	 this window are no longer tamper-evident … escalate to the founder. A
//	 confirmed break is a disclosure question."
//
// for a deploy the operator started themselves, and nothing on the alert let
// the on-call tell the two apart.
//
// ============================================================================
// THE DISCRIMINATOR, AND WHY IT IS EXACT RATHER THAN A HEURISTIC
// ============================================================================
//
// `VerifyReport.BrokenAtUnsequencedRow` is true when the break is a LINK/ORDER
// failure (invariant 1, 2 or 4) at a row carrying chain_seq = 0. That is the
// only signature a pre-224 writer can leave.
//
// Invariant 3 — the content hash — is deliberately excluded, and it is the
// exclusion that makes the flag trustworthy. That invariant recomputes
// SHA256(row.prev_hash || JCS(content)) from the row's OWN columns, with no
// reference to its neighbours, so it is independent of walk order: a pre-224
// row is self-consistent and passes it, and a row that fails it has had its
// CONTENT changed wherever its own sequence sits.
//
// READ "ITS OWN" LITERALLY. The guard covers the row the walk BREAKS AT. A
// tamper further along the chain can still be hidden behind a different row
// whose sequence was zeroed, because the walk stops at its first failure —
// that limit is constructed, measured and pinned in test 4b below, and it is
// the reason this header does not say "never".
//
// #2968 also MOVED invariant 3 to run BEFORE the link invariants, and
// TheContentTamperAtAnUnsequencedRowIsStillTamper below is why. `chain_seq` is
// not part of the row hash, so anyone who can UPDATE the table could edit a
// row's content and zero its sequence in one statement; with invariant 3 last,
// the row sorted to the front, invariant 1 fired first, and the tamper was
// classified as a rollout artefact — a self-service downgrade from critical to
// warning. Order of evaluation, as a security property.
//
// ============================================================================
// SIX TESTS: TWO POSITIVE, THREE NEGATIVE CONTROLS, ONE DECLARED LIMIT
// ============================================================================
//
//	TheUnsequencedRowIsClassifiedAsRolloutSkew   the artefact  => flag TRUE
//	TheContentTamperIsNotClassifiedAsRolloutSkew a real tamper => flag FALSE
//	TheContentTamperAtAnUnsequencedRowIsStillTamper  both, one row => flag FALSE
//	APlainReorderIsNotClassifiedAsRolloutSkew    order break at a NON-zero
//	                                             sequence      => flag FALSE
//	ATamperCanHideBehindALaterZeroedSequence_KnownLimit
//	                                             two rows      => flag TRUE
//	OneTamperedSessionDropsTheFlagForTheWholeOrg the aggregate => flag FALSE
//
// The first alone is satisfiable by `BrokenAtUnsequencedRow = !OK`, which would
// route every break in the platform away from the tamper alert. The fourth is
// the sharpest control: it is a genuine order break, so it separates "the flag
// means chain_seq is zero" from "the flag means an ordering invariant fired".
// The fifth asserts a hole rather than a property, and says so in its name.
//
// Every UPDATE below is a FORENSIC SIMULATION, not a reachable path: migration
// 017 REVOKEs UPDATE on agent_audit_log FROM PUBLIC and this pool connects as
// the table owner. Detection must not depend on prevention — same posture as
// TestAuditChainSeq2949_ReorderingTwoRowsIsRefused, whose fixture this reuses.
//
// Run:
//
//	DATABASE_URL="postgres://..." go test ./test/integration/context/... \
//	  -run TestAuditChain2968 -count=1
package context_integration

import (
	"context"
	"strings"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/upsquad-ai/upsquad-core/internal/runtime"
	"github.com/upsquad-ai/upsquad-core/internal/runtime/audit"
)

// buildChain2968 writes `n` serially-stamped org-grain rows and asserts the
// resulting chain verifies. It returns the rows in walk order.
//
// THE POSITIVE CONTROL IS NOT OPTIONAL. Every assertion in this file is about
// how a break is CLASSIFIED, and a verifier that refused everything would
// satisfy three of the four tests below. The control is what makes the refusals
// mean something.
func buildChain2968(t *testing.T, pool *pgxpool.Pool, orgID string, n int) []chainRow2949 {
	t.Helper()
	at := time.Now().UTC().Add(-time.Hour)
	for i := 0; i < n; i++ {
		burstDenials2949(t, pool, orgID, 1, func(int) time.Time {
			return at.Add(time.Duration(i) * time.Second)
		})
	}
	rep, err := audit.NewVerifier(pool).VerifyOrgScope(context.Background(), orgID, chainScope2949)
	if err != nil {
		t.Fatalf("VerifyOrgScope (control): %v", err)
	}
	if !rep.OK || rep.TotalRows != n {
		t.Fatalf("POSITIVE CONTROL FAILED: the untampered chain does not verify "+
			"(ok=%v rows=%d want %d: %s). Every classification assertion below is "+
			"vacuous until this passes", rep.OK, rep.TotalRows, n, rep.BrokenReason)
	}
	if rep.BrokenAtUnsequencedRow {
		t.Fatalf("an INTACT chain reports BrokenAtUnsequencedRow=true. The flag must be " +
			"false on every OK report — cmd/audit-verify emits it as an explicit 0 for " +
			"healthy chains and a true here would page a working platform as mid-rollout")
	}
	rows := readChain2949(t, pool, orgScopeWhere2949, orgID, chainScope2949)
	if len(rows) != n {
		t.Fatalf("read %d chain rows, want %d", len(rows), n)
	}
	return rows
}

// verify2968 re-walks the chain and returns the report.
func verify2968(t *testing.T, pool *pgxpool.Pool, orgID string) *audit.VerifyReport {
	t.Helper()
	rep, err := audit.NewVerifier(pool).VerifyOrgScope(context.Background(), orgID, chainScope2949)
	if err != nil {
		t.Fatalf("VerifyOrgScope: %v", err)
	}
	if rep.OK {
		t.Fatalf("the verifier ACCEPTED a chain this test just broke. Nothing below can be "+
			"measured on a chain that verifies. rows=%d", rep.TotalRows)
	}
	return rep
}

// ---------------------------------------------------------------------------
// 1. THE ARTEFACT. A pre-224 binary appends without a sequence.
// ---------------------------------------------------------------------------
//
// Simulated by zeroing the TAIL row's chain_seq, which is byte-for-byte the
// state such a writer leaves: content untouched, prev_hash still pointing at
// the row that was the tail when it was written, sequence at the column
// default. The row then sorts to the FRONT of the walk, its prev_hash is not
// the 32-zero root, and invariant 1 refuses it.
func TestAuditChain2968_TheUnsequencedRowIsClassifiedAsRolloutSkew(t *testing.T) {
	pool := setupChainSeq2949(t)
	orgID := newOrg2949(t, pool)
	rows := buildChain2968(t, pool, orgID, 5)

	tail := rows[len(rows)-1]
	if tail.seq == 0 {
		t.Fatalf("fixture precondition: the tail row already has chain_seq=0, so the " +
			"mutation below would change nothing and the test would measure the " +
			"unmutated chain")
	}
	if _, err := pool.Exec(context.Background(),
		`UPDATE agent_audit_log SET chain_seq = 0 WHERE id = $1::uuid`, tail.id); err != nil {
		t.Fatalf("simulate a pre-224 append: %v", err)
	}

	rep := verify2968(t, pool, orgID)
	if !rep.BrokenAtUnsequencedRow {
		t.Fatalf("a break at chain_seq=0 was NOT classified as rollout skew: %q\n"+
			"This is the defect #2968 was filed for: the deploy artefact routes to "+
			"AuditChainBroken, whose runbook tells the on-call this is a disclosure "+
			"question", rep.BrokenReason)
	}
	// The reason must NAME the sequence, because the runbook sends the on-call
	// to `kubectl logs` for exactly this string.
	if !strings.Contains(rep.BrokenReason, "chain_seq=0") {
		t.Errorf("the refusal does not name chain_seq=0: %q", rep.BrokenReason)
	}
	if rep.BrokenAtRow != 1 {
		t.Errorf("broken at walk position %d, want 1 — a zero sequence sorts before every "+
			"backfilled row, so the artefact is always the FIRST row the walk sees. If "+
			"this is not 1 the walk is not ordered on chain_seq", rep.BrokenAtRow)
	}
}

// ---------------------------------------------------------------------------
// 2. THE NEGATIVE CONTROL. A real content tamper, sequence untouched.
// ---------------------------------------------------------------------------
//
// Editing `detail` changes the canonical bytes and nothing else: prev_hash
// still matches the predecessor's row_hash, so invariant 2 passes and invariant
// 3 is what refuses the row. The flag must stay false or a genuine tamper is
// downgraded to a warning.
func TestAuditChain2968_TheContentTamperIsNotClassifiedAsRolloutSkew(t *testing.T) {
	pool := setupChainSeq2949(t)
	orgID := newOrg2949(t, pool)
	rows := buildChain2968(t, pool, orgID, 5)

	victim := rows[2]
	if _, err := pool.Exec(context.Background(),
		`UPDATE agent_audit_log SET detail = jsonb_set(COALESCE(detail, '{}'::jsonb),
             '{tampered_by_2968}', '"yes"'::jsonb) WHERE id = $1::uuid`, victim.id); err != nil {
		t.Fatalf("simulate a content tamper: %v", err)
	}

	rep := verify2968(t, pool, orgID)
	if rep.BrokenAtUnsequencedRow {
		t.Fatalf("a CONTENT TAMPER was classified as a migration-224 rollout artefact: %q\n"+
			"The alert then pages at warning with 'complete the rollout' instead of "+
			"critical with the disclosure runbook", rep.BrokenReason)
	}
	if !strings.Contains(rep.BrokenReason, "content tamper") {
		t.Errorf("the refusal does not name the content tamper: %q", rep.BrokenReason)
	}
}

// ---------------------------------------------------------------------------
// 3. THE ANTI-DOWNGRADE TEST — both mutations on the same row.
// ---------------------------------------------------------------------------
//
// This is the case that decides whether the discriminator is safe to route on,
// and the reason #2968 moved invariant 3 ahead of the link invariants.
//
// `chain_seq` is not part of the row hash, so an insider who can UPDATE the
// table can edit a row's CONTENT and zero its sequence in one statement. With
// invariant 3 evaluated last, the row sorted to the front, invariant 1 fired on
// it first, and the verifier called a deliberate content edit a rollout
// artefact — the attacker choosing their own alert severity.
//
// Invariant 3 is order-independent, so evaluating it first closes that
// completely: whatever chain_seq says, an edited row reports as an edited row.
func TestAuditChain2968_TheContentTamperAtAnUnsequencedRowIsStillTamper(t *testing.T) {
	pool := setupChainSeq2949(t)
	orgID := newOrg2949(t, pool)
	rows := buildChain2968(t, pool, orgID, 5)

	victim := rows[3]
	if _, err := pool.Exec(context.Background(),
		`UPDATE agent_audit_log
            SET detail = jsonb_set(COALESCE(detail, '{}'::jsonb),
                                   '{tampered_by_2968}', '"yes"'::jsonb),
                chain_seq = 0
          WHERE id = $1::uuid`, victim.id); err != nil {
		t.Fatalf("simulate a hidden content tamper: %v", err)
	}

	rep := verify2968(t, pool, orgID)
	if rep.BrokenAtUnsequencedRow {
		t.Fatalf("a content tamper HID BEHIND chain_seq=0 and was classified as a rollout "+
			"artefact: %q\nAn attacker who can write to the table can then choose their own "+
			"alert severity — critical becomes warning by adding one column to the UPDATE. "+
			"Invariant 3 must be evaluated BEFORE the link invariants (verifier.go)",
			rep.BrokenReason)
	}
	if !strings.Contains(rep.BrokenReason, "content tamper") {
		t.Errorf("the refusal does not name the content tamper: %q. The row sorted to the "+
			"front of the walk, so a report naming invariant 1 means the ordering check "+
			"ran first and won", rep.BrokenReason)
	}
}

// ---------------------------------------------------------------------------
// 4. THE SHARPEST CONTROL — an order break at a NON-ZERO sequence.
// ---------------------------------------------------------------------------
//
// Two intact rows swapped, exactly as TestAuditChainSeq2949_ReorderingTwoRowsIsRefused
// does. Content untouched, hashes untouched, links untouched: this is a genuine
// LINK/ORDER failure and invariant 2 refuses it. No sequence is zero.
//
// It separates the two readings of the flag that tests 1-3 cannot: "the break
// was at chain_seq = 0" (correct) versus "an ordering invariant fired"
// (catastrophic — every reorder, insert and delete in the platform would route
// away from the tamper alert). A `BrokenAtUnsequencedRow = !OK` implementation
// passes tests 1, 2 and 3 by luck and fails only this one.
func TestAuditChain2968_APlainReorderIsNotClassifiedAsRolloutSkew(t *testing.T) {
	pool := setupChainSeq2949(t)
	orgID := newOrg2949(t, pool)
	rows := buildChain2968(t, pool, orgID, 5)

	if rows[2].seq == 0 || rows[3].seq == 0 {
		t.Fatalf("fixture precondition: the swapped rows must both carry NON-ZERO "+
			"sequences or this test degenerates into test 1 (got %d and %d)",
			rows[2].seq, rows[3].seq)
	}
	if _, err := pool.Exec(context.Background(), `
        UPDATE agent_audit_log SET chain_seq = CASE id WHEN $1::uuid THEN $3::bigint ELSE $4::bigint END
         WHERE id IN ($1::uuid, $2::uuid)`,
		rows[2].id, rows[3].id, rows[3].seq, rows[2].seq); err != nil {
		t.Fatalf("simulate reorder: %v", err)
	}

	rep := verify2968(t, pool, orgID)
	if rep.BrokenAtUnsequencedRow {
		t.Fatalf("a reorder at NON-ZERO sequences was classified as a migration-224 rollout "+
			"artefact: %q\nThe flag is keyed on the ordering invariant firing rather than on "+
			"chain_seq being 0, which routes every insert, delete and reorder in the platform "+
			"away from the tamper alert", rep.BrokenReason)
	}
}

// ---------------------------------------------------------------------------
// 4b. THE DECLARED BLIND SPOT — a tamper hiding behind a LATER zeroed sequence.
// ---------------------------------------------------------------------------
//
// CONSTRUCTED BY THE ARCHITECT ON REVIEW OF THIS PR, and landed here because a
// limit that is only described in prose is a limit the next author builds on
// top of. The PR body and migration 224's header both over-claimed — "a content
// tamper is NEVER classified as skew, whatever its chain_seq" — and this is the
// two-statement counterexample that falsifies them. Both have been corrected to
// the narrower true claim; this test is what keeps them honest.
//
// The attack needs no hash recomputation at all:
//
//  1. edit row K's content              -> K now fails invariant 3
//  2. zero row J's chain_seq, J AFTER K -> J sorts to the FRONT of the walk
//
// Row J is untouched and self-consistent, so the anti-downgrade guard — which
// checks only the BREAKING row's own content hash — passes it. Invariant 1 then
// fires at chain_seq = 0, the report is classified as rollout skew, and K's
// invariant-3 failure is NEVER REACHED because the walk stops at its first
// failure.
//
// The guard closes the single-row downgrade (test 3 above) and cannot close
// this one: the attacker does not move the breaking row's content hash, they
// hide behind a DIFFERENT row.
//
// WHY THIS IS ACCEPTED RATHER THAN FIXED. It is inherent to "the walk stops at
// the first failure" plus "chain_seq is not hashed". It requires UPDATE on
// agent_audit_log. It is a severity DOWNGRADE (critical -> warning), never a
// silencing. And the operator surface already says so in the two places that
// matter: AuditChainRolloutSkew's description ("this chain is UNVERIFIED, not
// verified: the walk stops at the first failure, so a genuine break further
// along it is invisible") and runbook step 2, which sends an unexplained skew
// straight to the tamper path.
//
// THE TEST ASSERTS THE BOUNDARY, NOT JUST THE HOLE. Both halves of the
// architect's measurement are pinned: the tamper ALONE reports content tamper
// with the flag false, and only the addition of the second UPDATE flips it. A
// test that asserted the hole alone would also pass on a build that classified
// every break as skew.
func TestAuditChain2968_ATamperCanHideBehindALaterZeroedSequence_KnownLimit(t *testing.T) {
	pool := setupChainSeq2949(t)
	orgID := newOrg2949(t, pool)
	rows := buildChain2968(t, pool, orgID, 5)

	// K is the tampered row; J sorts after it and is the one that gets zeroed.
	victimK, hiderJ := rows[1], rows[3]
	if victimK.seq >= hiderJ.seq {
		t.Fatalf("fixture precondition: K (seq=%d) must precede J (seq=%d) in walk order, "+
			"or the hide does not happen and this test degenerates into test 2",
			victimK.seq, hiderJ.seq)
	}

	ctx := context.Background()

	// ---- HALF ONE: the tamper on its own. The boundary. ----
	if _, err := pool.Exec(ctx, `
        UPDATE agent_audit_log SET detail = jsonb_set(COALESCE(detail, '{}'::jsonb),
             '{tampered_by_2968}', '"yes"'::jsonb) WHERE id = $1::uuid`, victimK.id); err != nil {
		t.Fatalf("simulate a content tamper: %v", err)
	}
	rep := verify2968(t, pool, orgID)
	if rep.BrokenAtUnsequencedRow {
		t.Fatalf("BOUNDARY FAILED: the tamper alone was already classified as skew (%q). "+
			"The second UPDATE below is then not what causes the hide, and this test "+
			"measures nothing", rep.BrokenReason)
	}
	if !strings.Contains(rep.BrokenReason, "content tamper") {
		t.Fatalf("BOUNDARY FAILED: the tamper alone did not report as a content tamper: %q",
			rep.BrokenReason)
	}
	t.Logf("boundary — tamper alone: flag=%v reason=%q", rep.BrokenAtUnsequencedRow, rep.BrokenReason)

	// ---- HALF TWO: add the hider. One more UPDATE, no hash recomputed. ----
	if _, err := pool.Exec(ctx,
		`UPDATE agent_audit_log SET chain_seq = 0 WHERE id = $1::uuid`, hiderJ.id); err != nil {
		t.Fatalf("simulate the hider: %v", err)
	}
	rep = verify2968(t, pool, orgID)

	// THIS ASSERTS THE LIMIT, SO IT IS SPELLED AS THE LIMIT. If a future change
	// closes it — by continuing the walk past the first failure, by hashing
	// chain_seq, or by scanning for unsequenced rows before walking — this test
	// FAILS, and that failure is the signal to delete it and to correct the
	// three surfaces named in the message. It is not a regression.
	if !rep.BrokenAtUnsequencedRow {
		t.Fatalf("the known limit no longer reproduces: a tamper at chain_seq=%d hidden "+
			"behind a zeroed chain_seq at position %d now reports flag=false (%q).\n"+
			"IF THIS IS DELIBERATE, THIS TEST SHOULD BE DELETED and three places updated "+
			"to drop the caveat: AuditChainRolloutSkew's description and runbook step 2 "+
			"in deploy/alerts/audit-chain.yaml, docs/runbooks/audit-chain-224-rollout.md, "+
			"and migration 224's header. Do not silently leave them saying a limit exists "+
			"that does not.", victimK.seq, hiderJ.seq, rep.BrokenReason)
	}
	if !strings.Contains(rep.BrokenReason, "chain_seq=0") {
		t.Errorf("the hidden case does not report at chain_seq=0: %q", rep.BrokenReason)
	}
	t.Logf("limit — tamper + hider: flag=%v reason=%q", rep.BrokenAtUnsequencedRow, rep.BrokenReason)
}

// ---------------------------------------------------------------------------
// 5. THE AGGREGATE. One org, two broken sessions, two different causes.
// ---------------------------------------------------------------------------
//
// VerifyOrg emits ONE metric series for the whole org's session grain, so the
// flag on the aggregate report is a claim about EVERY break the pass found, not
// about the first one. It is therefore AND-ed across sessions rather than
// hoisted with the details of the first break.
//
// Hoisting would be the obvious implementation and it is exploitable: sessions
// are walked `ORDER BY session_id::text`, so whichever session sorts first
// decides the wording for the whole org. A rollout artefact in session A would
// downgrade a genuine content tamper in session B from a critical tamper page
// to a warning that says "complete the rollout" — and the ordering that picks
// the winner is a UUID.
//
// ── THE WALK ORDER IS PINNED BY THE FIXTURE, AND THAT IS THE POINT ──────────
//
// The first version of this test assigned the two roles in creation order and
// carried a comment claiming "both orders are exercised, so the test cannot
// pass by accident on a lucky draw". That was FALSE, and it was measured false
// on review: `newSession2949` mints a random UUID, so which of the two broken
// sessions is the "first break" is a coin flip, and the hoist is only
// distinguishable when the ARTEFACT sorts first. Ten runs against the hoist
// mutant killed it 4 times.
//
// A 40%-of-the-time oracle for the exact security property this file exists to
// create is worse than no oracle: it is green in CI for months and red in
// somebody's local run, and on a genuine regression it lands as a flake, which
// gets re-run rather than read.
//
// So the roles are assigned from the SORTED ids, not from creation order, and
// the direction is asserted rather than assumed. Production ordering is
// untouched — the fixture is what became deterministic.
func TestAuditChain2968_OneTamperedSessionDropsTheFlagForTheWholeOrg(t *testing.T) {
	pool := setupChainSeq2949(t)
	orgID := newOrg2949(t, pool)

	// Two independent session chains under one org, then ROLES BY SORT ORDER.
	//
	// artefact = the session VerifyOrg reaches first. Under the hoist mutant its
	// verdict is the one that gets hoisted, so the tampered session's
	// invariant-3 failure is the thing being wrongly excused — the state the
	// assertion at the bottom is about. Assigning the other way round makes the
	// mutant indistinguishable from correct code.
	first, second := writeSessionChain2968(t, pool, orgID, 4), writeSessionChain2968(t, pool, orgID, 4)
	if first > second {
		first, second = second, first
	}
	artefact, tampered := first, second
	// Go's `<` on these strings agrees with Postgres's `ORDER BY
	// session_id::text` for the whole domain: pgx renders a uuid as lowercase
	// hex with dashes, which is ASCII, and C-collation byte order and Go's byte
	// order coincide there.
	if artefact >= tampered {
		t.Fatalf("fixture precondition: artefact %q must sort BEFORE tampered %q, or the "+
			"hoist mutant is indistinguishable from correct code and this test is a coin "+
			"flip again", artefact, tampered)
	}
	// Self-diagnosing on a future failure: the walk order is the first thing to
	// check and it is not otherwise recoverable from the assertion message.
	t.Logf("walk order (ORDER BY session_id::text): [1] %s = rollout artefact, [2] %s = content tamper",
		artefact, tampered)

	ctx := context.Background()
	v := audit.NewVerifier(pool)

	// POSITIVE CONTROL. Both chains verify, and the org aggregate is clean.
	agg, err := v.VerifyOrg(ctx, orgID, time.Time{}, time.Time{})
	if err != nil {
		t.Fatalf("VerifyOrg (control): %v", err)
	}
	if !agg.OK || agg.SessionsVerified != 2 {
		t.Fatalf("POSITIVE CONTROL FAILED: ok=%v sessions=%d want true/2 (%s). Every "+
			"assertion below is vacuous until this passes",
			agg.OK, agg.SessionsVerified, agg.BrokenReason)
	}
	if agg.BrokenAtUnsequencedRow {
		t.Fatalf("an intact org aggregate reports BrokenAtUnsequencedRow=true")
	}

	// Session A: the rollout artefact. Zero the tail's sequence.
	rowsA := readChain2949(t, pool, sessionWhere2949, orgID, artefact)
	if _, err := pool.Exec(ctx, `UPDATE agent_audit_log SET chain_seq = 0 WHERE id = $1::uuid`,
		rowsA[len(rowsA)-1].id); err != nil {
		t.Fatalf("simulate a pre-224 append: %v", err)
	}

	// SANITY: on its own, session A's break IS classified as rollout skew. If
	// this is false the final assertion below would pass for the wrong reason —
	// "the flag is never true" also satisfies it.
	repA, err := v.VerifySession(ctx, orgID, artefact)
	if err != nil {
		t.Fatalf("VerifySession(A): %v", err)
	}
	if repA.OK || !repA.BrokenAtUnsequencedRow {
		t.Fatalf("session A alone: ok=%v flag=%v, want false/true (%s). The org-level "+
			"assertion below is only meaningful if this session on its own is a rollout "+
			"artefact", repA.OK, repA.BrokenAtUnsequencedRow, repA.BrokenReason)
	}

	// Session B: a genuine content tamper.
	rowsB := readChain2949(t, pool, sessionWhere2949, orgID, tampered)
	if _, err := pool.Exec(ctx, `
        UPDATE agent_audit_log SET detail = jsonb_set(COALESCE(detail, '{}'::jsonb),
             '{tampered_by_2968}', '"yes"'::jsonb) WHERE id = $1::uuid`,
		rowsB[2].id); err != nil {
		t.Fatalf("simulate a content tamper: %v", err)
	}

	agg, err = v.VerifyOrg(ctx, orgID, time.Time{}, time.Time{})
	if err != nil {
		t.Fatalf("VerifyOrg (broken): %v", err)
	}
	if agg.OK || agg.SessionsBroken != 2 {
		t.Fatalf("VerifyOrg found ok=%v sessions_broken=%d, want false/2 — both chains "+
			"were broken and the aggregate must see both", agg.OK, agg.SessionsBroken)
	}
	if agg.BrokenAtUnsequencedRow {
		t.Fatalf("the org aggregate is classified as a rollout artefact while one of its "+
			"two broken sessions is a CONTENT TAMPER (%q). The flag is hoisted from the "+
			"first break rather than AND-ed across every break, so which alert the tamper "+
			"pages as is decided by a UUID sort", agg.BrokenReason)
	}
}

// writeSessionChain2968 mints a session and writes n serially-stamped chained
// rows into it, returning the session id.
func writeSessionChain2968(t *testing.T, pool *pgxpool.Pool, orgID string, n int) string {
	t.Helper()
	agentID, sessionID := newSession2949(t, pool, orgID)
	store := audit.EnableChain(audit.NewPGStore(pool), audit.NewChainMode(true))
	at := time.Now().UTC().Add(-time.Hour)
	for i := 0; i < n; i++ {
		if err := store.InsertBatch(context.Background(), []audit.Entry{{
			OrgID:      orgID,
			AgentID:    agentID,
			SessionID:  sessionID,
			ActionType: runtime.AuditActionType("tool_call"),
			Detail:     map[string]interface{}{"i": i},
			CreatedAt:  at.Add(time.Duration(i) * time.Second),
		}}); err != nil {
			t.Fatalf("InsertBatch #%d: %v", i, err)
		}
	}
	return sessionID
}
