/** * Swarm policy — pure business logic (no framework imports). * Phase 2 Module 1: Complaint Swarm. */ export type SwarmOptInCheck = | { allowed: true } | { allowed: false; reason: string; code: "mission_not_eligible" | "consent_required" | "already_member" | "swarm_not_active" }; /** * Can this mission opt in to the swarm? * Requirements: * - mission state must be facts_confirmed or later (has structured data) * - consent grant must be provided (DPDP purpose-limitation) * - mission must not already be a member of this swarm * - swarm must be active */ export function canOptIn(mission: { state: string }, existingMember: boolean, swarmStatus: string): SwarmOptInCheck { const ELIGIBLE_STATES = [ "facts_confirmed", "legal_grounding_in_progress", "route_plan_ready", "actions_preview_ready", "actions_awaiting_consent", "action_executing", "waiting_for_response", "followup_due", "escalation_ready", "resolver_reply_received", "resolution_claimed", "resolved", ]; if (!ELIGIBLE_STATES.includes(mission.state)) { return { allowed: false, reason: `Mission state "${mission.state}" is not eligible for swarm opt-in. Must be facts_confirmed or later.`, code: "mission_not_eligible", }; } if (existingMember) { return { allowed: false, reason: "Mission is already a member of this swarm.", code: "already_member", }; } if (swarmStatus !== "active") { return { allowed: false, reason: `Swarm status is "${swarmStatus}" — only active swarms accept new members.`, code: "swarm_not_active", }; } return { allowed: true }; } /** * Compute the anonymized contribution a mission makes to a swarm dossier. * Strips PII per the excludePii flag. */ export function computeContribution( facts: Record | null, shareFields: string[], excludePii: boolean, ): Record { if (!facts) return {}; const contribution: Record = {}; for (const field of shareFields) { switch (field) { case "amount": if (typeof facts["amount"] === "number") { contribution["amount"] = facts["amount"]; } break; case "date": if (typeof facts["date"] === "string") { contribution["date"] = excludePii ? redactDate(facts["date"]) : facts["date"]; } break; case "evidence_type": if (typeof facts["evidenceType"] === "string") { contribution["evidenceType"] = facts["evidenceType"]; } break; case "route": if (typeof facts["route"] === "string") { contribution["route"] = facts["route"]; } break; case "summary": if (typeof facts["description"] === "string") { contribution["summary"] = excludePii ? redactText(facts["description"]) : facts["description"]; } break; } } return contribution; } /** Anonymize a date to month precision (strip day). */ function redactDate(dateStr: string): string { const d = new Date(dateStr); return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; } /** Strip obvious PII patterns from text. */ function redactText(text: string): string { return text .replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "[NAME]") // names .replace(/\b\d{4}[- ]?\d{4}[- ]?\d{4}\b/g, "[ID]") // Aadhaar-like .replace(/\b[A-Z]{5}\d{4}[A-Z]\b/g, "[ID]") // PAN .replace(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, "[EMAIL]") // email .replace(/\b\+?91[- ]?\d{10}\b/g, "[PHONE]") // Indian phone .slice(0, 500); // cap length }