Spaces:
Running
Running
| /** | |
| * 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<string, unknown> | null, | |
| shareFields: string[], | |
| excludePii: boolean, | |
| ): Record<string, unknown> { | |
| if (!facts) return {}; | |
| const contribution: Record<string, unknown> = {}; | |
| 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 | |
| } | |