diff --git a/project-access-denial-appeal-guard/.gitignore b/project-access-denial-appeal-guard/.gitignore
new file mode 100644
index 00000000..2bf074d6
--- /dev/null
+++ b/project-access-denial-appeal-guard/.gitignore
@@ -0,0 +1 @@
+reports/frames/
diff --git a/project-access-denial-appeal-guard/README.md b/project-access-denial-appeal-guard/README.md
new file mode 100644
index 00000000..9a829a7b
--- /dev/null
+++ b/project-access-denial-appeal-guard/README.md
@@ -0,0 +1,40 @@
+# Project Access Denial and Appeal Guard
+
+Self-contained synthetic module for SCIBASE issue #11, User & Project Management.
+
+The guard focuses on a narrow decision point: denied restricted-project access requests and appeal packets should be fair, timely, independently reviewed, requester-safe, and auditable before the final user-facing decision is sent.
+
+## What It Checks
+
+- Stable denied access request ids and requester ids.
+- Specific denial reason codes and rationales.
+- Requester-safe redaction of private denial and appeal notes.
+- Appeal submission inside the configured appeal window.
+- Independent appeal reviewer identity and eligible governance role.
+- Required training, data-use agreement, and IRB evidence by project classification.
+- Least-privilege object scope for restricted projects.
+- User-facing audit receipts with reason code, appeal deadline, evidence checklist, decision timestamp, and appeal outcome.
+
+This is distinct from broad RBAC ledgers, privacy access review campaigns, member offboarding, break-glass access, collaborator conflict checks, data-residency transfers, and object-permission drift. It is specifically about the post-denial appeal workflow and the receipt that a denied researcher receives.
+
+## Run
+
+```sh
+npm run check
+npm test
+npm run demo
+npm run verify-video
+```
+
+## Outputs
+
+`npm run demo` writes:
+
+- `reports/clean-audit.json`
+- `reports/risky-audit.json`
+- `reports/risky-review.md`
+- `reports/summary.svg`
+- `reports/manifest.json`
+- `reports/demo.mp4`
+
+The sample data is synthetic only. The module does not call identity providers, SAML/OAuth/ORCID services, GitHub, payment processors, private user/project systems, or external APIs.
diff --git a/project-access-denial-appeal-guard/demo.js b/project-access-denial-appeal-guard/demo.js
new file mode 100644
index 00000000..1a409861
--- /dev/null
+++ b/project-access-denial-appeal-guard/demo.js
@@ -0,0 +1,46 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const {
+ evaluateAccessDenialAppeals,
+ renderMarkdownReport,
+ renderSvgSummary
+} = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const clean = evaluateAccessDenialAppeals(cleanPacket);
+const risky = evaluateAccessDenialAppeals(riskyPacket);
+
+fs.writeFileSync(path.join(reportsDir, "clean-audit.json"), `${JSON.stringify(clean, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, "risky-audit.json"), `${JSON.stringify(risky, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(risky, riskyPacket));
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(risky));
+fs.writeFileSync(
+ path.join(reportsDir, "manifest.json"),
+ `${JSON.stringify(
+ {
+ generatedAt: new Date().toISOString(),
+ module: "project-access-denial-appeal-guard",
+ cleanStatus: clean.status,
+ riskyStatus: risky.status,
+ riskyFindings: risky.findings.length,
+ artifacts: [
+ "clean-audit.json",
+ "risky-audit.json",
+ "risky-review.md",
+ "summary.svg",
+ "demo.mp4"
+ ]
+ },
+ null,
+ 2
+ )}\n`
+);
+
+console.log(`Clean packet: ${clean.status} (${clean.findings.length} findings)`);
+console.log(`Risky packet: ${risky.status} (${risky.findings.length} findings)`);
+console.log(`Wrote reports to ${reportsDir}`);
diff --git a/project-access-denial-appeal-guard/index.js b/project-access-denial-appeal-guard/index.js
new file mode 100644
index 00000000..9251b40e
--- /dev/null
+++ b/project-access-denial-appeal-guard/index.js
@@ -0,0 +1,583 @@
+"use strict";
+
+const crypto = require("node:crypto");
+
+const SEVERITY_ORDER = ["critical", "high", "warning", "info"];
+
+const DEFAULT_POLICY = {
+ appealWindowDays: 14,
+ minRationaleCharacters: 24,
+ independentReviewerRoles: ["data-steward", "institution-admin", "ethics-reviewer"],
+ requiredEvidenceByClassification: {
+ restricted_human: ["trainingValid", "duaActive", "irbApproval"],
+ confidential: ["trainingValid", "duaActive"],
+ institutional_only: ["trainingValid"]
+ }
+};
+
+function evaluateAccessDenialAppeals(packet, options = {}) {
+ if (!isPlainObject(packet)) {
+ throw new TypeError("evaluateAccessDenialAppeals expects a packet object");
+ }
+
+ const now = options.now ?? new Date().toISOString();
+ const policy = {
+ ...DEFAULT_POLICY,
+ ...(isPlainObject(packet.policy) ? packet.policy : {}),
+ requiredEvidenceByClassification: {
+ ...DEFAULT_POLICY.requiredEvidenceByClassification,
+ ...(isPlainObject(packet.policy?.requiredEvidenceByClassification)
+ ? packet.policy.requiredEvidenceByClassification
+ : {})
+ }
+ };
+ const accessRequests = asArray(packet.accessRequests);
+ const findings = [];
+ const appealDecisions = [];
+
+ if (accessRequests.length === 0) {
+ findings.push(
+ finding(
+ "NO_ACCESS_REQUESTS",
+ "high",
+ "No denied project access requests were supplied.",
+ "The guard needs denial and appeal packets before a user-facing decision is finalized.",
+ "accessRequests",
+ "Attach denied access requests with denial reasons, appeal packets, evidence, and audit receipts.",
+ "workspace admin"
+ )
+ );
+ }
+
+ accessRequests.forEach((request, index) => {
+ inspectAccessRequest(request, index, policy, findings, appealDecisions);
+ });
+
+ const sortedFindings = sortFindings(findings);
+ const status = determineStatus(sortedFindings);
+ const remediationActions = sortedFindings.map((item) => ({
+ code: item.code,
+ requestId: item.requestId,
+ owner: item.owner,
+ action: item.remediation
+ }));
+ const summary = summarize(status, sortedFindings, appealDecisions);
+ const fingerprint = crypto
+ .createHash("sha256")
+ .update(
+ JSON.stringify({
+ policy,
+ accessRequests: accessRequests.map((request) => ({
+ id: request.id,
+ classification: request.project?.classification,
+ denialCode: request.denial?.reasonCode,
+ appealDecision: request.appeal?.decision,
+ findingCodes: sortedFindings.map((item) => item.code)
+ }))
+ })
+ )
+ .digest("hex")
+ .slice(0, 16);
+
+ return {
+ generatedAt: now,
+ status,
+ summary,
+ findingCounts: countBySeverity(sortedFindings),
+ findings: sortedFindings,
+ appealDecisions,
+ remediationActions,
+ fingerprint
+ };
+}
+
+function renderMarkdownReport(result, packet) {
+ const lines = [
+ "# Project Access Denial and Appeal Guard",
+ "",
+ `Packet: ${packet.id ?? "unknown"}`,
+ `Status: ${result.status}`,
+ `Fingerprint: ${result.fingerprint}`,
+ "",
+ "## Summary",
+ "",
+ result.summary,
+ "",
+ "## Appeal Decisions",
+ ""
+ ];
+
+ if (result.appealDecisions.length === 0) {
+ lines.push("- No appeal decisions were evaluated.");
+ } else {
+ result.appealDecisions.forEach((decision) => {
+ lines.push(`- ${decision.requestId}: ${decision.finalAction}; ${decision.findingCodes.length} finding(s)`);
+ lines.push(` - Requester: ${decision.requesterId}; project ${decision.projectId}`);
+ lines.push(` - Appeal state: ${decision.appealState}; receipt ${decision.receiptState}`);
+ });
+ }
+
+ lines.push("", "## Findings", "");
+ if (result.findings.length === 0) {
+ lines.push("- No denial/appeal governance blockers found.");
+ } else {
+ result.findings.forEach((item) => {
+ lines.push(`- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`);
+ lines.push(` - Evidence: ${item.evidence}`);
+ lines.push(` - Remediation: ${item.remediation}`);
+ });
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvgSummary(result) {
+ const counts = result.findingCounts;
+ const blockers = (counts.critical ?? 0) + (counts.high ?? 0);
+ const warnings = counts.warning ?? 0;
+ const ready = result.status === "READY";
+ const statusColor = ready ? "#16794c" : result.status === "REVIEW" ? "#a15c00" : "#a11b32";
+ const safeWidth = ready ? 360 : Math.max(90, 360 - blockers * 45);
+ const blockerWidth = Math.min(330, blockers * 54);
+ const warningWidth = Math.min(220, warnings * 42);
+
+ return [
+ ``
+ ].join("\n");
+}
+
+function inspectAccessRequest(request, index, policy, findings, appealDecisions) {
+ const requestId = request.id ?? `access-request-${index}`;
+ const path = `accessRequests[${index}]`;
+ const project = isPlainObject(request.project) ? request.project : {};
+ const requester = isPlainObject(request.requester) ? request.requester : {};
+ const denial = isPlainObject(request.denial) ? request.denial : {};
+ const appeal = isPlainObject(request.appeal) ? request.appeal : {};
+ const objects = asArray(request.requestedObjects);
+ const auditReceipt = isPlainObject(request.auditReceipt) ? request.auditReceipt : {};
+
+ const beforeCount = findings.length;
+
+ if (!request.id) {
+ findings.push(
+ finding(
+ "REQUEST_ID_MISSING",
+ "high",
+ `Access request at index ${index} has no stable id.`,
+ "Appeal packets must remain traceable across user notices and audit logs.",
+ `${path}.id`,
+ "Assign a stable access request id before finalizing the decision.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+
+ if (!requester.id) {
+ findings.push(
+ finding(
+ "REQUESTER_ID_MISSING",
+ "high",
+ `${requestId} has no requester id.`,
+ "The user-facing decision cannot be traced to a researcher profile or account.",
+ `${path}.requester.id`,
+ "Attach the requester account or researcher profile id.",
+ "identity admin",
+ requestId
+ )
+ );
+ }
+
+ inspectDenial(requestId, path, denial, findings);
+ inspectAppeal(requestId, path, denial, appeal, project, policy, findings);
+ inspectEvidence(requestId, path, project, appeal, policy, findings);
+ inspectObjectScope(requestId, path, objects, project, findings);
+ inspectAuditReceipt(requestId, path, auditReceipt, denial, appeal, findings);
+
+ const requestFindings = findings.slice(beforeCount);
+ appealDecisions.push({
+ requestId,
+ requesterId: requester.id ?? null,
+ projectId: project.id ?? null,
+ classification: project.classification ?? "unknown",
+ appealState: appeal.submittedAt ? "submitted" : "missing",
+ finalAction: requestFindings.some((item) => item.severity === "critical" || item.severity === "high")
+ ? "hold-decision"
+ : appeal.decision ?? denial.outcome ?? "finalize-denial",
+ receiptState: auditReceipt.userFacingNotice ? "ready" : "missing",
+ findingCodes: requestFindings.map((item) => item.code)
+ });
+}
+
+function inspectDenial(requestId, path, denial, findings) {
+ if (!denial.reasonCode) {
+ findings.push(
+ finding(
+ "DENIAL_REASON_CODE_MISSING",
+ "high",
+ `${requestId} has no denial reason code.`,
+ "Researchers need a concrete denial basis before they can appeal or remediate access gaps.",
+ `${path}.denial.reasonCode`,
+ "Add a stable denial reason code such as TRAINING_EXPIRED, DUA_MISSING, IRB_SCOPE_MISMATCH, or OBJECT_SCOPE_TOO_BROAD.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+
+ if (!denial.rationale || String(denial.rationale).trim().length < DEFAULT_POLICY.minRationaleCharacters) {
+ findings.push(
+ finding(
+ "DENIAL_RATIONALE_TOO_THIN",
+ "high",
+ `${requestId} has an insufficient denial rationale.`,
+ "The user-facing decision needs enough context to be fair, appealable, and auditable.",
+ `${path}.denial.rationale`,
+ "Document the missing requirement, affected object scope, and remediation path.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+
+ if (denial.privateNotes && denial.redactedForRequester !== true) {
+ findings.push(
+ finding(
+ "PRIVATE_DENIAL_NOTE_EXPOSED",
+ "critical",
+ `${requestId} has private denial notes without requester-safe redaction.`,
+ "Denied-access notices must not leak hidden reviewer comments, protected collaborator details, or institutional security notes.",
+ `${path}.denial.redactedForRequester`,
+ "Redact private notes and expose only a requester-safe reason code plus remediation checklist.",
+ "privacy reviewer",
+ requestId
+ )
+ );
+ }
+}
+
+function inspectAppeal(requestId, path, denial, appeal, project, policy, findings) {
+ if (!appeal.submittedAt) {
+ findings.push(
+ finding(
+ "APPEAL_PACKET_MISSING",
+ "warning",
+ `${requestId} has no appeal packet.`,
+ "The system can finalize a denial, but the audit packet should still record whether an appeal was offered.",
+ `${path}.appeal`,
+ "Record appeal deadline, appeal status, and evidence checklist even if the researcher does not appeal.",
+ "workspace admin",
+ requestId
+ )
+ );
+ return;
+ }
+
+ if (denial.deniedAt && daysBetween(denial.deniedAt, appeal.submittedAt) > policy.appealWindowDays) {
+ findings.push(
+ finding(
+ "APPEAL_WINDOW_EXPIRED",
+ "high",
+ `${requestId} appeal was submitted after the ${policy.appealWindowDays}-day appeal window.`,
+ "Late appeals need explicit exception approval before a denial decision is changed.",
+ `${path}.appeal.submittedAt`,
+ "Route to institution-admin exception review or keep the denial final with a clear late-appeal receipt.",
+ "institution admin",
+ requestId
+ )
+ );
+ }
+
+ if (!appeal.reviewerId) {
+ findings.push(
+ finding(
+ "APPEAL_REVIEWER_MISSING",
+ "high",
+ `${requestId} appeal has no reviewer id.`,
+ "Final appeal outcomes must identify the accountable reviewer.",
+ `${path}.appeal.reviewerId`,
+ "Assign an independent reviewer before finalizing the appeal.",
+ "workspace admin",
+ requestId
+ )
+ );
+ } else if (appeal.reviewerId === denial.deniedBy) {
+ findings.push(
+ finding(
+ "APPEAL_REVIEWER_NOT_INDEPENDENT",
+ "high",
+ `${requestId} appeal is reviewed by the same actor who denied access.`,
+ "Appeals require independence from the original denial decision.",
+ `${path}.appeal.reviewerId`,
+ "Assign an independent data steward, institution admin, or ethics reviewer.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+
+ if (!policy.independentReviewerRoles.includes(appeal.reviewerRole)) {
+ findings.push(
+ finding(
+ "APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE",
+ "high",
+ `${requestId} appeal reviewer role ${appeal.reviewerRole ?? "unknown"} is not eligible.`,
+ `Eligible appeal reviewer roles: ${policy.independentReviewerRoles.join(", ")}.`,
+ `${path}.appeal.reviewerRole`,
+ "Assign a reviewer with an eligible governance role or document a policy exception.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+
+ if (appeal.privateNotes && appeal.redactedForRequester !== true) {
+ findings.push(
+ finding(
+ "PRIVATE_APPEAL_NOTE_EXPOSED",
+ "critical",
+ `${requestId} appeal includes private notes without requester-safe redaction.`,
+ "Appeal responses must not reveal private collaborator notes or hidden institutional risk comments.",
+ `${path}.appeal.redactedForRequester`,
+ "Redact private notes and publish only requester-safe appeal rationale.",
+ "privacy reviewer",
+ requestId
+ )
+ );
+ }
+
+ if (appeal.decision === "approve" && project.classification === "restricted_human") {
+ const evidence = isPlainObject(appeal.evidence) ? appeal.evidence : {};
+ const missing = (policy.requiredEvidenceByClassification.restricted_human ?? []).filter((key) => evidence[key] !== true);
+ if (missing.length > 0) {
+ findings.push(
+ finding(
+ "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE",
+ "critical",
+ `${requestId} approves restricted human-data access while evidence is missing: ${missing.join(", ")}.`,
+ "Restricted access appeals cannot override training, DUA, or IRB prerequisites.",
+ `${path}.appeal.evidence`,
+ "Hold approval until all restricted-data prerequisites are present and current.",
+ "data steward",
+ requestId
+ )
+ );
+ }
+ }
+}
+
+function inspectEvidence(requestId, path, project, appeal, policy, findings) {
+ const classification = project.classification ?? "institutional_only";
+ const required = policy.requiredEvidenceByClassification[classification] ?? [];
+ const evidence = isPlainObject(appeal.evidence) ? appeal.evidence : {};
+
+ required.forEach((key) => {
+ if (evidence[key] !== true) {
+ findings.push(
+ finding(
+ "REQUIRED_APPEAL_EVIDENCE_MISSING",
+ "high",
+ `${requestId} is missing required evidence ${key} for ${classification} access.`,
+ "Appeal outcomes must prove the requester satisfied the project data-access prerequisites.",
+ `${path}.appeal.evidence.${key}`,
+ `Attach valid ${key} evidence or keep access denied with a remediation checklist.`,
+ "data steward",
+ requestId
+ )
+ );
+ }
+ });
+}
+
+function inspectObjectScope(requestId, path, objects, project, findings) {
+ if (objects.length === 0) {
+ findings.push(
+ finding(
+ "REQUESTED_OBJECT_SCOPE_MISSING",
+ "high",
+ `${requestId} has no object-level access scope.`,
+ "Project access decisions should state exactly which datasets, notebooks, documents, or comments were denied or appealed.",
+ `${path}.requestedObjects`,
+ "Attach object-level paths, ids, classifications, and requested permission levels.",
+ "workspace admin",
+ requestId
+ )
+ );
+ return;
+ }
+
+ const broadObjects = objects.filter((object) => object.scope === "all-project" || object.path === "*");
+ if (broadObjects.length > 0 && project.classification !== "public") {
+ findings.push(
+ finding(
+ "BROAD_RESTRICTED_SCOPE_REQUEST",
+ "high",
+ `${requestId} requests broad access to a non-public project.`,
+ "Restricted projects should appeal access at the narrowest object and permission scope that supports the research need.",
+ `${path}.requestedObjects`,
+ "Replace all-project scope with specific datasets, notebooks, manuscripts, or review threads and least-privilege permissions.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+}
+
+function inspectAuditReceipt(requestId, path, auditReceipt, denial, appeal, findings) {
+ if (auditReceipt.userFacingNotice !== true) {
+ findings.push(
+ finding(
+ "USER_FACING_RECEIPT_MISSING",
+ "high",
+ `${requestId} has no requester-facing denial/appeal receipt.`,
+ "Researchers need a stable notice showing the decision, appeal path, and remediation checklist.",
+ `${path}.auditReceipt.userFacingNotice`,
+ "Generate a requester-safe audit receipt with reason code, appeal deadline, outcome, and evidence checklist.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+
+ const requiredFields = ["reasonCode", "appealDeadline", "evidenceChecklist", "decisionTimestamp"];
+ const missingFields = requiredFields.filter((field) => !asArray(auditReceipt.fields).includes(field));
+ if (missingFields.length > 0) {
+ findings.push(
+ finding(
+ "AUDIT_RECEIPT_FIELDS_MISSING",
+ "warning",
+ `${requestId} audit receipt is missing fields: ${missingFields.join(", ")}.`,
+ "Complete receipts reduce support churn and make the appeal process auditable.",
+ `${path}.auditReceipt.fields`,
+ "Add the missing receipt fields before finalizing the denial or appeal decision.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+
+ if (denial.reasonCode && !asArray(auditReceipt.fields).includes("reasonCode")) {
+ findings.push(
+ finding(
+ "DENIAL_REASON_NOT_IN_RECEIPT",
+ "warning",
+ `${requestId} denial reason is not included in the user receipt.`,
+ "The requester needs the reason code to understand and remediate the denial.",
+ `${path}.auditReceipt.fields`,
+ "Include reasonCode in the user-facing receipt.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+
+ if (appeal.submittedAt && !asArray(auditReceipt.fields).includes("appealOutcome")) {
+ findings.push(
+ finding(
+ "APPEAL_OUTCOME_NOT_IN_RECEIPT",
+ "warning",
+ `${requestId} appeal outcome is not included in the user receipt.`,
+ "The requester-facing receipt should close the loop after an appeal is reviewed.",
+ `${path}.auditReceipt.fields`,
+ "Include appealOutcome and reviewerRole in the final appeal receipt.",
+ "workspace admin",
+ requestId
+ )
+ );
+ }
+}
+
+function summarize(status, findings, appealDecisions) {
+ const blockers = findings.filter((item) => item.severity === "critical" || item.severity === "high").length;
+ if (status === "READY") {
+ return `All ${appealDecisions.length} denied-access appeal packet(s) are ready for requester-safe finalization.`;
+ }
+ if (status === "HOLD") {
+ return `Hold final access decisions: ${blockers} critical or high denial/appeal governance blocker(s) need remediation.`;
+ }
+ return `Review needed: ${findings.length} warning-level denial/appeal evidence gap(s) should be completed.`;
+}
+
+function finding(code, severity, message, evidence, path, remediation, owner, requestId = null) {
+ return {
+ code,
+ severity,
+ message,
+ evidence,
+ path,
+ remediation,
+ owner,
+ requestId
+ };
+}
+
+function sortFindings(findings) {
+ return [...findings].sort((a, b) => {
+ const severityDelta = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
+ if (severityDelta !== 0) {
+ return severityDelta;
+ }
+ return a.code.localeCompare(b.code);
+ });
+}
+
+function determineStatus(findings) {
+ if (findings.some((item) => item.severity === "critical" || item.severity === "high")) {
+ return "HOLD";
+ }
+ if (findings.some((item) => item.severity === "warning")) {
+ return "REVIEW";
+ }
+ return "READY";
+}
+
+function countBySeverity(findings) {
+ return findings.reduce((counts, item) => {
+ counts[item.severity] = (counts[item.severity] ?? 0) + 1;
+ return counts;
+ }, {});
+}
+
+function daysBetween(start, end) {
+ const startMs = Date.parse(start);
+ const endMs = Date.parse(end);
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {
+ return 0;
+ }
+ return (endMs - startMs) / 86400000;
+}
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isPlainObject(value) {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+module.exports = {
+ DEFAULT_POLICY,
+ evaluateAccessDenialAppeals,
+ renderMarkdownReport,
+ renderSvgSummary
+};
diff --git a/project-access-denial-appeal-guard/make-demo-video.js b/project-access-denial-appeal-guard/make-demo-video.js
new file mode 100644
index 00000000..6d429401
--- /dev/null
+++ b/project-access-denial-appeal-guard/make-demo-video.js
@@ -0,0 +1,128 @@
+"use strict";
+
+const { execFileSync } = require("node:child_process");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const WIDTH = 960;
+const HEIGHT = 540;
+const FONT = {
+ A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
+ C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
+ D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
+ E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
+ G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
+ H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
+ I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
+ L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
+ N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
+ O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
+ P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
+ R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
+ S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
+ T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
+ V: ["10001", "10001", "10001", "10001", "01010", "01010", "00100"],
+ W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
+ Y: ["10001", "01010", "00100", "00100", "00100", "00100", "00100"]
+};
+
+const reportsDir = path.join(__dirname, "reports");
+const framesDir = path.join(reportsDir, "frames");
+fs.mkdirSync(framesDir, { recursive: true });
+
+for (const file of fs.readdirSync(framesDir)) {
+ fs.unlinkSync(path.join(framesDir, file));
+}
+
+const slides = [
+ { label: "DENIAL REASON", color: [22, 121, 76], fill: 0.72 },
+ { label: "APPEAL WINDOW", color: [161, 92, 0], fill: 0.82 },
+ { label: "REVIEWER CHECK", color: [22, 121, 76], fill: 0.7 },
+ { label: "PRIVATE HOLD", color: [161, 27, 50], fill: 0.92 }
+];
+
+let frameIndex = 0;
+for (const slide of slides) {
+ for (let i = 0; i < 8; i += 1) {
+ const progress = (i + 1) / 8;
+ const buffer = createFrame(slide, progress);
+ fs.writeFileSync(path.join(framesDir, `frame-${String(frameIndex).padStart(3, "0")}.ppm`), buffer);
+ frameIndex += 1;
+ }
+}
+
+const output = path.join(reportsDir, "demo.mp4");
+execFileSync(
+ "ffmpeg",
+ [
+ "-y",
+ "-framerate",
+ "8",
+ "-i",
+ path.join(framesDir, "frame-%03d.ppm"),
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ output
+ ],
+ { stdio: "ignore" }
+);
+
+const stats = fs.statSync(output);
+console.log(`Wrote ${output} (${stats.size} bytes)`);
+
+function createFrame(slide, progress) {
+ const pixels = Buffer.alloc(WIDTH * HEIGHT * 3);
+ fillRect(pixels, 0, 0, WIDTH, HEIGHT, [17, 24, 39]);
+ fillRect(pixels, 48, 48, 864, 444, [248, 250, 252]);
+ fillRect(pixels, 80, 192, 800, 86, [226, 232, 240]);
+ fillRect(pixels, 80, 192, Math.round(800 * slide.fill * progress), 86, slide.color);
+ fillRect(pixels, 80, 324, 220, 42, [226, 232, 240]);
+ fillRect(pixels, 332, 324, 220, 42, [226, 232, 240]);
+ fillRect(pixels, 584, 324, 220, 42, [226, 232, 240]);
+ fillRect(pixels, 80, 324, 150, 42, [22, 121, 76]);
+ fillRect(pixels, 332, 324, 160, 42, [161, 92, 0]);
+ fillRect(pixels, 584, 324, 170, 42, [161, 27, 50]);
+ drawText(pixels, "ACCESS APPEAL", 82, 104, 5, [17, 24, 39]);
+ drawText(pixels, slide.label, 108, 216, 7, [255, 255, 255]);
+ drawText(pixels, "USER SAFE RECEIPT", 82, 416, 4, [51, 65, 85]);
+ return Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`, "ascii"), pixels]);
+}
+
+function fillRect(pixels, x, y, width, height, color) {
+ const x2 = Math.min(WIDTH, x + width);
+ const y2 = Math.min(HEIGHT, y + height);
+ for (let row = Math.max(0, y); row < y2; row += 1) {
+ for (let col = Math.max(0, x); col < x2; col += 1) {
+ const offset = (row * WIDTH + col) * 3;
+ pixels[offset] = color[0];
+ pixels[offset + 1] = color[1];
+ pixels[offset + 2] = color[2];
+ }
+ }
+}
+
+function drawText(pixels, text, x, y, scale, color) {
+ let cursor = x;
+ for (const rawChar of text) {
+ const char = rawChar.toUpperCase();
+ if (char === " ") {
+ cursor += 4 * scale;
+ continue;
+ }
+ const glyph = FONT[char];
+ if (!glyph) {
+ cursor += 6 * scale;
+ continue;
+ }
+ glyph.forEach((row, rowIndex) => {
+ for (let colIndex = 0; colIndex < row.length; colIndex += 1) {
+ if (row[colIndex] === "1") {
+ fillRect(pixels, cursor + colIndex * scale, y + rowIndex * scale, scale, scale, color);
+ }
+ }
+ });
+ cursor += 6 * scale;
+ }
+}
diff --git a/project-access-denial-appeal-guard/package.json b/project-access-denial-appeal-guard/package.json
new file mode 100644
index 00000000..4f9e3ab4
--- /dev/null
+++ b/project-access-denial-appeal-guard/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "project-access-denial-appeal-guard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Synthetic project access denial and appeal guard for scientific user/project management workflows.",
+ "main": "index.js",
+ "scripts": {
+ "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js",
+ "test": "node test.js",
+ "demo": "node demo.js && node make-demo-video.js",
+ "verify-video": "ffprobe -v error -show_entries stream=codec_name,width,height,duration -of default=nokey=1:noprint_wrappers=1 reports/demo.mp4"
+ },
+ "keywords": [
+ "access-control",
+ "appeals",
+ "user-management",
+ "audit",
+ "synthetic"
+ ],
+ "license": "MIT"
+}
diff --git a/project-access-denial-appeal-guard/reports/clean-audit.json b/project-access-denial-appeal-guard/reports/clean-audit.json
new file mode 100644
index 00000000..45f7b9b0
--- /dev/null
+++ b/project-access-denial-appeal-guard/reports/clean-audit.json
@@ -0,0 +1,21 @@
+{
+ "generatedAt": "2026-06-01T10:51:17.051Z",
+ "status": "READY",
+ "summary": "All 1 denied-access appeal packet(s) are ready for requester-safe finalization.",
+ "findingCounts": {},
+ "findings": [],
+ "appealDecisions": [
+ {
+ "requestId": "AR-1001",
+ "requesterId": "researcher-42",
+ "projectId": "proj-human-cohort-7",
+ "classification": "restricted_human",
+ "appealState": "submitted",
+ "finalAction": "uphold-denial",
+ "receiptState": "ready",
+ "findingCodes": []
+ }
+ ],
+ "remediationActions": [],
+ "fingerprint": "31a620a33cd68018"
+}
diff --git a/project-access-denial-appeal-guard/reports/demo.mp4 b/project-access-denial-appeal-guard/reports/demo.mp4
new file mode 100644
index 00000000..39d8c227
Binary files /dev/null and b/project-access-denial-appeal-guard/reports/demo.mp4 differ
diff --git a/project-access-denial-appeal-guard/reports/manifest.json b/project-access-denial-appeal-guard/reports/manifest.json
new file mode 100644
index 00000000..10d55a6d
--- /dev/null
+++ b/project-access-denial-appeal-guard/reports/manifest.json
@@ -0,0 +1,14 @@
+{
+ "generatedAt": "2026-06-01T10:51:17.059Z",
+ "module": "project-access-denial-appeal-guard",
+ "cleanStatus": "READY",
+ "riskyStatus": "HOLD",
+ "riskyFindings": 14,
+ "artifacts": [
+ "clean-audit.json",
+ "risky-audit.json",
+ "risky-review.md",
+ "summary.svg",
+ "demo.mp4"
+ ]
+}
diff --git a/project-access-denial-appeal-guard/reports/risky-audit.json b/project-access-denial-appeal-guard/reports/risky-audit.json
new file mode 100644
index 00000000..47ddc6ca
--- /dev/null
+++ b/project-access-denial-appeal-guard/reports/risky-audit.json
@@ -0,0 +1,266 @@
+{
+ "generatedAt": "2026-06-01T10:51:17.052Z",
+ "status": "HOLD",
+ "summary": "Hold final access decisions: 12 critical or high denial/appeal governance blocker(s) need remediation.",
+ "findingCounts": {
+ "critical": 3,
+ "high": 9,
+ "warning": 2
+ },
+ "findings": [
+ {
+ "code": "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE",
+ "severity": "critical",
+ "message": "AR-2002 approves restricted human-data access while evidence is missing: duaActive, irbApproval.",
+ "evidence": "Restricted access appeals cannot override training, DUA, or IRB prerequisites.",
+ "path": "accessRequests[0].appeal.evidence",
+ "remediation": "Hold approval until all restricted-data prerequisites are present and current.",
+ "owner": "data steward",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "PRIVATE_APPEAL_NOTE_EXPOSED",
+ "severity": "critical",
+ "message": "AR-2002 appeal includes private notes without requester-safe redaction.",
+ "evidence": "Appeal responses must not reveal private collaborator notes or hidden institutional risk comments.",
+ "path": "accessRequests[0].appeal.redactedForRequester",
+ "remediation": "Redact private notes and publish only requester-safe appeal rationale.",
+ "owner": "privacy reviewer",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "PRIVATE_DENIAL_NOTE_EXPOSED",
+ "severity": "critical",
+ "message": "AR-2002 has private denial notes without requester-safe redaction.",
+ "evidence": "Denied-access notices must not leak hidden reviewer comments, protected collaborator details, or institutional security notes.",
+ "path": "accessRequests[0].denial.redactedForRequester",
+ "remediation": "Redact private notes and expose only a requester-safe reason code plus remediation checklist.",
+ "owner": "privacy reviewer",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "APPEAL_REVIEWER_NOT_INDEPENDENT",
+ "severity": "high",
+ "message": "AR-2002 appeal is reviewed by the same actor who denied access.",
+ "evidence": "Appeals require independence from the original denial decision.",
+ "path": "accessRequests[0].appeal.reviewerId",
+ "remediation": "Assign an independent data steward, institution admin, or ethics reviewer.",
+ "owner": "workspace admin",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE",
+ "severity": "high",
+ "message": "AR-2002 appeal reviewer role viewer is not eligible.",
+ "evidence": "Eligible appeal reviewer roles: data-steward, institution-admin, ethics-reviewer.",
+ "path": "accessRequests[0].appeal.reviewerRole",
+ "remediation": "Assign a reviewer with an eligible governance role or document a policy exception.",
+ "owner": "workspace admin",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "APPEAL_WINDOW_EXPIRED",
+ "severity": "high",
+ "message": "AR-2002 appeal was submitted after the 14-day appeal window.",
+ "evidence": "Late appeals need explicit exception approval before a denial decision is changed.",
+ "path": "accessRequests[0].appeal.submittedAt",
+ "remediation": "Route to institution-admin exception review or keep the denial final with a clear late-appeal receipt.",
+ "owner": "institution admin",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "BROAD_RESTRICTED_SCOPE_REQUEST",
+ "severity": "high",
+ "message": "AR-2002 requests broad access to a non-public project.",
+ "evidence": "Restricted projects should appeal access at the narrowest object and permission scope that supports the research need.",
+ "path": "accessRequests[0].requestedObjects",
+ "remediation": "Replace all-project scope with specific datasets, notebooks, manuscripts, or review threads and least-privilege permissions.",
+ "owner": "workspace admin",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "DENIAL_RATIONALE_TOO_THIN",
+ "severity": "high",
+ "message": "AR-2002 has an insufficient denial rationale.",
+ "evidence": "The user-facing decision needs enough context to be fair, appealable, and auditable.",
+ "path": "accessRequests[0].denial.rationale",
+ "remediation": "Document the missing requirement, affected object scope, and remediation path.",
+ "owner": "workspace admin",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "DENIAL_REASON_CODE_MISSING",
+ "severity": "high",
+ "message": "AR-2002 has no denial reason code.",
+ "evidence": "Researchers need a concrete denial basis before they can appeal or remediate access gaps.",
+ "path": "accessRequests[0].denial.reasonCode",
+ "remediation": "Add a stable denial reason code such as TRAINING_EXPIRED, DUA_MISSING, IRB_SCOPE_MISMATCH, or OBJECT_SCOPE_TOO_BROAD.",
+ "owner": "workspace admin",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "REQUIRED_APPEAL_EVIDENCE_MISSING",
+ "severity": "high",
+ "message": "AR-2002 is missing required evidence duaActive for restricted_human access.",
+ "evidence": "Appeal outcomes must prove the requester satisfied the project data-access prerequisites.",
+ "path": "accessRequests[0].appeal.evidence.duaActive",
+ "remediation": "Attach valid duaActive evidence or keep access denied with a remediation checklist.",
+ "owner": "data steward",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "REQUIRED_APPEAL_EVIDENCE_MISSING",
+ "severity": "high",
+ "message": "AR-2002 is missing required evidence irbApproval for restricted_human access.",
+ "evidence": "Appeal outcomes must prove the requester satisfied the project data-access prerequisites.",
+ "path": "accessRequests[0].appeal.evidence.irbApproval",
+ "remediation": "Attach valid irbApproval evidence or keep access denied with a remediation checklist.",
+ "owner": "data steward",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "USER_FACING_RECEIPT_MISSING",
+ "severity": "high",
+ "message": "AR-2002 has no requester-facing denial/appeal receipt.",
+ "evidence": "Researchers need a stable notice showing the decision, appeal path, and remediation checklist.",
+ "path": "accessRequests[0].auditReceipt.userFacingNotice",
+ "remediation": "Generate a requester-safe audit receipt with reason code, appeal deadline, outcome, and evidence checklist.",
+ "owner": "workspace admin",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "APPEAL_OUTCOME_NOT_IN_RECEIPT",
+ "severity": "warning",
+ "message": "AR-2002 appeal outcome is not included in the user receipt.",
+ "evidence": "The requester-facing receipt should close the loop after an appeal is reviewed.",
+ "path": "accessRequests[0].auditReceipt.fields",
+ "remediation": "Include appealOutcome and reviewerRole in the final appeal receipt.",
+ "owner": "workspace admin",
+ "requestId": "AR-2002"
+ },
+ {
+ "code": "AUDIT_RECEIPT_FIELDS_MISSING",
+ "severity": "warning",
+ "message": "AR-2002 audit receipt is missing fields: reasonCode, appealDeadline, evidenceChecklist.",
+ "evidence": "Complete receipts reduce support churn and make the appeal process auditable.",
+ "path": "accessRequests[0].auditReceipt.fields",
+ "remediation": "Add the missing receipt fields before finalizing the denial or appeal decision.",
+ "owner": "workspace admin",
+ "requestId": "AR-2002"
+ }
+ ],
+ "appealDecisions": [
+ {
+ "requestId": "AR-2002",
+ "requesterId": "researcher-99",
+ "projectId": "proj-human-cohort-9",
+ "classification": "restricted_human",
+ "appealState": "submitted",
+ "finalAction": "hold-decision",
+ "receiptState": "missing",
+ "findingCodes": [
+ "DENIAL_REASON_CODE_MISSING",
+ "DENIAL_RATIONALE_TOO_THIN",
+ "PRIVATE_DENIAL_NOTE_EXPOSED",
+ "APPEAL_WINDOW_EXPIRED",
+ "APPEAL_REVIEWER_NOT_INDEPENDENT",
+ "APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE",
+ "PRIVATE_APPEAL_NOTE_EXPOSED",
+ "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE",
+ "REQUIRED_APPEAL_EVIDENCE_MISSING",
+ "REQUIRED_APPEAL_EVIDENCE_MISSING",
+ "BROAD_RESTRICTED_SCOPE_REQUEST",
+ "USER_FACING_RECEIPT_MISSING",
+ "AUDIT_RECEIPT_FIELDS_MISSING",
+ "APPEAL_OUTCOME_NOT_IN_RECEIPT"
+ ]
+ }
+ ],
+ "remediationActions": [
+ {
+ "code": "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE",
+ "requestId": "AR-2002",
+ "owner": "data steward",
+ "action": "Hold approval until all restricted-data prerequisites are present and current."
+ },
+ {
+ "code": "PRIVATE_APPEAL_NOTE_EXPOSED",
+ "requestId": "AR-2002",
+ "owner": "privacy reviewer",
+ "action": "Redact private notes and publish only requester-safe appeal rationale."
+ },
+ {
+ "code": "PRIVATE_DENIAL_NOTE_EXPOSED",
+ "requestId": "AR-2002",
+ "owner": "privacy reviewer",
+ "action": "Redact private notes and expose only a requester-safe reason code plus remediation checklist."
+ },
+ {
+ "code": "APPEAL_REVIEWER_NOT_INDEPENDENT",
+ "requestId": "AR-2002",
+ "owner": "workspace admin",
+ "action": "Assign an independent data steward, institution admin, or ethics reviewer."
+ },
+ {
+ "code": "APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE",
+ "requestId": "AR-2002",
+ "owner": "workspace admin",
+ "action": "Assign a reviewer with an eligible governance role or document a policy exception."
+ },
+ {
+ "code": "APPEAL_WINDOW_EXPIRED",
+ "requestId": "AR-2002",
+ "owner": "institution admin",
+ "action": "Route to institution-admin exception review or keep the denial final with a clear late-appeal receipt."
+ },
+ {
+ "code": "BROAD_RESTRICTED_SCOPE_REQUEST",
+ "requestId": "AR-2002",
+ "owner": "workspace admin",
+ "action": "Replace all-project scope with specific datasets, notebooks, manuscripts, or review threads and least-privilege permissions."
+ },
+ {
+ "code": "DENIAL_RATIONALE_TOO_THIN",
+ "requestId": "AR-2002",
+ "owner": "workspace admin",
+ "action": "Document the missing requirement, affected object scope, and remediation path."
+ },
+ {
+ "code": "DENIAL_REASON_CODE_MISSING",
+ "requestId": "AR-2002",
+ "owner": "workspace admin",
+ "action": "Add a stable denial reason code such as TRAINING_EXPIRED, DUA_MISSING, IRB_SCOPE_MISMATCH, or OBJECT_SCOPE_TOO_BROAD."
+ },
+ {
+ "code": "REQUIRED_APPEAL_EVIDENCE_MISSING",
+ "requestId": "AR-2002",
+ "owner": "data steward",
+ "action": "Attach valid duaActive evidence or keep access denied with a remediation checklist."
+ },
+ {
+ "code": "REQUIRED_APPEAL_EVIDENCE_MISSING",
+ "requestId": "AR-2002",
+ "owner": "data steward",
+ "action": "Attach valid irbApproval evidence or keep access denied with a remediation checklist."
+ },
+ {
+ "code": "USER_FACING_RECEIPT_MISSING",
+ "requestId": "AR-2002",
+ "owner": "workspace admin",
+ "action": "Generate a requester-safe audit receipt with reason code, appeal deadline, outcome, and evidence checklist."
+ },
+ {
+ "code": "APPEAL_OUTCOME_NOT_IN_RECEIPT",
+ "requestId": "AR-2002",
+ "owner": "workspace admin",
+ "action": "Include appealOutcome and reviewerRole in the final appeal receipt."
+ },
+ {
+ "code": "AUDIT_RECEIPT_FIELDS_MISSING",
+ "requestId": "AR-2002",
+ "owner": "workspace admin",
+ "action": "Add the missing receipt fields before finalizing the denial or appeal decision."
+ }
+ ],
+ "fingerprint": "fde2d21c50c6dd89"
+}
diff --git a/project-access-denial-appeal-guard/reports/risky-review.md b/project-access-denial-appeal-guard/reports/risky-review.md
new file mode 100644
index 00000000..1d3dc39c
--- /dev/null
+++ b/project-access-denial-appeal-guard/reports/risky-review.md
@@ -0,0 +1,60 @@
+# Project Access Denial and Appeal Guard
+
+Packet: risky-access-appeals
+Status: HOLD
+Fingerprint: fde2d21c50c6dd89
+
+## Summary
+
+Hold final access decisions: 12 critical or high denial/appeal governance blocker(s) need remediation.
+
+## Appeal Decisions
+
+- AR-2002: hold-decision; 14 finding(s)
+ - Requester: researcher-99; project proj-human-cohort-9
+ - Appeal state: submitted; receipt missing
+
+## Findings
+
+- CRITICAL APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE: AR-2002 approves restricted human-data access while evidence is missing: duaActive, irbApproval.
+ - Evidence: Restricted access appeals cannot override training, DUA, or IRB prerequisites.
+ - Remediation: Hold approval until all restricted-data prerequisites are present and current.
+- CRITICAL PRIVATE_APPEAL_NOTE_EXPOSED: AR-2002 appeal includes private notes without requester-safe redaction.
+ - Evidence: Appeal responses must not reveal private collaborator notes or hidden institutional risk comments.
+ - Remediation: Redact private notes and publish only requester-safe appeal rationale.
+- CRITICAL PRIVATE_DENIAL_NOTE_EXPOSED: AR-2002 has private denial notes without requester-safe redaction.
+ - Evidence: Denied-access notices must not leak hidden reviewer comments, protected collaborator details, or institutional security notes.
+ - Remediation: Redact private notes and expose only a requester-safe reason code plus remediation checklist.
+- HIGH APPEAL_REVIEWER_NOT_INDEPENDENT: AR-2002 appeal is reviewed by the same actor who denied access.
+ - Evidence: Appeals require independence from the original denial decision.
+ - Remediation: Assign an independent data steward, institution admin, or ethics reviewer.
+- HIGH APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE: AR-2002 appeal reviewer role viewer is not eligible.
+ - Evidence: Eligible appeal reviewer roles: data-steward, institution-admin, ethics-reviewer.
+ - Remediation: Assign a reviewer with an eligible governance role or document a policy exception.
+- HIGH APPEAL_WINDOW_EXPIRED: AR-2002 appeal was submitted after the 14-day appeal window.
+ - Evidence: Late appeals need explicit exception approval before a denial decision is changed.
+ - Remediation: Route to institution-admin exception review or keep the denial final with a clear late-appeal receipt.
+- HIGH BROAD_RESTRICTED_SCOPE_REQUEST: AR-2002 requests broad access to a non-public project.
+ - Evidence: Restricted projects should appeal access at the narrowest object and permission scope that supports the research need.
+ - Remediation: Replace all-project scope with specific datasets, notebooks, manuscripts, or review threads and least-privilege permissions.
+- HIGH DENIAL_RATIONALE_TOO_THIN: AR-2002 has an insufficient denial rationale.
+ - Evidence: The user-facing decision needs enough context to be fair, appealable, and auditable.
+ - Remediation: Document the missing requirement, affected object scope, and remediation path.
+- HIGH DENIAL_REASON_CODE_MISSING: AR-2002 has no denial reason code.
+ - Evidence: Researchers need a concrete denial basis before they can appeal or remediate access gaps.
+ - Remediation: Add a stable denial reason code such as TRAINING_EXPIRED, DUA_MISSING, IRB_SCOPE_MISMATCH, or OBJECT_SCOPE_TOO_BROAD.
+- HIGH REQUIRED_APPEAL_EVIDENCE_MISSING: AR-2002 is missing required evidence duaActive for restricted_human access.
+ - Evidence: Appeal outcomes must prove the requester satisfied the project data-access prerequisites.
+ - Remediation: Attach valid duaActive evidence or keep access denied with a remediation checklist.
+- HIGH REQUIRED_APPEAL_EVIDENCE_MISSING: AR-2002 is missing required evidence irbApproval for restricted_human access.
+ - Evidence: Appeal outcomes must prove the requester satisfied the project data-access prerequisites.
+ - Remediation: Attach valid irbApproval evidence or keep access denied with a remediation checklist.
+- HIGH USER_FACING_RECEIPT_MISSING: AR-2002 has no requester-facing denial/appeal receipt.
+ - Evidence: Researchers need a stable notice showing the decision, appeal path, and remediation checklist.
+ - Remediation: Generate a requester-safe audit receipt with reason code, appeal deadline, outcome, and evidence checklist.
+- WARNING APPEAL_OUTCOME_NOT_IN_RECEIPT: AR-2002 appeal outcome is not included in the user receipt.
+ - Evidence: The requester-facing receipt should close the loop after an appeal is reviewed.
+ - Remediation: Include appealOutcome and reviewerRole in the final appeal receipt.
+- WARNING AUDIT_RECEIPT_FIELDS_MISSING: AR-2002 audit receipt is missing fields: reasonCode, appealDeadline, evidenceChecklist.
+ - Evidence: Complete receipts reduce support churn and make the appeal process auditable.
+ - Remediation: Add the missing receipt fields before finalizing the denial or appeal decision.
diff --git a/project-access-denial-appeal-guard/reports/summary.svg b/project-access-denial-appeal-guard/reports/summary.svg
new file mode 100644
index 00000000..82e4286e
--- /dev/null
+++ b/project-access-denial-appeal-guard/reports/summary.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/project-access-denial-appeal-guard/sample-data.js b/project-access-denial-appeal-guard/sample-data.js
new file mode 100644
index 00000000..53793de8
--- /dev/null
+++ b/project-access-denial-appeal-guard/sample-data.js
@@ -0,0 +1,121 @@
+"use strict";
+
+const cleanPacket = {
+ id: "clean-access-appeals",
+ accessRequests: [
+ {
+ id: "AR-1001",
+ requester: {
+ id: "researcher-42",
+ institution: "Northeast Medical Lab",
+ orcidVerified: true,
+ mfaFresh: true
+ },
+ project: {
+ id: "proj-human-cohort-7",
+ classification: "restricted_human",
+ visibility: "institutional-only"
+ },
+ requestedObjects: [
+ {
+ id: "dataset-redacted-v2",
+ path: "data/redacted-cohort-v2.csv",
+ permission: "download",
+ classification: "restricted_human"
+ },
+ {
+ id: "notebook-analysis",
+ path: "notebooks/reproduce.ipynb",
+ permission: "read",
+ classification: "institutional_only"
+ }
+ ],
+ denial: {
+ deniedAt: "2026-05-20T12:00:00.000Z",
+ deniedBy: "steward-a",
+ reasonCode: "IRB_SCOPE_REVIEW_REQUIRED",
+ rationale: "The requested cohort export needs an IRB scope check before download access can be granted.",
+ outcome: "deny-with-remediation",
+ privateNotes: "Internal reviewer note: contact PI before export.",
+ redactedForRequester: true
+ },
+ appeal: {
+ submittedAt: "2026-05-24T09:00:00.000Z",
+ reviewerId: "ethics-reviewer-b",
+ reviewerRole: "ethics-reviewer",
+ decision: "uphold-denial",
+ rationale: "Appeal reviewed. Access remains denied until the amended IRB letter is attached.",
+ redactedForRequester: true,
+ evidence: {
+ trainingValid: true,
+ duaActive: true,
+ irbApproval: true
+ }
+ },
+ auditReceipt: {
+ userFacingNotice: true,
+ fields: ["reasonCode", "appealDeadline", "evidenceChecklist", "decisionTimestamp", "appealOutcome", "reviewerRole"]
+ }
+ }
+ ]
+};
+
+const riskyPacket = {
+ id: "risky-access-appeals",
+ accessRequests: [
+ {
+ id: "AR-2002",
+ requester: {
+ id: "researcher-99",
+ institution: "External Partner Lab",
+ orcidVerified: false,
+ mfaFresh: false
+ },
+ project: {
+ id: "proj-human-cohort-9",
+ classification: "restricted_human",
+ visibility: "invitation-only"
+ },
+ requestedObjects: [
+ {
+ id: "all-project",
+ path: "*",
+ scope: "all-project",
+ permission: "download",
+ classification: "restricted_human"
+ }
+ ],
+ denial: {
+ deniedAt: "2026-05-01T12:00:00.000Z",
+ deniedBy: "steward-a",
+ reasonCode: "",
+ rationale: "No.",
+ privateNotes: "Private: reviewer suspects a competing project conflict.",
+ redactedForRequester: false
+ },
+ appeal: {
+ submittedAt: "2026-05-25T09:00:00.000Z",
+ reviewerId: "steward-a",
+ reviewerRole: "viewer",
+ decision: "approve",
+ rationale: "Approved after appeal.",
+ privateNotes: "Internal note: sponsor did not respond.",
+ redactedForRequester: false,
+ evidence: {
+ trainingValid: true,
+ duaActive: false,
+ irbApproval: false
+ }
+ },
+ auditReceipt: {
+ userFacingNotice: false,
+ fields: ["decisionTimestamp"]
+ }
+ }
+ ]
+};
+
+module.exports = {
+ cleanPacket,
+ riskyPacket
+};
diff --git a/project-access-denial-appeal-guard/test.js b/project-access-denial-appeal-guard/test.js
new file mode 100644
index 00000000..3de48def
--- /dev/null
+++ b/project-access-denial-appeal-guard/test.js
@@ -0,0 +1,36 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const {
+ evaluateAccessDenialAppeals,
+ renderMarkdownReport,
+ renderSvgSummary
+} = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const clean = evaluateAccessDenialAppeals(cleanPacket, { now: "2026-06-01T11:00:00.000Z" });
+assert.equal(clean.status, "READY");
+assert.equal(clean.findings.length, 0);
+assert.equal(clean.appealDecisions.length, 1);
+assert.equal(clean.appealDecisions[0].finalAction, "uphold-denial");
+
+const risky = evaluateAccessDenialAppeals(riskyPacket, { now: "2026-06-01T11:00:00.000Z" });
+assert.equal(risky.status, "HOLD");
+assert.ok(risky.findings.some((finding) => finding.code === "PRIVATE_DENIAL_NOTE_EXPOSED"));
+assert.ok(risky.findings.some((finding) => finding.code === "PRIVATE_APPEAL_NOTE_EXPOSED"));
+assert.ok(risky.findings.some((finding) => finding.code === "APPEAL_WINDOW_EXPIRED"));
+assert.ok(risky.findings.some((finding) => finding.code === "APPEAL_REVIEWER_NOT_INDEPENDENT"));
+assert.ok(risky.findings.some((finding) => finding.code === "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE"));
+assert.equal(risky.appealDecisions[0].finalAction, "hold-decision");
+
+const markdown = renderMarkdownReport(risky, riskyPacket);
+assert.match(markdown, /Project Access Denial and Appeal Guard/);
+assert.match(markdown, /PRIVATE_DENIAL_NOTE_EXPOSED/);
+
+const svg = renderSvgSummary(risky);
+assert.match(svg, /