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 @@
+
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 = `
+`;
+
+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 });
+});