From 10a28020b9c2301cd8c72ad646f64bf0a9cc94a9 Mon Sep 17 00:00:00 2001 From: Telemark Codex Agent Date: Sat, 15 Aug 2026 23:46:55 +0200 Subject: [PATCH] Add project ownership succession guard --- project-ownership-succession-guard/README.md | 48 +++ .../data/sample_succession_packets.json | 183 +++++++++++ .../package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 54523 bytes .../reports/reviewer-packet.md | 71 ++++ .../reports/summary.json | 196 +++++++++++ .../reports/summary.svg | 17 + .../scripts/demo.js | 79 +++++ .../scripts/render-demo-video.js | 60 ++++ .../src/index.js | 306 ++++++++++++++++++ ...project-ownership-succession-guard.test.js | 51 +++ 11 files changed, 1024 insertions(+) create mode 100644 project-ownership-succession-guard/README.md create mode 100644 project-ownership-succession-guard/data/sample_succession_packets.json create mode 100644 project-ownership-succession-guard/package.json create mode 100644 project-ownership-succession-guard/reports/demo.mp4 create mode 100644 project-ownership-succession-guard/reports/reviewer-packet.md create mode 100644 project-ownership-succession-guard/reports/summary.json create mode 100644 project-ownership-succession-guard/reports/summary.svg create mode 100644 project-ownership-succession-guard/scripts/demo.js create mode 100644 project-ownership-succession-guard/scripts/render-demo-video.js create mode 100644 project-ownership-succession-guard/src/index.js create mode 100644 project-ownership-succession-guard/test/project-ownership-succession-guard.test.js 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 0000000000000000000000000000000000000000..77edf07bc46bf02f107c952127a1b3526809b6c0 GIT binary patch literal 54523 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 VOWVNXiGQQYG1snmfA%8E{{V|MdW--7 literal 0 HcmV?d00001 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 }); +});