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 @@ + + + Researcher Identity Merge Guard + Blocks reputation and credit transfer until identity, consent, privacy, and audit evidence align. + + + + Approve merge: 1 + Review merge: 1 + Hold merge: 2 + Synthetic packets only + No credentials, external APIs, desktop capture, or private researcher data. + 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 ` + + Researcher Identity Merge Guard + Blocks reputation and credit transfer until identity, consent, privacy, and audit evidence align. + + + + Approve merge: ${summary.decisions.APPROVE_MERGE} + Review merge: ${summary.decisions.REVIEW_MERGE} + Hold merge: ${summary.decisions.HOLD_MERGE} + Synthetic packets only + No credentials, external APIs, desktop capture, or private researcher data. + +`; +} 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 + }); +});