File size: 3,684 Bytes
8c87335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
/**
 * 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
}