diff --git a/PR_BODY_IDENTITY_MERGE_REPUTATION_15.md b/PR_BODY_IDENTITY_MERGE_REPUTATION_15.md
new file mode 100644
index 00000000..e89471e9
--- /dev/null
+++ b/PR_BODY_IDENTITY_MERGE_REPUTATION_15.md
@@ -0,0 +1,53 @@
+/claim #15
+
+## Summary
+
+Adds `researcher-identity-merge-guard`, a focused Community & User Reputation System slice for safe researcher profile consolidation before reputation, CRediT-style contribution credit, badges, leaderboards, or private review history can transfer between merged profiles.
+
+The guard evaluates synthetic merge packets for:
+
+- independent verified identity proofs across ORCID, GitHub, and institutional email evidence
+- ORCID and GitHub alias conflicts
+- stale institutional email evidence
+- projected reputation or credit spikes after merge
+- open source-profile objection windows
+- anonymous, double-blind, or private review history exposure
+- missing institutional steward approval
+- duplicate publication or contribution-credit collisions
+- missing durable audit receipts
+
+## Demo Video
+
+Required short demo video is included at:
+
+`researcher-identity-merge-guard/reports/demo.mp4`
+
+It is a generated synthetic slate only. It does not capture a desktop, browser, credentials, personal files, API keys, or private researcher data.
+
+## Reviewer Artifacts
+
+- `researcher-identity-merge-guard/reports/summary.json`
+- `researcher-identity-merge-guard/reports/reviewer-packet.md`
+- `researcher-identity-merge-guard/reports/summary.svg`
+- `researcher-identity-merge-guard/reports/demo.mp4`
+
+## Validation
+
+```text
+npm run check
+npm test
+npm run demo
+npm run video
+git diff --check
+ffprobe reports/demo.mp4
+```
+
+Local results:
+
+- syntax check passed
+- tests passed: 5/5
+- demo generated 4 packet decisions: approve 1, review 1, hold 2
+- MP4 rendered successfully: 1280x720, yuv420p, 8.000000 seconds
+- `git diff --check` passed
+
+AI-assisted implementation, reviewed and locally verified before submission.
diff --git a/researcher-identity-merge-guard/README.md b/researcher-identity-merge-guard/README.md
new file mode 100644
index 00000000..29a8cfc7
--- /dev/null
+++ b/researcher-identity-merge-guard/README.md
@@ -0,0 +1,41 @@
+# Researcher Identity Merge Guard
+
+This package is a focused SCIBASE community reputation slice for issue #15. It evaluates researcher identity consolidation packets before merged profiles can transfer reputation, CRediT-style contribution credit, badges, leaderboards, or private review history.
+
+The guard is intentionally self-contained. It uses synthetic data only, has no external API calls, needs no credentials, and does not touch live profile systems.
+
+## Why this fits Community and User Reputation
+
+Community reputation systems often need to merge duplicated or renamed researcher identities: ORCID records, GitHub handles, institutional emails, legacy profile IDs, and project contribution ledgers. A naive merge can hijack credit, leak double-blind reviews, duplicate publication credit, or inflate reputation scores.
+
+This guard produces deterministic reviewer decisions:
+
+- `APPROVE_MERGE` when independent identity proofs, consent, redaction, steward approval, and audit receipts are all present.
+- `REVIEW_MERGE` when evidence is plausible but stale or the projected credit transfer is unusually large.
+- `HOLD_MERGE` when conflicting identity claims, open objections, privacy leaks, missing approvals, duplicate publication collisions, or missing audit receipts are present.
+
+## Files
+
+- `src/index.js` contains the decision engine.
+- `data/sample-merge-packets.json` contains four synthetic profile merge packets.
+- `test/identity-merge-guard.test.js` covers approve, review, hold, aggregate, and helper behavior.
+- `scripts/demo.js` writes reviewer artifacts to `reports/`.
+- `scripts/render-demo-video.js` renders the required short MP4 demo using a generated slate only.
+
+## Local Validation
+
+```powershell
+npm run check
+npm test
+npm run demo
+npm run video
+```
+
+Generated review artifacts:
+
+- `reports/summary.json`
+- `reports/reviewer-packet.md`
+- `reports/summary.svg`
+- `reports/demo.mp4`
+
+The MP4 is generated from synthetic text and color frames only. It does not capture the desktop, browser windows, keys, personal files, or private data.
diff --git a/researcher-identity-merge-guard/data/sample-merge-packets.json b/researcher-identity-merge-guard/data/sample-merge-packets.json
new file mode 100644
index 00000000..aa652357
--- /dev/null
+++ b/researcher-identity-merge-guard/data/sample-merge-packets.json
@@ -0,0 +1,198 @@
+[
+ {
+ "packetId": "identity-approve-001",
+ "policy": {
+ "minimumVerifiedProofTypes": 2,
+ "maxInstitutionalEmailAgeDays": 180
+ },
+ "primaryProfile": {
+ "profileId": "sci-user-1029",
+ "orcid": "0000-0002-1111-2222",
+ "github": "dr-rivera"
+ },
+ "sourceProfiles": [
+ {
+ "profileId": "legacy-user-44",
+ "orcid": "0000-0002-1111-2222",
+ "github": "dr-rivera"
+ }
+ ],
+ "identityProofs": [
+ {
+ "type": "orcid",
+ "status": "verified"
+ },
+ {
+ "type": "github",
+ "status": "verified"
+ },
+ {
+ "type": "institutional_email",
+ "status": "verified",
+ "freshnessDays": 34
+ }
+ ],
+ "objectionWindowStatus": "closed",
+ "requiresStewardApproval": true,
+ "stewardApprovalStatus": "approved",
+ "auditReceiptId": "merge-receipt-2026-08-16-001",
+ "reputationPreview": {
+ "before": 184,
+ "after": 201,
+ "maxAllowedDelta": 25
+ },
+ "privateReviewLinks": [
+ {
+ "reviewId": "dbl-948",
+ "mergeVisibility": "redact"
+ }
+ ],
+ "publicationCollisions": []
+ },
+ {
+ "packetId": "identity-hold-002",
+ "policy": {
+ "minimumVerifiedProofTypes": 2,
+ "maxInstitutionalEmailAgeDays": 180
+ },
+ "primaryProfile": {
+ "profileId": "sci-user-8821",
+ "orcid": "0000-0003-2222-3333",
+ "github": "lab-anika"
+ },
+ "sourceProfiles": [
+ {
+ "profileId": "legacy-user-17",
+ "orcid": "0000-0003-9999-3333",
+ "github": "anika-results"
+ }
+ ],
+ "identityProofs": [
+ {
+ "type": "github",
+ "status": "verified"
+ },
+ {
+ "type": "institutional_email",
+ "status": "stale",
+ "freshnessDays": 412
+ }
+ ],
+ "objectionWindowStatus": "open",
+ "requiresStewardApproval": true,
+ "stewardApprovalStatus": "missing",
+ "auditReceiptId": "",
+ "reputationPreview": {
+ "before": 91,
+ "after": 178,
+ "maxAllowedDelta": 30
+ },
+ "privateReviewLinks": [
+ {
+ "reviewId": "dbl-122",
+ "mergeVisibility": "expose"
+ }
+ ],
+ "publicationCollisions": [
+ {
+ "artifactId": "doi:10.5555/conflict.2026",
+ "status": "unresolved"
+ }
+ ]
+ },
+ {
+ "packetId": "identity-review-003",
+ "policy": {
+ "minimumVerifiedProofTypes": 2,
+ "maxInstitutionalEmailAgeDays": 180
+ },
+ "primaryProfile": {
+ "profileId": "sci-user-3104",
+ "orcid": "0000-0001-4444-5555",
+ "github": "m-chen"
+ },
+ "sourceProfiles": [
+ {
+ "profileId": "legacy-user-90",
+ "orcid": "0000-0001-4444-5555",
+ "github": "m-chen"
+ }
+ ],
+ "identityProofs": [
+ {
+ "type": "orcid",
+ "status": "verified"
+ },
+ {
+ "type": "github",
+ "status": "verified"
+ },
+ {
+ "type": "institutional_email",
+ "status": "verified",
+ "freshnessDays": 219
+ }
+ ],
+ "objectionWindowStatus": "closed",
+ "requiresStewardApproval": false,
+ "stewardApprovalStatus": "not_required",
+ "auditReceiptId": "merge-receipt-2026-08-16-003",
+ "reputationPreview": {
+ "before": 132,
+ "after": 190,
+ "maxAllowedDelta": 45
+ },
+ "privateReviewLinks": [],
+ "publicationCollisions": []
+ },
+ {
+ "packetId": "identity-hold-004",
+ "policy": {
+ "minimumVerifiedProofTypes": 2,
+ "maxInstitutionalEmailAgeDays": 180
+ },
+ "primaryProfile": {
+ "profileId": "sci-user-6407",
+ "orcid": "0000-0002-7777-8888",
+ "github": "kpatel-open"
+ },
+ "sourceProfiles": [
+ {
+ "profileId": "legacy-user-128",
+ "orcid": "0000-0002-7777-8888",
+ "github": "kpatel-open"
+ }
+ ],
+ "identityProofs": [
+ {
+ "type": "orcid",
+ "status": "verified"
+ },
+ {
+ "type": "github",
+ "status": "verified"
+ },
+ {
+ "type": "institutional_email",
+ "status": "verified",
+ "freshnessDays": 88
+ }
+ ],
+ "objectionWindowStatus": "missing",
+ "requiresStewardApproval": true,
+ "stewardApprovalStatus": "pending",
+ "auditReceiptId": "merge-receipt-2026-08-16-004",
+ "reputationPreview": {
+ "before": 220,
+ "after": 231,
+ "maxAllowedDelta": 35
+ },
+ "privateReviewLinks": [],
+ "publicationCollisions": [
+ {
+ "artifactId": "doi:10.5555/shared-credit.2026",
+ "status": "unresolved"
+ }
+ ]
+ }
+]
diff --git a/researcher-identity-merge-guard/package.json b/researcher-identity-merge-guard/package.json
new file mode 100644
index 00000000..3187931d
--- /dev/null
+++ b/researcher-identity-merge-guard/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "researcher-identity-merge-guard",
+ "version": "1.0.0",
+ "description": "Synthetic researcher identity merge guard for SCIBASE community reputation workflows.",
+ "main": "src/index.js",
+ "scripts": {
+ "check": "node --check src/index.js && node --check test/identity-merge-guard.test.js && node --check scripts/demo.js && node --check scripts/render-demo-video.js",
+ "test": "node --test",
+ "demo": "node scripts/demo.js",
+ "video": "node scripts/render-demo-video.js"
+ },
+ "keywords": [
+ "scibase",
+ "reputation",
+ "identity",
+ "orcid",
+ "audit"
+ ],
+ "license": "MIT"
+}
diff --git a/researcher-identity-merge-guard/reports/demo.mp4 b/researcher-identity-merge-guard/reports/demo.mp4
new file mode 100644
index 00000000..bfcc1496
Binary files /dev/null and b/researcher-identity-merge-guard/reports/demo.mp4 differ
diff --git a/researcher-identity-merge-guard/reports/reviewer-packet.md b/researcher-identity-merge-guard/reports/reviewer-packet.md
new file mode 100644
index 00000000..d42d7822
--- /dev/null
+++ b/researcher-identity-merge-guard/reports/reviewer-packet.md
@@ -0,0 +1,68 @@
+# Researcher Identity Merge Guard Reviewer Packet
+
+Synthetic SCIBASE community reputation packets were evaluated before identity consolidation could transfer credit, reputation, badges, or private review history.
+
+## Decision Summary
+
+- Total packets: 4
+- Approve: 1
+- Review: 1
+- Hold: 2
+
+## Packet Findings
+
+### identity-approve-001: APPROVE_MERGE
+
+- Verified proof types: orcid, github, institutional_email
+- Projected credit delta: 17
+- Findings: none
+- Reviewer actions:
+ - Approve merge and preserve the audit receipt on both source profiles.
+
+### identity-hold-002: HOLD_MERGE
+
+- Verified proof types: github
+- Projected credit delta: 87
+- Findings:
+ - IDENTITY_EVIDENCE_MISSING (high): Fewer than two independent verified identity proofs are present.
+ - ORCID_CONFLICT (critical): Profile records claim conflicting ORCID identifiers.
+ - GITHUB_HANDLE_CONFLICT (high): GitHub handles conflict without a verified alias or account migration proof.
+ - INSTITUTIONAL_EMAIL_UNVERIFIED (medium): Institutional email evidence is missing, unverified, or stale.
+ - CREDIT_SPIKE_AFTER_MERGE (medium): Projected reputation or credit delta exceeds the configured merge allowance.
+ - SOURCE_PROFILE_OBJECTION_OPEN (high): Source profile owner or collaborator objection window is not closed.
+ - PRIVATE_OR_BLIND_REVIEW_LEAK (critical): The merge would expose anonymous, double-blind, or private review history.
+ - AFFILIATION_APPROVAL_MISSING (high): A governed affiliation merge is missing steward approval.
+ - DUPLICATE_PUBLICATION_COLLISION (high): The same artifact is claimed by both profiles with conflicting roles or credit.
+ - AUDIT_RECEIPT_MISSING (medium): The merge packet lacks a durable audit receipt.
+- Reviewer actions:
+ - Require verified identity proof before reputation or credit transfer.
+ - Request a signed GitHub alias or account migration receipt.
+ - Route the packet to the institutional steward for approval.
+ - Freeze public credit deltas until artifact ownership is reconciled.
+ - Wait for the objection period to close and attach responses.
+ - Redact private review history before any profile consolidation.
+ - Generate an immutable merge receipt before approval.
+
+### identity-review-003: REVIEW_MERGE
+
+- Verified proof types: orcid, github, institutional_email
+- Projected credit delta: 58
+- Findings:
+ - INSTITUTIONAL_EMAIL_UNVERIFIED (medium): Institutional email evidence is missing, unverified, or stale.
+ - CREDIT_SPIKE_AFTER_MERGE (medium): Projected reputation or credit delta exceeds the configured merge allowance.
+- Reviewer actions:
+ - Route the packet to the institutional steward for approval.
+ - Freeze public credit deltas until artifact ownership is reconciled.
+
+### identity-hold-004: HOLD_MERGE
+
+- Verified proof types: orcid, github, institutional_email
+- Projected credit delta: 11
+- Findings:
+ - SOURCE_PROFILE_OBJECTION_OPEN (high): Source profile owner or collaborator objection window is not closed.
+ - AFFILIATION_APPROVAL_MISSING (high): A governed affiliation merge is missing steward approval.
+ - DUPLICATE_PUBLICATION_COLLISION (high): The same artifact is claimed by both profiles with conflicting roles or credit.
+- Reviewer actions:
+ - Wait for the objection period to close and attach responses.
+ - Route the packet to the institutional steward for approval.
+ - Freeze public credit deltas until artifact ownership is reconciled.
diff --git a/researcher-identity-merge-guard/reports/summary.json b/researcher-identity-merge-guard/reports/summary.json
new file mode 100644
index 00000000..e8da1c54
--- /dev/null
+++ b/researcher-identity-merge-guard/reports/summary.json
@@ -0,0 +1,227 @@
+{
+ "summary": {
+ "totalPackets": 4,
+ "decisions": {
+ "APPROVE_MERGE": 1,
+ "REVIEW_MERGE": 1,
+ "HOLD_MERGE": 2
+ },
+ "findingCounts": {
+ "IDENTITY_EVIDENCE_MISSING": 1,
+ "ORCID_CONFLICT": 1,
+ "GITHUB_HANDLE_CONFLICT": 1,
+ "INSTITUTIONAL_EMAIL_UNVERIFIED": 2,
+ "CREDIT_SPIKE_AFTER_MERGE": 2,
+ "SOURCE_PROFILE_OBJECTION_OPEN": 2,
+ "PRIVATE_OR_BLIND_REVIEW_LEAK": 1,
+ "AFFILIATION_APPROVAL_MISSING": 2,
+ "DUPLICATE_PUBLICATION_COLLISION": 2,
+ "AUDIT_RECEIPT_MISSING": 1
+ }
+ },
+ "results": [
+ {
+ "packetId": "identity-approve-001",
+ "decision": "APPROVE_MERGE",
+ "verifiedProofTypes": [
+ "orcid",
+ "github",
+ "institutional_email"
+ ],
+ "projectedCreditDelta": 17,
+ "findings": [],
+ "reviewerActions": [
+ "Approve merge and preserve the audit receipt on both source profiles."
+ ]
+ },
+ {
+ "packetId": "identity-hold-002",
+ "decision": "HOLD_MERGE",
+ "verifiedProofTypes": [
+ "github"
+ ],
+ "projectedCreditDelta": 87,
+ "findings": [
+ {
+ "code": "IDENTITY_EVIDENCE_MISSING",
+ "severity": "high",
+ "message": "Fewer than two independent verified identity proofs are present.",
+ "evidence": {
+ "verifiedProofTypes": [
+ "github"
+ ],
+ "required": 2
+ }
+ },
+ {
+ "code": "ORCID_CONFLICT",
+ "severity": "critical",
+ "message": "Profile records claim conflicting ORCID identifiers.",
+ "evidence": {
+ "orcids": [
+ "0000-0003-2222-3333",
+ "0000-0003-9999-3333"
+ ]
+ }
+ },
+ {
+ "code": "GITHUB_HANDLE_CONFLICT",
+ "severity": "high",
+ "message": "GitHub handles conflict without a verified alias or account migration proof.",
+ "evidence": {
+ "githubHandles": [
+ "lab-anika",
+ "anika-results"
+ ]
+ }
+ },
+ {
+ "code": "INSTITUTIONAL_EMAIL_UNVERIFIED",
+ "severity": "medium",
+ "message": "Institutional email evidence is missing, unverified, or stale.",
+ "evidence": {
+ "maxAgeDays": 180
+ }
+ },
+ {
+ "code": "CREDIT_SPIKE_AFTER_MERGE",
+ "severity": "medium",
+ "message": "Projected reputation or credit delta exceeds the configured merge allowance.",
+ "evidence": {
+ "projectedDelta": 87,
+ "maxAllowedDelta": 30
+ }
+ },
+ {
+ "code": "SOURCE_PROFILE_OBJECTION_OPEN",
+ "severity": "high",
+ "message": "Source profile owner or collaborator objection window is not closed.",
+ "evidence": {
+ "objectionWindowStatus": "open"
+ }
+ },
+ {
+ "code": "PRIVATE_OR_BLIND_REVIEW_LEAK",
+ "severity": "critical",
+ "message": "The merge would expose anonymous, double-blind, or private review history.",
+ "evidence": {
+ "exposedReviewIds": [
+ "dbl-122"
+ ]
+ }
+ },
+ {
+ "code": "AFFILIATION_APPROVAL_MISSING",
+ "severity": "high",
+ "message": "A governed affiliation merge is missing steward approval.",
+ "evidence": {
+ "stewardApprovalStatus": "missing"
+ }
+ },
+ {
+ "code": "DUPLICATE_PUBLICATION_COLLISION",
+ "severity": "high",
+ "message": "The same artifact is claimed by both profiles with conflicting roles or credit.",
+ "evidence": {
+ "collisions": [
+ "doi:10.5555/conflict.2026"
+ ]
+ }
+ },
+ {
+ "code": "AUDIT_RECEIPT_MISSING",
+ "severity": "medium",
+ "message": "The merge packet lacks a durable audit receipt.",
+ "evidence": {
+ "packetId": "identity-hold-002"
+ }
+ }
+ ],
+ "reviewerActions": [
+ "Require verified identity proof before reputation or credit transfer.",
+ "Request a signed GitHub alias or account migration receipt.",
+ "Route the packet to the institutional steward for approval.",
+ "Freeze public credit deltas until artifact ownership is reconciled.",
+ "Wait for the objection period to close and attach responses.",
+ "Redact private review history before any profile consolidation.",
+ "Generate an immutable merge receipt before approval."
+ ]
+ },
+ {
+ "packetId": "identity-review-003",
+ "decision": "REVIEW_MERGE",
+ "verifiedProofTypes": [
+ "orcid",
+ "github",
+ "institutional_email"
+ ],
+ "projectedCreditDelta": 58,
+ "findings": [
+ {
+ "code": "INSTITUTIONAL_EMAIL_UNVERIFIED",
+ "severity": "medium",
+ "message": "Institutional email evidence is missing, unverified, or stale.",
+ "evidence": {
+ "maxAgeDays": 180
+ }
+ },
+ {
+ "code": "CREDIT_SPIKE_AFTER_MERGE",
+ "severity": "medium",
+ "message": "Projected reputation or credit delta exceeds the configured merge allowance.",
+ "evidence": {
+ "projectedDelta": 58,
+ "maxAllowedDelta": 45
+ }
+ }
+ ],
+ "reviewerActions": [
+ "Route the packet to the institutional steward for approval.",
+ "Freeze public credit deltas until artifact ownership is reconciled."
+ ]
+ },
+ {
+ "packetId": "identity-hold-004",
+ "decision": "HOLD_MERGE",
+ "verifiedProofTypes": [
+ "orcid",
+ "github",
+ "institutional_email"
+ ],
+ "projectedCreditDelta": 11,
+ "findings": [
+ {
+ "code": "SOURCE_PROFILE_OBJECTION_OPEN",
+ "severity": "high",
+ "message": "Source profile owner or collaborator objection window is not closed.",
+ "evidence": {
+ "objectionWindowStatus": "missing"
+ }
+ },
+ {
+ "code": "AFFILIATION_APPROVAL_MISSING",
+ "severity": "high",
+ "message": "A governed affiliation merge is missing steward approval.",
+ "evidence": {
+ "stewardApprovalStatus": "pending"
+ }
+ },
+ {
+ "code": "DUPLICATE_PUBLICATION_COLLISION",
+ "severity": "high",
+ "message": "The same artifact is claimed by both profiles with conflicting roles or credit.",
+ "evidence": {
+ "collisions": [
+ "doi:10.5555/shared-credit.2026"
+ ]
+ }
+ }
+ ],
+ "reviewerActions": [
+ "Wait for the objection period to close and attach responses.",
+ "Route the packet to the institutional steward for approval.",
+ "Freeze public credit deltas until artifact ownership is reconciled."
+ ]
+ }
+ ]
+}
diff --git a/researcher-identity-merge-guard/reports/summary.svg b/researcher-identity-merge-guard/reports/summary.svg
new file mode 100644
index 00000000..d3c65a4e
--- /dev/null
+++ b/researcher-identity-merge-guard/reports/summary.svg
@@ -0,0 +1,13 @@
+
diff --git a/researcher-identity-merge-guard/scripts/demo.js b/researcher-identity-merge-guard/scripts/demo.js
new file mode 100644
index 00000000..74d11203
--- /dev/null
+++ b/researcher-identity-merge-guard/scripts/demo.js
@@ -0,0 +1,88 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const { analyzeIdentityMergePackets } = require("../src/index.js");
+
+const root = path.resolve(__dirname, "..");
+const packets = JSON.parse(
+ fs.readFileSync(path.join(root, "data", "sample-merge-packets.json"), "utf8")
+);
+const analysis = analyzeIdentityMergePackets(packets);
+const outDir = path.join(root, "reports");
+
+fs.mkdirSync(outDir, { recursive: true });
+fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(analysis, null, 2)}\n`);
+fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), renderMarkdown(analysis));
+fs.writeFileSync(path.join(outDir, "summary.svg"), renderSvg(analysis));
+
+console.log(JSON.stringify({
+ packets: analysis.summary.totalPackets,
+ decisions: analysis.summary.decisions,
+ outDir
+}, null, 2));
+
+function renderMarkdown({ summary, results }) {
+ const lines = [
+ "# Researcher Identity Merge Guard Reviewer Packet",
+ "",
+ "Synthetic SCIBASE community reputation packets were evaluated before identity consolidation could transfer credit, reputation, badges, or private review history.",
+ "",
+ "## Decision Summary",
+ "",
+ `- Total packets: ${summary.totalPackets}`,
+ `- Approve: ${summary.decisions.APPROVE_MERGE}`,
+ `- Review: ${summary.decisions.REVIEW_MERGE}`,
+ `- Hold: ${summary.decisions.HOLD_MERGE}`,
+ "",
+ "## Packet Findings",
+ ""
+ ];
+
+ for (const result of results) {
+ lines.push(`### ${result.packetId}: ${result.decision}`);
+ lines.push("");
+ lines.push(`- Verified proof types: ${result.verifiedProofTypes.join(", ") || "none"}`);
+ lines.push(`- Projected credit delta: ${result.projectedCreditDelta}`);
+ if (result.findings.length === 0) {
+ lines.push("- Findings: none");
+ } else {
+ lines.push("- Findings:");
+ for (const finding of result.findings) {
+ lines.push(` - ${finding.code} (${finding.severity}): ${finding.message}`);
+ }
+ }
+ lines.push("- Reviewer actions:");
+ for (const action of result.reviewerActions) {
+ lines.push(` - ${action}`);
+ }
+ lines.push("");
+ }
+
+ while (lines[lines.length - 1] === "") {
+ lines.pop();
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvg({ summary }) {
+ const total = Math.max(1, summary.totalPackets);
+ const approveWidth = Math.round((summary.decisions.APPROVE_MERGE / total) * 740);
+ const reviewWidth = Math.round((summary.decisions.REVIEW_MERGE / total) * 740);
+ const holdWidth = Math.round((summary.decisions.HOLD_MERGE / total) * 740);
+
+ return `
+`;
+}
diff --git a/researcher-identity-merge-guard/scripts/render-demo-video.js b/researcher-identity-merge-guard/scripts/render-demo-video.js
new file mode 100644
index 00000000..5d07b31e
--- /dev/null
+++ b/researcher-identity-merge-guard/scripts/render-demo-video.js
@@ -0,0 +1,60 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const root = path.resolve(__dirname, "..");
+const ffmpeg = path.resolve(
+ root,
+ "..",
+ "..",
+ "tool_downloads",
+ "video_tools",
+ "node_modules",
+ "ffmpeg-static",
+ "ffmpeg.exe"
+);
+const outDir = path.join(root, "reports");
+const out = path.join(outDir, "demo.mp4");
+const font = "C\\:/Windows/Fonts/arial.ttf";
+
+fs.mkdirSync(outDir, { recursive: true });
+
+const draw = [
+ `drawtext=fontfile=${font}:text='Researcher Identity Merge Guard':x=64:y=56:fontsize=42:fontcolor=black`,
+ `drawtext=fontfile=${font}:text='Community reputation profile consolidation gate':x=64:y=122:fontsize=25:fontcolor=0x374151`,
+ `drawtext=fontfile=${font}:text='Synthetic merge packets analyzed 4':x=64:y=214:fontsize=34:fontcolor=black`,
+ `drawtext=fontfile=${font}:text='HOLD 2 REVIEW 1 APPROVE 1':x=64:y=274:fontsize=40:fontcolor=0x2f855a`,
+ `drawtext=fontfile=${font}:text='Checks ORCID GitHub email consent privacy and credit spikes':x=64:y=366:fontsize=26:fontcolor=0x374151`,
+ `drawtext=fontfile=${font}:text='Freezes reputation transfer until audit evidence is safe':x=64:y=428:fontsize=26:fontcolor=0x374151`,
+ `drawtext=fontfile=${font}:text='Synthetic generated slate only no desktop capture or private data':x=64:y=492:fontsize=26:fontcolor=0x374151`
+].join(",");
+
+if (!fs.existsSync(ffmpeg)) {
+ throw new Error(`ffmpeg binary not found at ${ffmpeg}`);
+}
+
+const result = spawnSync(ffmpeg, [
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=0xfbfaf6:s=1280x720:d=8:r=30",
+ "-vf",
+ draw,
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ out
+], { encoding: "utf8" });
+
+if (result.status !== 0) {
+ process.stderr.write(result.stderr || result.stdout);
+ process.exit(result.status || 1);
+}
+
+console.log(JSON.stringify({ out, bytes: fs.statSync(out).size }, null, 2));
diff --git a/researcher-identity-merge-guard/src/index.js b/researcher-identity-merge-guard/src/index.js
new file mode 100644
index 00000000..ee35b882
--- /dev/null
+++ b/researcher-identity-merge-guard/src/index.js
@@ -0,0 +1,279 @@
+"use strict";
+
+const FINDINGS = Object.freeze({
+ IDENTITY_EVIDENCE_MISSING: {
+ severity: "high",
+ message: "Fewer than two independent verified identity proofs are present."
+ },
+ ORCID_CONFLICT: {
+ severity: "critical",
+ message: "Profile records claim conflicting ORCID identifiers."
+ },
+ GITHUB_HANDLE_CONFLICT: {
+ severity: "high",
+ message: "GitHub handles conflict without a verified alias or account migration proof."
+ },
+ INSTITUTIONAL_EMAIL_UNVERIFIED: {
+ severity: "medium",
+ message: "Institutional email evidence is missing, unverified, or stale."
+ },
+ CREDIT_SPIKE_AFTER_MERGE: {
+ severity: "medium",
+ message: "Projected reputation or credit delta exceeds the configured merge allowance."
+ },
+ SOURCE_PROFILE_OBJECTION_OPEN: {
+ severity: "high",
+ message: "Source profile owner or collaborator objection window is not closed."
+ },
+ PRIVATE_OR_BLIND_REVIEW_LEAK: {
+ severity: "critical",
+ message: "The merge would expose anonymous, double-blind, or private review history."
+ },
+ AFFILIATION_APPROVAL_MISSING: {
+ severity: "high",
+ message: "A governed affiliation merge is missing steward approval."
+ },
+ DUPLICATE_PUBLICATION_COLLISION: {
+ severity: "high",
+ message: "The same artifact is claimed by both profiles with conflicting roles or credit."
+ },
+ AUDIT_RECEIPT_MISSING: {
+ severity: "medium",
+ message: "The merge packet lacks a durable audit receipt."
+ }
+});
+
+const SEVERITY_RANK = Object.freeze({
+ low: 1,
+ medium: 2,
+ high: 3,
+ critical: 4
+});
+
+function normalize(value) {
+ return String(value || "").trim().toLowerCase();
+}
+
+function uniqueNonEmpty(values) {
+ return [...new Set(values.map(normalize).filter(Boolean))];
+}
+
+function profileValues(packet, field) {
+ const profiles = [packet.primaryProfile, ...(packet.sourceProfiles || [])].filter(Boolean);
+ return uniqueNonEmpty(profiles.map((profile) => profile[field]));
+}
+
+function verifiedProofTypes(packet) {
+ return uniqueNonEmpty(
+ (packet.identityProofs || [])
+ .filter((proof) => proof.status === "verified")
+ .map((proof) => proof.type)
+ );
+}
+
+function hasVerifiedAliasProof(packet, type) {
+ return (packet.identityProofs || []).some(
+ (proof) => proof.type === type && proof.status === "verified"
+ );
+}
+
+function institutionalEmailNeedsReview(packet) {
+ const emailProof = (packet.identityProofs || []).find(
+ (proof) => proof.type === "institutional_email"
+ );
+
+ if (!emailProof || emailProof.status !== "verified") return true;
+ if (emailProof.freshnessDays > packet.policy.maxInstitutionalEmailAgeDays) return true;
+ return false;
+}
+
+function projectedCreditSpike(packet) {
+ const preview = packet.reputationPreview || {};
+ const before = Number(preview.before || 0);
+ const after = Number(preview.after || 0);
+ const allowance = Number(preview.maxAllowedDelta || 0);
+ return {
+ delta: after - before,
+ allowance,
+ isSpike: after - before > allowance
+ };
+}
+
+function buildFinding(code, evidence) {
+ const base = FINDINGS[code];
+ return {
+ code,
+ severity: base.severity,
+ message: base.message,
+ evidence
+ };
+}
+
+function analyzeIdentityMergePacket(packet) {
+ if (!packet || typeof packet !== "object") {
+ throw new TypeError("analyzeIdentityMergePacket requires a packet object");
+ }
+ if (!packet.policy) {
+ throw new TypeError(`packet ${packet.packetId || "(unknown)"} is missing policy`);
+ }
+
+ const findings = [];
+ const proofTypes = verifiedProofTypes(packet);
+ const orcids = profileValues(packet, "orcid");
+ const githubHandles = profileValues(packet, "github");
+ const creditSpike = projectedCreditSpike(packet);
+
+ if (proofTypes.length < packet.policy.minimumVerifiedProofTypes) {
+ findings.push(
+ buildFinding("IDENTITY_EVIDENCE_MISSING", {
+ verifiedProofTypes: proofTypes,
+ required: packet.policy.minimumVerifiedProofTypes
+ })
+ );
+ }
+
+ if (orcids.length > 1) {
+ findings.push(buildFinding("ORCID_CONFLICT", { orcids }));
+ }
+
+ if (githubHandles.length > 1 && !hasVerifiedAliasProof(packet, "github_alias")) {
+ findings.push(buildFinding("GITHUB_HANDLE_CONFLICT", { githubHandles }));
+ }
+
+ if (institutionalEmailNeedsReview(packet)) {
+ findings.push(
+ buildFinding("INSTITUTIONAL_EMAIL_UNVERIFIED", {
+ maxAgeDays: packet.policy.maxInstitutionalEmailAgeDays
+ })
+ );
+ }
+
+ if (creditSpike.isSpike) {
+ findings.push(
+ buildFinding("CREDIT_SPIKE_AFTER_MERGE", {
+ projectedDelta: creditSpike.delta,
+ maxAllowedDelta: creditSpike.allowance
+ })
+ );
+ }
+
+ if (packet.objectionWindowStatus !== "closed") {
+ findings.push(
+ buildFinding("SOURCE_PROFILE_OBJECTION_OPEN", {
+ objectionWindowStatus: packet.objectionWindowStatus || "missing"
+ })
+ );
+ }
+
+ if ((packet.privateReviewLinks || []).some((link) => link.mergeVisibility === "expose")) {
+ findings.push(
+ buildFinding("PRIVATE_OR_BLIND_REVIEW_LEAK", {
+ exposedReviewIds: (packet.privateReviewLinks || [])
+ .filter((link) => link.mergeVisibility === "expose")
+ .map((link) => link.reviewId)
+ })
+ );
+ }
+
+ if (packet.requiresStewardApproval && packet.stewardApprovalStatus !== "approved") {
+ findings.push(
+ buildFinding("AFFILIATION_APPROVAL_MISSING", {
+ stewardApprovalStatus: packet.stewardApprovalStatus || "missing"
+ })
+ );
+ }
+
+ const collisions = (packet.publicationCollisions || []).filter(
+ (collision) => collision.status !== "resolved"
+ );
+ if (collisions.length > 0) {
+ findings.push(
+ buildFinding("DUPLICATE_PUBLICATION_COLLISION", {
+ collisions: collisions.map((collision) => collision.artifactId)
+ })
+ );
+ }
+
+ if (!packet.auditReceiptId) {
+ findings.push(buildFinding("AUDIT_RECEIPT_MISSING", { packetId: packet.packetId }));
+ }
+
+ const worstRank = Math.max(0, ...findings.map((finding) => SEVERITY_RANK[finding.severity]));
+ const decision =
+ worstRank >= SEVERITY_RANK.high
+ ? "HOLD_MERGE"
+ : worstRank >= SEVERITY_RANK.medium
+ ? "REVIEW_MERGE"
+ : "APPROVE_MERGE";
+
+ return {
+ packetId: packet.packetId,
+ decision,
+ verifiedProofTypes: proofTypes,
+ projectedCreditDelta: creditSpike.delta,
+ findings,
+ reviewerActions: reviewerActionsFor(findings)
+ };
+}
+
+function reviewerActionsFor(findings) {
+ if (findings.length === 0) {
+ return ["Approve merge and preserve the audit receipt on both source profiles."];
+ }
+
+ const actions = new Set();
+ for (const finding of findings) {
+ if (finding.code.includes("IDENTITY") || finding.code.includes("ORCID")) {
+ actions.add("Require verified identity proof before reputation or credit transfer.");
+ }
+ if (finding.code.includes("GITHUB")) {
+ actions.add("Request a signed GitHub alias or account migration receipt.");
+ }
+ if (finding.code.includes("EMAIL") || finding.code.includes("AFFILIATION")) {
+ actions.add("Route the packet to the institutional steward for approval.");
+ }
+ if (finding.code.includes("CREDIT") || finding.code.includes("PUBLICATION")) {
+ actions.add("Freeze public credit deltas until artifact ownership is reconciled.");
+ }
+ if (finding.code.includes("OBJECTION")) {
+ actions.add("Wait for the objection period to close and attach responses.");
+ }
+ if (finding.code.includes("PRIVATE") || finding.code.includes("BLIND")) {
+ actions.add("Redact private review history before any profile consolidation.");
+ }
+ if (finding.code.includes("AUDIT")) {
+ actions.add("Generate an immutable merge receipt before approval.");
+ }
+ }
+ return [...actions];
+}
+
+function analyzeIdentityMergePackets(packets) {
+ const results = packets.map(analyzeIdentityMergePacket);
+ const summary = {
+ totalPackets: results.length,
+ decisions: {
+ APPROVE_MERGE: 0,
+ REVIEW_MERGE: 0,
+ HOLD_MERGE: 0
+ },
+ findingCounts: {}
+ };
+
+ for (const result of results) {
+ summary.decisions[result.decision] += 1;
+ for (const finding of result.findings) {
+ summary.findingCounts[finding.code] = (summary.findingCounts[finding.code] || 0) + 1;
+ }
+ }
+
+ return { summary, results };
+}
+
+module.exports = {
+ FINDINGS,
+ analyzeIdentityMergePacket,
+ analyzeIdentityMergePackets,
+ verifiedProofTypes,
+ projectedCreditSpike
+};
diff --git a/researcher-identity-merge-guard/test/identity-merge-guard.test.js b/researcher-identity-merge-guard/test/identity-merge-guard.test.js
new file mode 100644
index 00000000..65388f1d
--- /dev/null
+++ b/researcher-identity-merge-guard/test/identity-merge-guard.test.js
@@ -0,0 +1,71 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+
+const {
+ analyzeIdentityMergePacket,
+ analyzeIdentityMergePackets,
+ projectedCreditSpike,
+ verifiedProofTypes
+} = require("../src/index.js");
+
+const packets = JSON.parse(
+ fs.readFileSync(path.join(__dirname, "..", "data", "sample-merge-packets.json"), "utf8")
+);
+
+test("approves a clean identity merge packet", () => {
+ const result = analyzeIdentityMergePacket(packets[0]);
+
+ assert.equal(result.packetId, "identity-approve-001");
+ assert.equal(result.decision, "APPROVE_MERGE");
+ assert.deepEqual(result.findings, []);
+ assert.deepEqual(result.verifiedProofTypes, ["orcid", "github", "institutional_email"]);
+});
+
+test("holds conflicted profile merges before credit transfer", () => {
+ const result = analyzeIdentityMergePacket(packets[1]);
+ const codes = result.findings.map((finding) => finding.code);
+
+ assert.equal(result.decision, "HOLD_MERGE");
+ assert.ok(codes.includes("ORCID_CONFLICT"));
+ assert.ok(codes.includes("GITHUB_HANDLE_CONFLICT"));
+ assert.ok(codes.includes("PRIVATE_OR_BLIND_REVIEW_LEAK"));
+ assert.ok(codes.includes("DUPLICATE_PUBLICATION_COLLISION"));
+ assert.ok(codes.includes("AUDIT_RECEIPT_MISSING"));
+});
+
+test("routes stale evidence and excessive credit deltas to review", () => {
+ const result = analyzeIdentityMergePacket(packets[2]);
+ const codes = result.findings.map((finding) => finding.code);
+
+ assert.equal(result.decision, "REVIEW_MERGE");
+ assert.deepEqual(codes.sort(), [
+ "CREDIT_SPIKE_AFTER_MERGE",
+ "INSTITUTIONAL_EMAIL_UNVERIFIED"
+ ]);
+});
+
+test("aggregates decisions and finding counts for reviewer queues", () => {
+ const { summary } = analyzeIdentityMergePackets(packets);
+
+ assert.equal(summary.totalPackets, 4);
+ assert.deepEqual(summary.decisions, {
+ APPROVE_MERGE: 1,
+ REVIEW_MERGE: 1,
+ HOLD_MERGE: 2
+ });
+ assert.equal(summary.findingCounts.DUPLICATE_PUBLICATION_COLLISION, 2);
+ assert.equal(summary.findingCounts.SOURCE_PROFILE_OBJECTION_OPEN, 2);
+});
+
+test("exposes small helpers for API integration tests", () => {
+ assert.deepEqual(verifiedProofTypes(packets[0]), ["orcid", "github", "institutional_email"]);
+ assert.deepEqual(projectedCreditSpike(packets[2]), {
+ delta: 58,
+ allowance: 45,
+ isSpike: true
+ });
+});