Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions __tests__/csend-proof-invariant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* C-SEND-1 proof=0 honesty invariant.
*
* Two guarantees that together prove approving in the dry-run era cannot
* manufacture proof while proof_events = 0:
* (1) the console decide route NEVER writes a `proof_events` row (it records an
* approval via outcome_receipts, but proof_events is Pulse-owned - written
* only on real provider success / PILOT_LOCK blocked-emit);
* (2) the buyer proof ledger built from an empty proof-event set reports
* proof_events = 0 and wins = 0 (no fabricated recovery).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
vi.mock("@/lib/api/error-handler", () => ({
withErrorHandler: (fn: (...a: unknown[]) => unknown) => fn,
}));
vi.mock("@/lib/logger", () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/autonomy/engine", () => ({
recordApproval: vi.fn().mockResolvedValue(undefined),
recordDismissal: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/outcomes/record-outcome", () => ({
recordOutcome: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/focus/decision-telemetry", () => ({
estimateTrackedRevenue: vi.fn(() => 150),
toAutonomyCategory: vi.fn(() => "recovery"),
toTrackedActionType: vi.fn(() => "win_back"),
}));
vi.mock("@/lib/outcome-receipts", () => ({
normalizeOutcomeReceiptActionType: vi.fn(() => "win_back"),
}));

import { createClient } from "@/lib/supabase/server";
import { buildBuyerProofLedger } from "@/lib/proof-ledger/buyer-proof-ledger";

const ORIGINAL_PILOT_LOCK = process.env.PILOT_LOCK;
const ORIGINAL_OUTREACH_CONSUMER_ENABLED = process.env.OUTREACH_CONSUMER_ENABLED;

function chain(result: { data: unknown; error: unknown }) {
const c: Record<string, ReturnType<typeof vi.fn>> = {
update: vi.fn(() => c),
eq: vi.fn(() => c),
select: vi.fn(() => c),
single: vi.fn().mockResolvedValue(result),
};
return c;
}

async function callApprove() {
const fromCalls: string[] = [];
const profiles = {
select: vi.fn(() => profiles),
eq: vi.fn(() => profiles),
single: vi.fn().mockResolvedValue({ data: { tenant_id: "tenant-a" }, error: null }),
} as Record<string, ReturnType<typeof vi.fn>>;
const recovery_items = chain({
data: {
id: "ri1", status: "approved", type: "win_back", service_type: null,
suggested_action: "Text Maria", client_name: "Maria", client_id: "c1", confidence_score: 82,
// Even a fully LIVE + pilot-unlocked + consumer-on approve must not write proof_events.
data_provenance: "live", side_effect_mode: "live",
},
error: null,
});
const outreach_queue = { insert: vi.fn().mockResolvedValue({ error: null }) };
const outcome_receipts = { insert: vi.fn().mockResolvedValue({ error: null }) };
const proof_events = { insert: vi.fn().mockResolvedValue({ error: null }) };
const supabase = {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: "u1" } } }) },
from: vi.fn((t: string) => {
fromCalls.push(t);
return ({ profiles, recovery_items, outreach_queue, outcome_receipts, proof_events } as Record<string, unknown>)[t];
}),
};
vi.mocked(createClient).mockResolvedValue(supabase as never);
const mod = await import("@/app/api/v1/focus/decide/route");
const req = new Request("https://x.test/api/v1/focus/decide", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: "ri1", action: "approve" }),
});
const res = await mod.POST(req as never);
return { res, fromCalls, outcome_receipts, proof_events };
}

describe("C-SEND-1 proof=0 invariant", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PILOT_LOCK = "false";
process.env.OUTREACH_CONSUMER_ENABLED = "true";
});
afterEach(() => {
if (ORIGINAL_PILOT_LOCK === undefined) delete process.env.PILOT_LOCK;
else process.env.PILOT_LOCK = ORIGINAL_PILOT_LOCK;
if (ORIGINAL_OUTREACH_CONSUMER_ENABLED === undefined) delete process.env.OUTREACH_CONSUMER_ENABLED;
else process.env.OUTREACH_CONSUMER_ENABLED = ORIGINAL_OUTREACH_CONSUMER_ENABLED;
});

it("approve records an outcome receipt but NEVER writes a proof_events row", async () => {
const { res, fromCalls, outcome_receipts, proof_events } = await callApprove();
expect(res.status).toBe(200);
// Approval IS recorded (the approval log).
expect(outcome_receipts.insert).toHaveBeenCalledTimes(1);
// proof_events is Pulse-owned: the console decide path must never touch it.
expect(fromCalls).not.toContain("proof_events");
expect(proof_events.insert).not.toHaveBeenCalled();
});

it("an empty proof-event set yields a ledger with proof_events = 0 and wins = 0", () => {
const ledger = buildBuyerProofLedger({
tenantId: "tenant-a",
proofEvents: [],
outcomeReceipts: [],
});
expect(ledger.totals.proof_events).toBe(0);
expect(ledger.totals.wins).toBe(0);
expect(ledger.totals.verified_wins).toBe(0);
});
});
61 changes: 58 additions & 3 deletions __tests__/focus-decide-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ vi.mock("@/lib/outcome-receipts", () => ({
import { createClient } from "@/lib/supabase/server";

const ORIGINAL_OUTREACH_CONSUMER_ENABLED = process.env.OUTREACH_CONSUMER_ENABLED;
const ORIGINAL_PILOT_LOCK = process.env.PILOT_LOCK;

// A genuinely-live recovery item: only such a row is allowed to queue an
// external send (mirrors resolveProvenance live-rule). recovery_items has no
// provenance column in prod, so these fields are absent there = fail-close.
const LIVE_PROVENANCE = { data_provenance: "live", side_effect_mode: "live" };

function makeRecoveryItemsChain(result: { data: unknown; error: unknown }) {
const c: Record<string, ReturnType<typeof vi.fn>> = {
Expand Down Expand Up @@ -87,6 +93,9 @@ describe("POST /api/v1/focus/decide", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.OUTREACH_CONSUMER_ENABLED;
// Default = pilot LOCKED (isPilotLockEnabled() is true unless PILOT_LOCK==='false').
// This is the safe prod default; queue-expecting tests must opt out explicitly.
delete process.env.PILOT_LOCK;
});

afterEach(() => {
Expand All @@ -95,6 +104,11 @@ describe("POST /api/v1/focus/decide", () => {
} else {
process.env.OUTREACH_CONSUMER_ENABLED = ORIGINAL_OUTREACH_CONSUMER_ENABLED;
}
if (ORIGINAL_PILOT_LOCK === undefined) {
delete process.env.PILOT_LOCK;
} else {
process.env.PILOT_LOCK = ORIGINAL_PILOT_LOCK;
}
});

it("rejects missing id and invalid action with 400", async () => {
Expand Down Expand Up @@ -136,31 +150,72 @@ describe("POST /api/v1/focus/decide", () => {
expect(outreach_queue.insert).not.toHaveBeenCalled();
});

it("on approve, queues outreach and records an outcome receipt", async () => {
it("on approve of a LIVE item (pilot unlocked + consumer on), queues outreach and records a receipt", async () => {
process.env.OUTREACH_CONSUMER_ENABLED = "true";
process.env.PILOT_LOCK = "false";
const { supabase, outreach_queue, outcome_receipts } = setup({
updated: { data: { id: "ri1", status: "approved", type: "win_back", service_type: null, suggested_action: "Text Maria", client_name: "Maria", client_id: "c1", confidence_score: 82 }, error: null },
updated: { data: { id: "ri1", status: "approved", type: "win_back", service_type: null, suggested_action: "Text Maria", client_name: "Maria", client_id: "c1", confidence_score: 82, ...LIVE_PROVENANCE }, error: null },
});
vi.mocked(createClient).mockResolvedValue(supabase as never);
const res = await callPOST({ id: "ri1", action: "approve" });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.would_send).toBe(true);
expect(body.send_blocked_reason).toBeNull();
expect(outreach_queue.insert).toHaveBeenCalledTimes(1);
expect(outcome_receipts.insert).toHaveBeenCalledTimes(1);
const queued = outreach_queue.insert.mock.calls[0][0];
expect(queued.tenant_id).toBe("tenant-a");
expect(queued.opportunity_id).toBe("ri1");
});

it("records approval but holds outreach queueing while the consumer is paused", async () => {
it("HOLDS the send for a non-live (sample/shadow) item even with consumer enabled + pilot unlocked", async () => {
process.env.OUTREACH_CONSUMER_ENABLED = "true";
process.env.PILOT_LOCK = "false";
// No provenance fields on the row -> resolves to not-live -> fail-close.
const { supabase, outreach_queue, outcome_receipts } = setup({
updated: { data: { id: "ri1", status: "approved", type: "win_back", service_type: null, suggested_action: "Text Maria", client_name: "Maria", client_id: "c1", confidence_score: 82 }, error: null },
});
vi.mocked(createClient).mockResolvedValue(supabase as never);
const res = await callPOST({ id: "ri1", action: "approve" });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.would_send).toBe(false);
expect(body.send_blocked_reason).toBe("not_live_provenance");
expect(outreach_queue.insert).not.toHaveBeenCalled();
// Approval is still recorded even when the external send is held.
expect(outcome_receipts.insert).toHaveBeenCalledTimes(1);
});

it("HOLDS the send when PILOT_LOCK is engaged, even for a live item with consumer enabled", async () => {
process.env.OUTREACH_CONSUMER_ENABLED = "true";
// PILOT_LOCK left at default (deleted) = enabled = locked.
const { supabase, outreach_queue, outcome_receipts } = setup({
updated: { data: { id: "ri1", status: "approved", type: "win_back", service_type: null, suggested_action: "Text Maria", client_name: "Maria", client_id: "c1", confidence_score: 82, ...LIVE_PROVENANCE }, error: null },
});
vi.mocked(createClient).mockResolvedValue(supabase as never);
const res = await callPOST({ id: "ri1", action: "approve" });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.would_send).toBe(false);
expect(body.send_blocked_reason).toBe("pilot_lock");
expect(outreach_queue.insert).not.toHaveBeenCalled();
expect(outcome_receipts.insert).toHaveBeenCalledTimes(1);
});

it("records approval but holds the send while the consumer is paused (live item, pilot unlocked)", async () => {
process.env.PILOT_LOCK = "false";
// Consumer left paused (deleted). Live provenance so the ONLY blocker is the consumer.
const { supabase, outreach_queue, outcome_receipts } = setup({
updated: { data: { id: "ri1", status: "approved", type: "win_back", service_type: null, suggested_action: "Text Maria", client_name: "Maria", client_id: "c1", confidence_score: 82, ...LIVE_PROVENANCE }, error: null },
});
vi.mocked(createClient).mockResolvedValue(supabase as never);

const res = await callPOST({ id: "ri1", action: "approve" });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.queued).toBe(false);
expect(body.send_blocked_reason).toBe("consumer_paused");
expect(body.queueWarning).toContain("External delivery is paused");
expect(outreach_queue.insert).not.toHaveBeenCalled();
expect(outcome_receipts.insert).toHaveBeenCalledTimes(1);
Expand Down
47 changes: 44 additions & 3 deletions app/(shell)/focus/FocusModeClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ interface ApproveAllSnapshot {

const CONFIDENCE_THRESHOLD = 0.7;

/**
* C-SEND-1: plain owner-facing labels for a held (approved-but-not-sent) send.
* The decide route returns send_blocked_reason when the fail-close gate holds an
* approved send; we show "Prepared, not sent" + one of these so an approval is
* never mistaken for a delivery (critical while PILOT_LOCK holds every send).
*/
const HELD_REASON_LABEL: Record<string, string> = {
pilot_lock: "Saved. It sends once testing wraps up; nothing has gone out yet.",
not_live_provenance: "Saved as a preview. Nothing has been sent.",
consumer_paused: "Saved. Sending is paused for now; nothing has gone out yet.",
};

/** Map Focus Mode decisions to the backend decide endpoint actions */
function toDecideAction(decision: ActionDecision): "approve" | "snooze" | "dismiss" {
switch (decision) {
Expand Down Expand Up @@ -291,6 +303,8 @@ export function FocusModeClient({
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
const [failed, setFailed] = useState<Set<string>>(new Set());
const [queued, setQueued] = useState<Set<string>>(new Set());
// C-SEND-1: id -> plain reason an approved send was HELD by the send-safety gate.
const [heldReasons, setHeldReasons] = useState<Map<string, string>>(new Map());
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [allApproved, setAllApproved] = useState(false);
const [openConfidenceFor, setOpenConfidenceFor] = useState<string | null>(null);
Expand Down Expand Up @@ -570,6 +584,10 @@ export function FocusModeClient({
haptic(decision === "approved" ? "success" : "selection");
if (data.queued) {
setQueued((prev) => new Set(prev).add(id));
} else if (data.send_blocked_reason && HELD_REASON_LABEL[data.send_blocked_reason]) {
// C-SEND-1: approved but the send-safety gate held the send - remember why
// so the card shows "prepared, not sent" instead of implying delivery.
setHeldReasons((prev) => new Map(prev).set(id, HELD_REASON_LABEL[data.send_blocked_reason]));
}
void mutate("/api/v1/focus/status");
} catch {
Expand All @@ -580,7 +598,7 @@ export function FocusModeClient({
}
// useState setters are referentially stable; listing them satisfies the React
// Compiler's manual-memoization check without changing runtime behavior.
}, [setSubmitting, setErrorMessage, setFailed, setDecisions, setQueued]);
}, [setSubmitting, setErrorMessage, setFailed, setDecisions, setQueued, setHeldReasons]);

useEffect(() => {
if (autonomyLoading) {
Expand Down Expand Up @@ -632,19 +650,28 @@ export function FocusModeClient({
});
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
return { id: action.id, queued: data.queued };
return {
id: action.id,
queued: data.queued as boolean | undefined,
blockedReason: data.send_blocked_reason as string | undefined,
};
}),
);

// Only mark successful ones as approved
const successIds: Record<string, ActionDecision> = {};
const newQueued = new Set<string>();
const newHeld = new Map<string, string>();
results.forEach((result, i) => {
const actionId = toApprove[i].id;
setSubmitting((prev) => { const next = new Set(prev); next.delete(actionId); return next; });
if (result.status === "fulfilled") {
successIds[actionId] = "approved";
if (result.value.queued) newQueued.add(actionId);
if (result.value.queued) {
newQueued.add(actionId);
} else if (result.value.blockedReason && HELD_REASON_LABEL[result.value.blockedReason]) {
newHeld.set(actionId, HELD_REASON_LABEL[result.value.blockedReason]);
}
} else {
failCount++;
setFailed((prev) => new Set(prev).add(actionId));
Expand All @@ -653,6 +680,7 @@ export function FocusModeClient({

setDecisions((prev) => ({ ...prev, ...successIds }));
setQueued((prev) => { const next = new Set(prev); newQueued.forEach((id) => next.add(id)); return next; });
setHeldReasons((prev) => { const next = new Map(prev); newHeld.forEach((v, k) => next.set(k, v)); return next; });
void mutate("/api/v1/focus/status");

if (failCount > 0) {
Expand Down Expand Up @@ -1388,6 +1416,14 @@ export function FocusModeClient({
<Send className="h-2.5 w-2.5" /> Queued
</span>
)}
{decided === "approved" && !queued.has(action.id) && heldReasons.has(action.id) && (
<span
className="inline-flex items-center gap-1 rounded-full bg-stone-800/60 border border-stone-600/40 px-2 py-0.5 text-[10px] font-medium text-amber-300"
title={heldReasons.get(action.id)}
>
<Clock className="h-2.5 w-2.5" /> Prepared, not sent
</span>
)}
<span
className={`text-xs font-medium ${
decided === "approved"
Expand All @@ -1405,6 +1441,11 @@ export function FocusModeClient({
</span>
</div>
</div>
{decided === "approved" && !queued.has(action.id) && heldReasons.has(action.id) && (
<p className="mt-1.5 text-[11px] leading-snug text-stone-400">
{heldReasons.get(action.id)}
</p>
)}
</div>
);
}
Expand Down
Loading