diff --git a/project-ownership-succession-guard/README.md b/project-ownership-succession-guard/README.md new file mode 100644 index 00000000..1d8e5324 --- /dev/null +++ b/project-ownership-succession-guard/README.md @@ -0,0 +1,48 @@ +# Project Ownership Succession Guard + +This contribution adds a focused guardrail for SCIBASE's User & Project Management layer. It evaluates project ownership transfer requests when a workspace owner is inactive, has left an institution, is unreachable, or when a project risks becoming orphaned. + +The slice is intentionally narrow. It does not rebuild RBAC, invitation flows, offboarding, visibility transitions, deletion, or archive handoff. It covers the succession decision itself: who can inherit control of a scientific workspace, what approvals are required, and how attribution and compliance holds stay intact. + +## What It Checks + +- A valid owner-inactivity, departure, suspension, legal, or institutional succession trigger. +- Whether the proposed owner has a succession-eligible role such as admin, co-owner, PI, data steward, or institutional steward. +- Fresh identity verification and MFA posture for the successor. +- Independent approval through an institutional steward, data steward plus co-owner quorum, or legal authority. +- Compliance approval when the project contains IRB, DUA, clinical, embargoed, human-subjects, or export-controlled holds. +- Complete collaborator notice and a meaningful objection window. +- Preservation of original owner credit, contribution history, and a public transfer receipt. +- Open ownership disputes or competing successor claims. +- Immutable audit receipts for the transfer packet. + +## Running Locally + +```bash +npm test +npm run demo +npm run check +npm run video +``` + +The demo uses only synthetic packets in `data/sample_succession_packets.json`. + +Generated files: + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +The MP4 is generated from an FFmpeg color source and drawtext slate. It is not a screen recording and contains no desktop capture or private information. + +## Why This Matters + +Scientific workspaces often outlive a single account owner. A PI can leave, an institutional email can disappear, a lab can close, or an owner may become unreachable while collaborators still need to preserve datasets, manuscripts, audit evidence, and attribution. + +Without an explicit succession guard, platforms can drift toward two bad outcomes: + +1. Workspaces become orphaned and cannot be responsibly maintained. +2. A contributor or administrator takes over without enough independent approval, notice, or compliance review. + +This guard creates a reviewer-ready decision packet so SCIBASE can freeze risky transfers, preserve original credit, and approve clean succession requests with auditable evidence. diff --git a/project-ownership-succession-guard/data/sample_succession_packets.json b/project-ownership-succession-guard/data/sample_succession_packets.json new file mode 100644 index 00000000..91ddac21 --- /dev/null +++ b/project-ownership-succession-guard/data/sample_succession_packets.json @@ -0,0 +1,183 @@ +[ + { + "id": "succession-approve-001", + "title": "Departed PI transfer to verified co-owner with full notice", + "reviewDate": "2026-08-15", + "project": { + "visibility": "institutional-only", + "holds": [] + }, + "currentOwner": { + "id": "owner-1", + "active": false, + "lastSeenAt": "2026-03-01" + }, + "proposedOwner": { + "id": "co-owner-2", + "roles": ["co_owner", "admin"], + "identityVerified": true, + "mfaFresh": true + }, + "request": { + "reason": "departed_institution", + "inactivityThresholdDays": 90, + "activeDisputeCount": 0 + }, + "approvals": { + "institutionalSteward": true, + "dataSteward": true, + "coOwnerQuorum": true, + "complianceOfficer": false + }, + "notices": { + "collaboratorCount": 8, + "notifiedCollaborators": 8, + "objectionWindowDays": 21 + }, + "attribution": { + "preserveOriginalOwnerCredit": true, + "preserveContributionHistory": true, + "transferReasonPublicReceipt": true + }, + "audit": { + "immutableReceipt": true, + "openOwnershipDispute": false + } + }, + { + "id": "succession-hold-002", + "title": "Contributor attempts takeover without independent approval", + "reviewDate": "2026-08-15", + "project": { + "visibility": "private", + "holds": [] + }, + "currentOwner": { + "id": "owner-9", + "active": true, + "lastSeenAt": "2026-08-01" + }, + "proposedOwner": { + "id": "contributor-4", + "roles": ["contributor"], + "identityVerified": true, + "mfaFresh": false + }, + "request": { + "reason": "team_preference", + "inactivityThresholdDays": 90, + "competingOwnerClaim": true, + "activeDisputeCount": 1 + }, + "approvals": { + "institutionalSteward": false, + "dataSteward": false, + "coOwnerQuorum": false, + "complianceOfficer": false + }, + "notices": { + "collaboratorCount": 5, + "notifiedCollaborators": 2, + "objectionWindowDays": 3 + }, + "attribution": { + "preserveOriginalOwnerCredit": false, + "preserveContributionHistory": true, + "transferReasonPublicReceipt": false + }, + "audit": { + "immutableReceipt": false, + "openOwnershipDispute": true + } + }, + { + "id": "succession-hold-003", + "title": "Clinical dataset project transfer missing compliance signoff", + "reviewDate": "2026-08-15", + "project": { + "visibility": "invitation-only", + "holds": ["irb_hold", "clinical_data_lock", "data_use_agreement"] + }, + "currentOwner": { + "id": "owner-3", + "active": false, + "lastSeenAt": "2025-12-15" + }, + "proposedOwner": { + "id": "data-steward-7", + "roles": ["data_steward", "admin"], + "identityVerified": true, + "mfaFresh": true + }, + "request": { + "reason": "owner_unreachable", + "inactivityThresholdDays": 90, + "activeDisputeCount": 0 + }, + "approvals": { + "institutionalSteward": true, + "dataSteward": true, + "coOwnerQuorum": true, + "complianceOfficer": false + }, + "notices": { + "collaboratorCount": 12, + "notifiedCollaborators": 12, + "objectionWindowDays": 21 + }, + "attribution": { + "preserveOriginalOwnerCredit": true, + "preserveContributionHistory": true, + "transferReasonPublicReceipt": true + }, + "audit": { + "immutableReceipt": true, + "openOwnershipDispute": false + } + }, + { + "id": "succession-review-004", + "title": "Eligible successor with short notice window and missing receipt", + "reviewDate": "2026-08-15", + "project": { + "visibility": "private", + "holds": [] + }, + "currentOwner": { + "id": "owner-5", + "active": false, + "lastSeenAt": "2026-02-01" + }, + "proposedOwner": { + "id": "admin-11", + "roles": ["admin"], + "identityVerified": true, + "mfaFresh": true + }, + "request": { + "reason": "owner_inactive", + "inactivityThresholdDays": 90, + "activeDisputeCount": 0 + }, + "approvals": { + "institutionalSteward": true, + "dataSteward": false, + "coOwnerQuorum": false, + "complianceOfficer": false + }, + "notices": { + "collaboratorCount": 6, + "notifiedCollaborators": 6, + "objectionWindowDays": 7 + }, + "attribution": { + "preserveOriginalOwnerCredit": true, + "preserveContributionHistory": true, + "transferReasonPublicReceipt": true + }, + "audit": { + "immutableReceipt": false, + "openOwnershipDispute": false + } + } +] diff --git a/project-ownership-succession-guard/package.json b/project-ownership-succession-guard/package.json new file mode 100644 index 00000000..8a2f3c27 --- /dev/null +++ b/project-ownership-succession-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "project-ownership-succession-guard", + "version": "1.0.0", + "description": "Dependency-free guard for project ownership succession and orphaned workspace takeover decisions.", + "main": "src/index.js", + "scripts": { + "test": "node --test", + "demo": "node scripts/demo.js", + "video": "node scripts/render-demo-video.js", + "check": "node --check src/index.js && node --check scripts/demo.js && node --check scripts/render-demo-video.js" + }, + "license": "MIT" +} diff --git a/project-ownership-succession-guard/reports/demo.mp4 b/project-ownership-succession-guard/reports/demo.mp4 new file mode 100644 index 00000000..77edf07b Binary files /dev/null and b/project-ownership-succession-guard/reports/demo.mp4 differ diff --git a/project-ownership-succession-guard/reports/reviewer-packet.md b/project-ownership-succession-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..8688b9d9 --- /dev/null +++ b/project-ownership-succession-guard/reports/reviewer-packet.md @@ -0,0 +1,71 @@ +# Project Ownership Succession Guard Report + +Generated: 2026-08-15T21:45:36.455Z +Packets analyzed: 4 +Decision counts: APPROVE 1, REVIEW 1, HOLD 2 + +## Findings + +### succession-approve-001: Departed PI transfer to verified co-owner with full notice + +Decision: APPROVE +Risk score: 0 +Succession trigger: yes +Compliance hold: no + +- No succession risk detected. + +Recommended actions: +- Approve the ownership transfer and retain the succession receipt. + +### succession-hold-002: Contributor attempts takeover without independent approval + +Decision: HOLD +Risk score: 100 +Succession trigger: no +Compliance hold: no + +- CRITICAL NO_VALID_SUCCESSION_TRIGGER: The packet does not show a valid owner inactivity, departure, or succession trigger. +- CRITICAL PROPOSED_OWNER_ROLE_INELIGIBLE: The proposed owner lacks a succession-eligible project role. +- MAJOR SUCCESSOR_IDENTITY_POSTURE_INSUFFICIENT: The proposed owner does not have current verified identity and MFA evidence. +- CRITICAL INDEPENDENT_SUCCESSION_APPROVAL_MISSING: Ownership transfer lacks an independent institutional, data-steward, quorum, or legal approval path. +- MAJOR COLLABORATOR_SUCCESSION_NOTICE_INCOMPLETE: Not all collaborators were notified about the ownership succession request. +- MAJOR OBJECTION_WINDOW_TOO_SHORT: The collaborator objection window is too short for a project ownership transfer. +- MAJOR ORIGINAL_OWNER_ATTRIBUTION_NOT_PRESERVED: The transfer packet does not preserve original owner credit, contribution history, and a public transfer receipt. +- CRITICAL COMPETING_OWNERSHIP_CLAIM_UNRESOLVED: The transfer has an unresolved ownership dispute or competing successor claim. +- MAJOR IMMUTABLE_TRANSFER_AUDIT_RECEIPT_MISSING: The succession packet lacks an immutable audit receipt. + +Recommended actions: +- Freeze owner-only project actions until the succession packet is resolved. +- Route the packet to independent governance review. +- Preserve original owner attribution and collaborator notice evidence. + +### succession-hold-003: Clinical dataset project transfer missing compliance signoff + +Decision: HOLD +Risk score: 35 +Succession trigger: yes +Compliance hold: yes + +- CRITICAL COMPLIANCE_HOLD_TRANSFER_APPROVAL_MISSING: The project has regulated-data, IRB, DUA, embargo, clinical, or export-control holds without compliance approval. + +Recommended actions: +- Keep regulated datasets and embargoed artifacts locked until compliance approval is recorded. +- Freeze owner-only project actions until the succession packet is resolved. +- Route the packet to independent governance review. +- Preserve original owner attribution and collaborator notice evidence. + +### succession-review-004: Eligible successor with short notice window and missing receipt + +Decision: REVIEW +Risk score: 44 +Succession trigger: yes +Compliance hold: no + +- MAJOR OBJECTION_WINDOW_TOO_SHORT: The collaborator objection window is too short for a project ownership transfer. +- MAJOR IMMUTABLE_TRANSFER_AUDIT_RECEIPT_MISSING: The succession packet lacks an immutable audit receipt. + +Recommended actions: +- Freeze owner-only project actions until the succession packet is resolved. +- Route the packet to independent governance review. +- Preserve original owner attribution and collaborator notice evidence. diff --git a/project-ownership-succession-guard/reports/summary.json b/project-ownership-succession-guard/reports/summary.json new file mode 100644 index 00000000..ef0fe386 --- /dev/null +++ b/project-ownership-succession-guard/reports/summary.json @@ -0,0 +1,196 @@ +{ + "generatedAt": "2026-08-15T21:45:36.455Z", + "totalPackets": 4, + "counts": { + "APPROVE": 1, + "REVIEW": 1, + "HOLD": 2 + }, + "results": [ + { + "id": "succession-approve-001", + "title": "Departed PI transfer to verified co-owner with full notice", + "decision": "APPROVE", + "riskScore": 0, + "hasSuccessionTrigger": true, + "complianceHold": false, + "findings": [], + "recommendedActions": [ + "Approve the ownership transfer and retain the succession receipt." + ] + }, + { + "id": "succession-hold-002", + "title": "Contributor attempts takeover without independent approval", + "decision": "HOLD", + "riskScore": 100, + "hasSuccessionTrigger": false, + "complianceHold": false, + "findings": [ + { + "code": "NO_VALID_SUCCESSION_TRIGGER", + "severity": "critical", + "message": "The packet does not show a valid owner inactivity, departure, or succession trigger.", + "evidence": { + "reason": "team_preference", + "currentOwnerActive": true, + "currentOwnerLastSeenAt": "2026-08-01" + }, + "remediation": "Require owner consent or document a valid inactivity, departure, death, suspension, or institutional succession trigger." + }, + { + "code": "PROPOSED_OWNER_ROLE_INELIGIBLE", + "severity": "critical", + "message": "The proposed owner lacks a succession-eligible project role.", + "evidence": { + "proposedOwner": "contributor-4", + "roles": [ + "contributor" + ] + }, + "remediation": "Limit ownership succession to admins, co-owners, PIs, data stewards, or institutional stewards." + }, + { + "code": "SUCCESSOR_IDENTITY_POSTURE_INSUFFICIENT", + "severity": "major", + "message": "The proposed owner does not have current verified identity and MFA evidence.", + "evidence": { + "identityVerified": true, + "mfaFresh": false + }, + "remediation": "Require fresh identity verification and MFA before ownership transfer." + }, + { + "code": "INDEPENDENT_SUCCESSION_APPROVAL_MISSING", + "severity": "critical", + "message": "Ownership transfer lacks an independent institutional, data-steward, quorum, or legal approval path.", + "evidence": { + "institutionalSteward": false, + "dataSteward": false, + "coOwnerQuorum": false, + "complianceOfficer": false + }, + "remediation": "Route the packet to an institutional steward, data steward plus co-owner quorum, or documented legal authority." + }, + { + "code": "COLLABORATOR_SUCCESSION_NOTICE_INCOMPLETE", + "severity": "major", + "message": "Not all collaborators were notified about the ownership succession request.", + "evidence": { + "collaboratorCount": 5, + "notifiedCollaborators": 2 + }, + "remediation": "Notify all active collaborators and preserve the notice receipt in the audit log." + }, + { + "code": "OBJECTION_WINDOW_TOO_SHORT", + "severity": "major", + "message": "The collaborator objection window is too short for a project ownership transfer.", + "evidence": { + "objectionWindowDays": 3 + }, + "remediation": "Provide at least a 14-day objection window unless legal or safety escalation documents an exception." + }, + { + "code": "ORIGINAL_OWNER_ATTRIBUTION_NOT_PRESERVED", + "severity": "major", + "message": "The transfer packet does not preserve original owner credit, contribution history, and a public transfer receipt.", + "evidence": { + "preserveOriginalOwnerCredit": false, + "preserveContributionHistory": true, + "transferReasonPublicReceipt": false + }, + "remediation": "Preserve original PI/owner credit, contribution records, and a public audit receipt for the transfer reason." + }, + { + "code": "COMPETING_OWNERSHIP_CLAIM_UNRESOLVED", + "severity": "critical", + "message": "The transfer has an unresolved ownership dispute or competing successor claim.", + "evidence": { + "competingOwnerClaim": true, + "activeDisputeCount": 1, + "openOwnershipDispute": true + }, + "remediation": "Hold the transfer until the dispute is resolved by independent governance." + }, + { + "code": "IMMUTABLE_TRANSFER_AUDIT_RECEIPT_MISSING", + "severity": "major", + "message": "The succession packet lacks an immutable audit receipt.", + "evidence": { + "immutableReceipt": false + }, + "remediation": "Record the trigger, approvals, notices, object freezes, and attribution preservation in an immutable audit receipt." + } + ], + "recommendedActions": [ + "Freeze owner-only project actions until the succession packet is resolved.", + "Route the packet to independent governance review.", + "Preserve original owner attribution and collaborator notice evidence." + ] + }, + { + "id": "succession-hold-003", + "title": "Clinical dataset project transfer missing compliance signoff", + "decision": "HOLD", + "riskScore": 35, + "hasSuccessionTrigger": true, + "complianceHold": true, + "findings": [ + { + "code": "COMPLIANCE_HOLD_TRANSFER_APPROVAL_MISSING", + "severity": "critical", + "message": "The project has regulated-data, IRB, DUA, embargo, clinical, or export-control holds without compliance approval.", + "evidence": { + "holds": [ + "irb_hold", + "clinical_data_lock", + "data_use_agreement" + ], + "complianceOfficer": false + }, + "remediation": "Require compliance-officer approval and keep restricted objects frozen until the transfer is approved." + } + ], + "recommendedActions": [ + "Keep regulated datasets and embargoed artifacts locked until compliance approval is recorded.", + "Freeze owner-only project actions until the succession packet is resolved.", + "Route the packet to independent governance review.", + "Preserve original owner attribution and collaborator notice evidence." + ] + }, + { + "id": "succession-review-004", + "title": "Eligible successor with short notice window and missing receipt", + "decision": "REVIEW", + "riskScore": 44, + "hasSuccessionTrigger": true, + "complianceHold": false, + "findings": [ + { + "code": "OBJECTION_WINDOW_TOO_SHORT", + "severity": "major", + "message": "The collaborator objection window is too short for a project ownership transfer.", + "evidence": { + "objectionWindowDays": 7 + }, + "remediation": "Provide at least a 14-day objection window unless legal or safety escalation documents an exception." + }, + { + "code": "IMMUTABLE_TRANSFER_AUDIT_RECEIPT_MISSING", + "severity": "major", + "message": "The succession packet lacks an immutable audit receipt.", + "evidence": { + "immutableReceipt": false + }, + "remediation": "Record the trigger, approvals, notices, object freezes, and attribution preservation in an immutable audit receipt." + } + ], + "recommendedActions": [ + "Freeze owner-only project actions until the succession packet is resolved.", + "Route the packet to independent governance review.", + "Preserve original owner attribution and collaborator notice evidence." + ] + } + ] +} diff --git a/project-ownership-succession-guard/reports/summary.svg b/project-ownership-succession-guard/reports/summary.svg new file mode 100644 index 00000000..0b0fe961 --- /dev/null +++ b/project-ownership-succession-guard/reports/summary.svg @@ -0,0 +1,17 @@ + + + Project Ownership Succession Guard + Synthetic orphaned-workspace transfer audit for user and project governance. + Decision distribution + HOLD + + 2 + REVIEW + + 1 + APPROVE + + 1 + Checks succession triggers, eligible successors, compliance holds, notices, attribution, and audit receipts. + Synthetic generated artifact only. No desktop capture or private data. + diff --git a/project-ownership-succession-guard/scripts/demo.js b/project-ownership-succession-guard/scripts/demo.js new file mode 100644 index 00000000..a8812e84 --- /dev/null +++ b/project-ownership-succession-guard/scripts/demo.js @@ -0,0 +1,79 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzeSuccessionPackets } = require("../src"); + +const root = path.resolve(__dirname, ".."); +const dataPath = path.join(root, "data", "sample_succession_packets.json"); +const outDir = path.join(root, "reports"); +const packets = JSON.parse(fs.readFileSync(dataPath, "utf8")); +const report = analyzeSuccessionPackets(packets); + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(report, null, 2)}\n`); + +const markdown = [ + "# Project Ownership Succession Guard Report", + "", + `Generated: ${report.generatedAt}`, + `Packets analyzed: ${report.totalPackets}`, + `Decision counts: APPROVE ${report.counts.APPROVE}, REVIEW ${report.counts.REVIEW}, HOLD ${report.counts.HOLD}`, + "", + "## Findings", + "", + ...report.results.flatMap((item) => [ + `### ${item.id}: ${item.title}`, + "", + `Decision: ${item.decision}`, + `Risk score: ${item.riskScore}`, + `Succession trigger: ${item.hasSuccessionTrigger ? "yes" : "no"}`, + `Compliance hold: ${item.complianceHold ? "yes" : "no"}`, + "", + item.findings.length === 0 + ? "- No succession risk detected." + : item.findings.map((finding) => `- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`).join("\n"), + "", + "Recommended actions:", + ...item.recommendedActions.map((action) => `- ${action}`), + "" + ]) +].join("\n"); + +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), markdown); + +const maxBar = 620; +const total = Math.max(1, report.totalPackets); +const approveWidth = Math.round((report.counts.APPROVE / total) * maxBar); +const reviewWidth = Math.round((report.counts.REVIEW / total) * maxBar); +const holdWidth = Math.round((report.counts.HOLD / total) * maxBar); +const svg = ` + + Project Ownership Succession Guard + Synthetic orphaned-workspace transfer audit for user and project governance. + Decision distribution + HOLD + + ${report.counts.HOLD} + REVIEW + + ${report.counts.REVIEW} + APPROVE + + ${report.counts.APPROVE} + Checks succession triggers, eligible successors, compliance holds, notices, attribution, and audit receipts. + Synthetic generated artifact only. No desktop capture or private data. + +`; + +fs.writeFileSync(path.join(outDir, "summary.svg"), svg); + +console.log(JSON.stringify({ + packets: report.totalPackets, + counts: report.counts, + outputs: [ + path.join(outDir, "summary.json"), + path.join(outDir, "reviewer-packet.md"), + path.join(outDir, "summary.svg") + ] +}, null, 2)); diff --git a/project-ownership-succession-guard/scripts/render-demo-video.js b/project-ownership-succession-guard/scripts/render-demo-video.js new file mode 100644 index 00000000..2c380365 --- /dev/null +++ b/project-ownership-succession-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='Project Ownership Succession Guard':x=64:y=56:fontsize=40:fontcolor=black`, + `drawtext=fontfile=${font}:text='Orphaned workspace takeover audit for user and project governance':x=64:y=122:fontsize=25:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic transfer 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=0x245fd6`, + `drawtext=fontfile=${font}:text='Checks owner inactivity successor eligibility and independent approvals':x=64:y=366:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Preserves attribution collaborator notice and compliance holds':x=64:y=428:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic generated slate only no desktop capture or private data':x=64:y=492:fontsize=26:fontcolor=0x34413b` +].join(","); + +if (!fs.existsSync(ffmpeg)) { + throw new Error(`ffmpeg binary not found at ${ffmpeg}`); +} + +const result = spawnSync(ffmpeg, [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0xf9faf8: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/project-ownership-succession-guard/src/index.js b/project-ownership-succession-guard/src/index.js new file mode 100644 index 00000000..dfa03c65 --- /dev/null +++ b/project-ownership-succession-guard/src/index.js @@ -0,0 +1,306 @@ +"use strict"; + +const OWNER_INACTIVE_REASONS = new Set([ + "departed_institution", + "owner_unreachable", + "owner_inactive", + "owner_deceased", + "lab_closed", + "account_suspended" +]); + +const ELIGIBLE_ROLES = new Set([ + "admin", + "principal_investigator", + "data_steward", + "institutional_steward", + "co_owner" +]); + +const COMPLIANCE_HOLDS = new Set([ + "irb_hold", + "funder_embargo", + "data_use_agreement", + "human_subjects_restriction", + "clinical_data_lock", + "export_control" +]); + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeText(value) { + return String(value || "").trim().toLowerCase(); +} + +function parseDate(value) { + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time) : null; +} + +function daysBetween(startValue, endValue) { + const start = parseDate(startValue); + const end = parseDate(endValue); + if (!start || !end) return null; + return Math.round((end.getTime() - start.getTime()) / 86400000); +} + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function normalizePacket(packet) { + return { + id: packet.id, + title: packet.title || "Untitled ownership transfer packet", + reviewDate: packet.reviewDate || "2026-08-15", + project: packet.project || {}, + currentOwner: packet.currentOwner || {}, + proposedOwner: packet.proposedOwner || {}, + request: packet.request || {}, + approvals: packet.approvals || {}, + notices: packet.notices || {}, + attribution: packet.attribution || {}, + audit: packet.audit || {} + }; +} + +function hasOwnerSuccessionTrigger(packet) { + const reason = normalizeText(packet.request.reason); + if (OWNER_INACTIVE_REASONS.has(reason)) return true; + if (packet.currentOwner.active === false) return true; + const lastSeenAge = daysBetween(packet.currentOwner.lastSeenAt, packet.reviewDate); + return lastSeenAge !== null && lastSeenAge > Number(packet.request.inactivityThresholdDays || 90); +} + +function proposedOwnerRoleEligible(proposedOwner) { + const roles = list(proposedOwner.roles).map(normalizeText); + return roles.some((role) => ELIGIBLE_ROLES.has(role)); +} + +function projectHasComplianceHold(project) { + const holds = list(project.holds).map(normalizeText); + return holds.some((hold) => COMPLIANCE_HOLDS.has(hold)); +} + +function independentApprovalPresent(packet) { + const approvals = packet.approvals; + if (approvals.institutionalSteward === true) return true; + if (approvals.dataSteward === true && approvals.coOwnerQuorum === true) return true; + if (approvals.courtOrEstateAuthority === true) return true; + return false; +} + +function collaboratorNoticeComplete(packet) { + const collaborators = Number(packet.notices.collaboratorCount || 0); + const notified = Number(packet.notices.notifiedCollaborators || 0); + if (collaborators === 0) return true; + return notified >= collaborators; +} + +function noticeWindowAdequate(packet) { + return Number(packet.notices.objectionWindowDays || 0) >= 14; +} + +function attributionPreserved(packet) { + return ( + packet.attribution.preserveOriginalOwnerCredit === true && + packet.attribution.preserveContributionHistory === true && + packet.attribution.transferReasonPublicReceipt === true + ); +} + +function hasConflictingClaim(packet) { + if (packet.request.competingOwnerClaim === true) return true; + if (packet.audit.openOwnershipDispute === true) return true; + return Number(packet.request.activeDisputeCount || 0) > 0; +} + +function scoreFindings(findings) { + const score = findings.reduce((total, item) => { + if (item.severity === "critical") return total + 35; + if (item.severity === "major") return total + 22; + return total + 10; + }, 0); + return Math.min(100, score); +} + +function decisionFor(findings) { + if (findings.some((item) => item.severity === "critical")) return "HOLD"; + if (findings.some((item) => item.severity === "major")) return "REVIEW"; + return "APPROVE"; +} + +function analyzePacket(input) { + const packet = normalizePacket(input); + const findings = []; + const hasTrigger = hasOwnerSuccessionTrigger(packet); + const complianceHold = projectHasComplianceHold(packet.project); + + if (!hasTrigger) { + findings.push(finding( + "NO_VALID_SUCCESSION_TRIGGER", + "critical", + "The packet does not show a valid owner inactivity, departure, or succession trigger.", + { + reason: packet.request.reason || null, + currentOwnerActive: packet.currentOwner.active, + currentOwnerLastSeenAt: packet.currentOwner.lastSeenAt || null + }, + "Require owner consent or document a valid inactivity, departure, death, suspension, or institutional succession trigger." + )); + } + + if (!proposedOwnerRoleEligible(packet.proposedOwner)) { + findings.push(finding( + "PROPOSED_OWNER_ROLE_INELIGIBLE", + "critical", + "The proposed owner lacks a succession-eligible project role.", + { proposedOwner: packet.proposedOwner.id || null, roles: list(packet.proposedOwner.roles) }, + "Limit ownership succession to admins, co-owners, PIs, data stewards, or institutional stewards." + )); + } + + if (packet.proposedOwner.identityVerified !== true || packet.proposedOwner.mfaFresh !== true) { + findings.push(finding( + "SUCCESSOR_IDENTITY_POSTURE_INSUFFICIENT", + "major", + "The proposed owner does not have current verified identity and MFA evidence.", + { + identityVerified: packet.proposedOwner.identityVerified || false, + mfaFresh: packet.proposedOwner.mfaFresh || false + }, + "Require fresh identity verification and MFA before ownership transfer." + )); + } + + if (!independentApprovalPresent(packet)) { + findings.push(finding( + "INDEPENDENT_SUCCESSION_APPROVAL_MISSING", + "critical", + "Ownership transfer lacks an independent institutional, data-steward, quorum, or legal approval path.", + packet.approvals, + "Route the packet to an institutional steward, data steward plus co-owner quorum, or documented legal authority." + )); + } + + if (complianceHold && packet.approvals.complianceOfficer !== true) { + findings.push(finding( + "COMPLIANCE_HOLD_TRANSFER_APPROVAL_MISSING", + "critical", + "The project has regulated-data, IRB, DUA, embargo, clinical, or export-control holds without compliance approval.", + { holds: list(packet.project.holds), complianceOfficer: packet.approvals.complianceOfficer || false }, + "Require compliance-officer approval and keep restricted objects frozen until the transfer is approved." + )); + } + + if (!collaboratorNoticeComplete(packet)) { + findings.push(finding( + "COLLABORATOR_SUCCESSION_NOTICE_INCOMPLETE", + "major", + "Not all collaborators were notified about the ownership succession request.", + { + collaboratorCount: Number(packet.notices.collaboratorCount || 0), + notifiedCollaborators: Number(packet.notices.notifiedCollaborators || 0) + }, + "Notify all active collaborators and preserve the notice receipt in the audit log." + )); + } + + if (!noticeWindowAdequate(packet)) { + findings.push(finding( + "OBJECTION_WINDOW_TOO_SHORT", + "major", + "The collaborator objection window is too short for a project ownership transfer.", + { objectionWindowDays: Number(packet.notices.objectionWindowDays || 0) }, + "Provide at least a 14-day objection window unless legal or safety escalation documents an exception." + )); + } + + if (!attributionPreserved(packet)) { + findings.push(finding( + "ORIGINAL_OWNER_ATTRIBUTION_NOT_PRESERVED", + "major", + "The transfer packet does not preserve original owner credit, contribution history, and a public transfer receipt.", + packet.attribution, + "Preserve original PI/owner credit, contribution records, and a public audit receipt for the transfer reason." + )); + } + + if (hasConflictingClaim(packet)) { + findings.push(finding( + "COMPETING_OWNERSHIP_CLAIM_UNRESOLVED", + "critical", + "The transfer has an unresolved ownership dispute or competing successor claim.", + { + competingOwnerClaim: packet.request.competingOwnerClaim || false, + activeDisputeCount: Number(packet.request.activeDisputeCount || 0), + openOwnershipDispute: packet.audit.openOwnershipDispute || false + }, + "Hold the transfer until the dispute is resolved by independent governance." + )); + } + + if (packet.audit.immutableReceipt !== true) { + findings.push(finding( + "IMMUTABLE_TRANSFER_AUDIT_RECEIPT_MISSING", + "major", + "The succession packet lacks an immutable audit receipt.", + { immutableReceipt: packet.audit.immutableReceipt || false }, + "Record the trigger, approvals, notices, object freezes, and attribution preservation in an immutable audit receipt." + )); + } + + const decision = decisionFor(findings); + return { + id: packet.id, + title: packet.title, + decision, + riskScore: scoreFindings(findings), + hasSuccessionTrigger: hasTrigger, + complianceHold, + findings, + recommendedActions: buildRecommendedActions(decision, findings) + }; +} + +function buildRecommendedActions(decision, findings) { + if (decision === "APPROVE") { + return ["Approve the ownership transfer and retain the succession receipt."]; + } + + const actions = [ + "Freeze owner-only project actions until the succession packet is resolved.", + "Route the packet to independent governance review.", + "Preserve original owner attribution and collaborator notice evidence." + ]; + + if (findings.some((item) => item.code === "COMPLIANCE_HOLD_TRANSFER_APPROVAL_MISSING")) { + actions.unshift("Keep regulated datasets and embargoed artifacts locked until compliance approval is recorded."); + } + + return actions; +} + +function analyzeSuccessionPackets(packets) { + const results = list(packets).map(analyzePacket); + const counts = results.reduce((acc, item) => { + acc[item.decision] = (acc[item.decision] || 0) + 1; + return acc; + }, { APPROVE: 0, REVIEW: 0, HOLD: 0 }); + + return { + generatedAt: new Date().toISOString(), + totalPackets: results.length, + counts, + results + }; +} + +module.exports = { + analyzePacket, + analyzeSuccessionPackets, + daysBetween, + hasOwnerSuccessionTrigger +}; diff --git a/project-ownership-succession-guard/test/project-ownership-succession-guard.test.js b/project-ownership-succession-guard/test/project-ownership-succession-guard.test.js new file mode 100644 index 00000000..8304e748 --- /dev/null +++ b/project-ownership-succession-guard/test/project-ownership-succession-guard.test.js @@ -0,0 +1,51 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { analyzePacket, analyzeSuccessionPackets, daysBetween, hasOwnerSuccessionTrigger } = require("../src"); +const samples = require("../data/sample_succession_packets.json"); + +test("computes inactivity windows", () => { + assert.equal(daysBetween("2026-01-01", "2026-01-15"), 14); + assert.equal(daysBetween("bad-date", "2026-01-15"), null); +}); + +test("detects owner succession triggers", () => { + assert.equal(hasOwnerSuccessionTrigger({ currentOwner: { active: false }, request: {} }), true); + assert.equal(hasOwnerSuccessionTrigger({ currentOwner: { active: true, lastSeenAt: "2026-01-01" }, reviewDate: "2026-08-15", request: { inactivityThresholdDays: 90 } }), true); + assert.equal(hasOwnerSuccessionTrigger({ currentOwner: { active: true, lastSeenAt: "2026-08-01" }, reviewDate: "2026-08-15", request: { reason: "team_preference" } }), false); +}); + +test("approves a complete succession packet", () => { + const result = analyzePacket(samples.find((item) => item.id === "succession-approve-001")); + assert.equal(result.decision, "APPROVE"); + assert.equal(result.findings.length, 0); +}); + +test("holds invalid takeover attempts", () => { + const result = analyzePacket(samples.find((item) => item.id === "succession-hold-002")); + assert.equal(result.decision, "HOLD"); + assert.ok(result.findings.some((item) => item.code === "NO_VALID_SUCCESSION_TRIGGER")); + assert.ok(result.findings.some((item) => item.code === "PROPOSED_OWNER_ROLE_INELIGIBLE")); + assert.ok(result.findings.some((item) => item.code === "COMPETING_OWNERSHIP_CLAIM_UNRESOLVED")); +}); + +test("holds regulated project transfers without compliance signoff", () => { + const result = analyzePacket(samples.find((item) => item.id === "succession-hold-003")); + assert.equal(result.decision, "HOLD"); + assert.equal(result.complianceHold, true); + assert.ok(result.findings.some((item) => item.code === "COMPLIANCE_HOLD_TRANSFER_APPROVAL_MISSING")); +}); + +test("routes incomplete but non-critical packets for review", () => { + const result = analyzePacket(samples.find((item) => item.id === "succession-review-004")); + assert.equal(result.decision, "REVIEW"); + assert.ok(result.findings.some((item) => item.code === "OBJECTION_WINDOW_TOO_SHORT")); + assert.ok(result.findings.some((item) => item.code === "IMMUTABLE_TRANSFER_AUDIT_RECEIPT_MISSING")); +}); + +test("aggregates transfer packet decisions", () => { + const report = analyzeSuccessionPackets(samples); + assert.equal(report.totalPackets, 4); + assert.deepEqual(report.counts, { APPROVE: 1, REVIEW: 1, HOLD: 2 }); +});