diff --git a/__tests__/csend-proof-invariant.test.ts b/__tests__/csend-proof-invariant.test.ts new file mode 100644 index 00000000..9dee8ab6 --- /dev/null +++ b/__tests__/csend-proof-invariant.test.ts @@ -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> = { + 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>; + 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)[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); + }); +}); diff --git a/__tests__/focus-decide-route.test.ts b/__tests__/focus-decide-route.test.ts index 4dfb6153..86cc71ff 100644 --- a/__tests__/focus-decide-route.test.ts +++ b/__tests__/focus-decide-route.test.ts @@ -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> = { @@ -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(() => { @@ -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 () => { @@ -136,14 +150,18 @@ 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]; @@ -151,16 +169,53 @@ describe("POST /api/v1/focus/decide", () => { 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); diff --git a/app/(shell)/focus/FocusModeClient.tsx b/app/(shell)/focus/FocusModeClient.tsx index d271d45b..69670efc 100644 --- a/app/(shell)/focus/FocusModeClient.tsx +++ b/app/(shell)/focus/FocusModeClient.tsx @@ -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 = { + 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) { @@ -291,6 +303,8 @@ export function FocusModeClient({ const [submitting, setSubmitting] = useState>(new Set()); const [failed, setFailed] = useState>(new Set()); const [queued, setQueued] = useState>(new Set()); + // C-SEND-1: id -> plain reason an approved send was HELD by the send-safety gate. + const [heldReasons, setHeldReasons] = useState>(new Map()); const [errorMessage, setErrorMessage] = useState(null); const [allApproved, setAllApproved] = useState(false); const [openConfidenceFor, setOpenConfidenceFor] = useState(null); @@ -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 { @@ -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) { @@ -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 = {}; const newQueued = new Set(); + const newHeld = new Map(); 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)); @@ -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) { @@ -1388,6 +1416,14 @@ export function FocusModeClient({ Queued )} + {decided === "approved" && !queued.has(action.id) && heldReasons.has(action.id) && ( + + Prepared, not sent + + )} + {decided === "approved" && !queued.has(action.id) && heldReasons.has(action.id) && ( +

+ {heldReasons.get(action.id)} +

+ )} ); } diff --git a/app/api/v1/focus/decide/route.ts b/app/api/v1/focus/decide/route.ts index 771802ac..3662e25f 100644 --- a/app/api/v1/focus/decide/route.ts +++ b/app/api/v1/focus/decide/route.ts @@ -5,7 +5,14 @@ import { normalizeOutcomeReceiptActionType } from "@/lib/outcome-receipts"; import logger from "@/lib/logger"; import { recordApproval, recordDismissal } from "@/lib/autonomy/engine"; import { recordOutcome } from "@/lib/outcomes/record-outcome"; -import { OUTREACH_QUEUE_HELD_WARNING, isOutreachConsumerEnabled } from "@/lib/outreach/safety"; +import { + OUTREACH_QUEUE_HELD_WARNING, + isOutreachConsumerEnabled, + resolveSendDecision, + type SendBlockedReason, +} from "@/lib/outreach/safety"; +import { isPilotLockEnabled } from "@/lib/pilot-lock"; +import { isDataProvenance, isSideEffectMode } from "@/lib/pulse/provenance-contract"; import { estimateTrackedRevenue, toAutonomyCategory, @@ -99,15 +106,39 @@ export const POST = withErrorHandler(async function POST(request: NextRequest) { const warnings = new Set(); let queued = false; let queueWarning: string | undefined; + let sendBlockedReason: SendBlockedReason | null = null; if (action === "approve") { if (decisionRow.suggested_action) { - if (!isOutreachConsumerEnabled()) { + // Fail-close send-safety gate (C-SEND-1). recovery_items carries no + // provenance column today, so data_provenance / side_effect_mode are read + // defensively off the row (absent -> not live -> held). This mirrors + // resolveProvenance()'s live-rule (lib/pulse/provenance-contract): a real + // external send requires the item to be explicitly live end-to-end. The + // send is queued ONLY when live AND pilot-unlocked AND the consumer is on - + // never by a single env flip. proof_events is never written here (Pulse + // owns it on real provider success / PILOT_LOCK blocked-emit). + const rawRow = updated as Record; + const dp = rawRow.data_provenance; + const sem = rawRow.side_effect_mode; + const sends = + isDataProvenance(dp) && dp === "live" && isSideEffectMode(sem) && sem === "live"; + const decision = resolveSendDecision({ + sends, + pilotLocked: isPilotLockEnabled(), + consumerEnabled: isOutreachConsumerEnabled(), + }); + + if (!decision.allowed) { + sendBlockedReason = decision.reason; queueWarning = OUTREACH_QUEUE_HELD_WARNING; warnings.add(queueWarning); logger.info( - { tenantId: profile.tenant_id, recoveryItemId: id }, - "Approved recovery action held out of outreach queue while consumer is paused", + { tenantId: profile.tenant_id, recoveryItemId: id, sendBlockedReason }, + // Keep the "outreach queue while consumer is paused" phrase: a source + // guard (outreach-consumer-safety.test.ts) asserts every send-capable + // route references the consumer-pause safety. + "Approved recovery action recorded; held out of outreach queue while consumer is paused or the send-safety gate holds it", ); } else { const { error: queueError } = await supabase @@ -224,6 +255,8 @@ export const POST = withErrorHandler(async function POST(request: NextRequest) { success: true, status: decisionRow.status, queued, + would_send: queued, + send_blocked_reason: sendBlockedReason, queueWarning, warnings: warnings.size > 0 ? Array.from(warnings) : undefined, }); diff --git a/lib/outreach/safety.test.ts b/lib/outreach/safety.test.ts new file mode 100644 index 00000000..e5e7372d --- /dev/null +++ b/lib/outreach/safety.test.ts @@ -0,0 +1,55 @@ +/** + * C-SEND-1 - the fail-close send-safety contract (resolveSendDecision). + * Locks the full truth table so the gate can never silently degrade into + * "one env flip queues a real send": queueing is allowed ONLY when the item is + * genuinely live AND pilot-unlocked AND the consumer is enabled, with the + * blocked-reason precedence pilot_lock -> not_live_provenance -> consumer_paused. + */ +import { describe, expect, it } from "vitest"; +import { resolveSendDecision, type SendDecision } from "@/lib/outreach/safety"; + +type Case = { + sends: boolean; + pilotLocked: boolean; + consumerEnabled: boolean; + expected: SendDecision; +}; + +const CASES: Case[] = [ + // The ONLY allowed combination. + { sends: true, pilotLocked: false, consumerEnabled: true, expected: { allowed: true, reason: null } }, + // Consumer paused (live, unlocked, but consumer off). + { sends: true, pilotLocked: false, consumerEnabled: false, expected: { allowed: false, reason: "consumer_paused" } }, + // Pilot lock dominates even when everything else says send. + { sends: true, pilotLocked: true, consumerEnabled: true, expected: { allowed: false, reason: "pilot_lock" } }, + { sends: true, pilotLocked: true, consumerEnabled: false, expected: { allowed: false, reason: "pilot_lock" } }, + // Not live -> held even with consumer on. + { sends: false, pilotLocked: false, consumerEnabled: true, expected: { allowed: false, reason: "not_live_provenance" } }, + // Not live AND consumer off -> provenance reason wins (precedence). + { sends: false, pilotLocked: false, consumerEnabled: false, expected: { allowed: false, reason: "not_live_provenance" } }, + // Pilot lock still dominates over provenance + consumer. + { sends: false, pilotLocked: true, consumerEnabled: true, expected: { allowed: false, reason: "pilot_lock" } }, + { sends: false, pilotLocked: true, consumerEnabled: false, expected: { allowed: false, reason: "pilot_lock" } }, +]; + +describe("resolveSendDecision - fail-close send-safety contract", () => { + it.each(CASES)( + "sends=$sends pilotLocked=$pilotLocked consumerEnabled=$consumerEnabled -> $expected.reason", + ({ sends, pilotLocked, consumerEnabled, expected }) => { + expect(resolveSendDecision({ sends, pilotLocked, consumerEnabled })).toEqual(expected); + }, + ); + + it("allows a send in exactly one of the eight combinations", () => { + const allowedCount = CASES.filter((c) => c.expected.allowed).length; + expect(allowedCount).toBe(1); + }); + + it("never allows a send while pilot-locked, regardless of other inputs", () => { + for (const sends of [true, false]) { + for (const consumerEnabled of [true, false]) { + expect(resolveSendDecision({ sends, pilotLocked: true, consumerEnabled }).allowed).toBe(false); + } + } + }); +}); diff --git a/lib/outreach/safety.ts b/lib/outreach/safety.ts index 89194b81..6d0c616b 100644 --- a/lib/outreach/safety.ts +++ b/lib/outreach/safety.ts @@ -6,3 +6,39 @@ export const OUTREACH_QUEUE_HELD_WARNING = export function isOutreachConsumerEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return env.OUTREACH_CONSUMER_ENABLED === OUTREACH_CONSUMER_ENABLED_VALUE; } + +export type SendBlockedReason = "pilot_lock" | "not_live_provenance" | "consumer_paused"; + +export interface SendDecisionInput { + /** Will approving this action send externally? (resolveProvenance().sends) */ + sends: boolean; + /** PILOT_LOCK engaged for this tenant (isPilotLockEnabled()). */ + pilotLocked: boolean; + /** Outreach consumer switched on (isOutreachConsumerEnabled()). */ + consumerEnabled: boolean; +} + +export interface SendDecision { + /** True only when it is safe to queue an external send. */ + allowed: boolean; + /** Why the send was held, or null when allowed. */ + reason: SendBlockedReason | null; +} + +/** + * Fail-close send-safety contract for an approved outreach action. Queueing an + * external send is allowed ONLY when the item is genuinely live AND the tenant + * is not pilot-locked AND the outreach consumer is enabled. Any missing + * condition HOLDS the send (the approval itself is still recorded upstream). + * + * Precedence pilot_lock -> not_live_provenance -> consumer_paused reports the + * most important brake first: PILOT_LOCK is the First-Light / billing brake, + * provenance is "is this even a real live item", and consumer is the ops toggle. + * This is the contract; it must never become true by a single env flip alone. + */ +export function resolveSendDecision(input: SendDecisionInput): SendDecision { + if (input.pilotLocked) return { allowed: false, reason: "pilot_lock" }; + if (!input.sends) return { allowed: false, reason: "not_live_provenance" }; + if (!input.consumerEnabled) return { allowed: false, reason: "consumer_paused" }; + return { allowed: true, reason: null }; +}