courtmitra / packages /domain /src /policies /swarm-policy.ts
Hetansh Waghela
module 1,2,3
8c87335
Raw
History Blame
3.68 kB
/**
* 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
}