diff --git a/project-break-glass-access-guard/README.md b/project-break-glass-access-guard/README.md
new file mode 100644
index 00000000..829479f3
--- /dev/null
+++ b/project-break-glass-access-guard/README.md
@@ -0,0 +1,34 @@
+# Project Break-Glass Access Guard
+
+This module provides a focused User & Project Management slice for SCIBASE issue
+#11. It evaluates emergency break-glass access requests for locked or
+time-sensitive scientific workspaces before temporary owner/admin access is
+granted.
+
+## What It Covers
+
+- Owner/admin emergency access requests for locked project spaces.
+- Reason-code and reason-detail requirements.
+- Fresh MFA gates for sensitive temporary access.
+- Least-privilege object scopes with short expiry windows.
+- Protected data-class review holds.
+- Sponsor approval or after-hours justification.
+- Immutable audit receipts and mandatory post-access review.
+
+## Run
+
+```bash
+npm run check
+npm test
+npm run demo
+```
+
+Generated artifacts are written to `reports/`:
+
+- `summary.json`
+- `reviewer-packet.md`
+- `summary.svg`
+- `demo.mp4`
+
+All data is synthetic and does not touch real identity providers, user accounts,
+project permissions, or production workspaces.
diff --git a/project-break-glass-access-guard/acceptance-notes.md b/project-break-glass-access-guard/acceptance-notes.md
new file mode 100644
index 00000000..166f3d6c
--- /dev/null
+++ b/project-break-glass-access-guard/acceptance-notes.md
@@ -0,0 +1,19 @@
+# Acceptance Notes
+
+The guard classifies requests as:
+
+- `approved`: temporary scoped access can be granted.
+- `approve_with_review`: after-hours access can proceed with mandatory owner review.
+- `steward_review`: protected or risky access requires steward approval first.
+- `denied`: request does not meet break-glass entry requirements.
+
+Validation commands:
+
+```bash
+npm run check
+npm test
+npm run demo
+ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height,pix_fmt -of default=noprint_wrappers=1 reports/demo.mp4
+git diff --check
+git diff --cached --check
+```
diff --git a/project-break-glass-access-guard/demo.js b/project-break-glass-access-guard/demo.js
new file mode 100644
index 00000000..0a00c996
--- /dev/null
+++ b/project-break-glass-access-guard/demo.js
@@ -0,0 +1,31 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const {
+ analyzeBreakGlassAccess,
+ renderMarkdownReport,
+ renderSvgSummary,
+} = require("./index");
+const { sampleBreakGlassPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const result = analyzeBreakGlassAccess(sampleBreakGlassPacket, {
+ asOf: "2026-05-22T12:00:00.000Z",
+});
+
+fs.writeFileSync(
+ path.join(reportsDir, "summary.json"),
+ `${JSON.stringify(result, null, 2)}\n`
+);
+fs.writeFileSync(
+ path.join(reportsDir, "reviewer-packet.md"),
+ renderMarkdownReport(result)
+);
+fs.writeFileSync(
+ path.join(reportsDir, "summary.svg"),
+ renderSvgSummary(result)
+);
+
+console.log("project break-glass access guard demo artifacts written");
+console.log(`audit digest: ${result.auditDigest}`);
diff --git a/project-break-glass-access-guard/index.js b/project-break-glass-access-guard/index.js
new file mode 100644
index 00000000..1845f858
--- /dev/null
+++ b/project-break-glass-access-guard/index.js
@@ -0,0 +1,363 @@
+const crypto = require("node:crypto");
+
+const APPROVED = "approved";
+const APPROVE_WITH_REVIEW = "approve_with_review";
+const STEWARD_REVIEW = "steward_review";
+const DENIED = "denied";
+
+const ALLOWED_ROLES = new Set(["Owner", "Admin"]);
+const ALLOWED_REASON_CODES = new Set([
+ "incident_response",
+ "submission_deadline",
+ "compliance_deadline",
+ "owner_unavailable",
+]);
+
+function requireFields(value, fields, label) {
+ for (const field of fields) {
+ if (value[field] === undefined || value[field] === null || value[field] === "") {
+ throw new Error(`missing required ${label} field: ${field}`);
+ }
+ }
+}
+
+function parseDate(value, label) {
+ if (!value) return null;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ throw new Error(`invalid date for ${label}`);
+ }
+ return date;
+}
+
+function minutesBetween(a, b) {
+ return Math.round((b.getTime() - a.getTime()) / 60000);
+}
+
+function stableStringify(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(",")}]`;
+ }
+
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
+ .join(",")}}`;
+ }
+
+ return JSON.stringify(value);
+}
+
+function createAuditDigest(result) {
+ const payload = {
+ asOf: result.asOf,
+ totals: result.totals,
+ decisions: result.decisions.map((decision) => ({
+ id: decision.id,
+ projectId: decision.projectId,
+ status: decision.status,
+ expiresInMinutes: decision.expiresInMinutes,
+ reasons: decision.reasons,
+ })),
+ };
+
+ return crypto.createHash("sha256").update(stableStringify(payload)).digest("hex");
+}
+
+function projectLookup(projects) {
+ const lookup = new Map();
+ for (const project of projects) {
+ requireFields(
+ project,
+ ["projectId", "owners", "admins", "protectedDataClasses", "lockedSections"],
+ "project"
+ );
+ lookup.set(project.projectId, project);
+ }
+ return lookup;
+}
+
+function hasProtectedDataRequest(request, project) {
+ return (request.requestedDataClasses || []).some((dataClass) =>
+ project.protectedDataClasses.includes(dataClass)
+ );
+}
+
+function hasLeastPrivilegeScope(request) {
+ const scopes = request.requestedScopes || [];
+ return scopes.length > 0 && scopes.every((scope) => /:(read|write)$/.test(scope));
+}
+
+function approvedByProjectOwner(request, project) {
+ const approvals = new Set(request.sponsorApprovals || []);
+ return project.owners.some((owner) => approvals.has(owner));
+}
+
+function evaluateRequest(request, context) {
+ requireFields(
+ request,
+ [
+ "id",
+ "projectId",
+ "requesterId",
+ "requesterRole",
+ "reasonCode",
+ "reasonDetail",
+ "requestedScopes",
+ "requestedDataClasses",
+ "requestedAt",
+ "expiresAt",
+ "mfaVerifiedAt",
+ ],
+ "break-glass request"
+ );
+
+ const project = context.projects.get(request.projectId);
+ const requestedAt = parseDate(request.requestedAt, `requestedAt for ${request.id}`);
+ const expiresAt = parseDate(request.expiresAt, `expiresAt for ${request.id}`);
+ const mfaVerifiedAt = parseDate(request.mfaVerifiedAt, `mfaVerifiedAt for ${request.id}`);
+ const postReviewDueAt = parseDate(
+ request.postReviewDueAt,
+ `postReviewDueAt for ${request.id}`
+ );
+ const reasons = [];
+ const actions = [];
+ const base = {
+ id: request.id,
+ projectId: request.projectId,
+ requesterId: request.requesterId,
+ requesterRole: request.requesterRole,
+ reasonCode: request.reasonCode,
+ requestedScopes: request.requestedScopes,
+ requestedDataClasses: request.requestedDataClasses,
+ expiresInMinutes: expiresAt && requestedAt ? minutesBetween(requestedAt, expiresAt) : 0,
+ status: DENIED,
+ reasons,
+ actions,
+ };
+
+ if (!project) {
+ return {
+ ...base,
+ status: DENIED,
+ reasons: ["project is not registered"],
+ actions: ["Reject break-glass request"],
+ };
+ }
+
+ if (!ALLOWED_ROLES.has(request.requesterRole)) {
+ reasons.push("requester role is not allowed to initiate break-glass access");
+ }
+
+ if (!ALLOWED_REASON_CODES.has(request.reasonCode)) {
+ reasons.push("reason code is not recognized for emergency access");
+ }
+
+ if (request.reasonDetail.trim().length < 24) {
+ reasons.push("reason detail is too short for audit review");
+ }
+
+ if (!hasLeastPrivilegeScope(request)) {
+ reasons.push("requested scopes are missing or not least-privilege read/write scopes");
+ }
+
+ const mfaAgeMinutes = minutesBetween(mfaVerifiedAt, requestedAt);
+ if (mfaAgeMinutes < 0 || mfaAgeMinutes > 30) {
+ reasons.push("MFA verification is stale for emergency access");
+ }
+
+ if (base.expiresInMinutes <= 0 || base.expiresInMinutes > 120) {
+ reasons.push("break-glass expiry must be positive and no longer than 120 minutes");
+ }
+
+ if (!Array.isArray(request.auditReceiptIds) || request.auditReceiptIds.length === 0) {
+ reasons.push("immutable audit receipt is missing");
+ }
+
+ if (!postReviewDueAt || minutesBetween(requestedAt, postReviewDueAt) > 24 * 60) {
+ reasons.push("post-access review is missing or due more than 24 hours later");
+ }
+
+ const protectedData = hasProtectedDataRequest(request, project);
+ const ownerApproved = approvedByProjectOwner(request, project);
+ const requesterIsOwner = project.owners.includes(request.requesterId);
+
+ if (protectedData) {
+ reasons.push("request touches protected project data classes");
+ }
+
+ if (!ownerApproved && !requesterIsOwner && !request.afterHoursJustification) {
+ reasons.push("missing owner sponsor approval or after-hours justification");
+ }
+
+ if (reasons.some((reason) => /not allowed|stale|missing|no longer|protected/.test(reason))) {
+ return {
+ ...base,
+ status: protectedData ? STEWARD_REVIEW : DENIED,
+ reasons,
+ actions: [
+ protectedData
+ ? "Route to steward review before any access is granted"
+ : "Deny request and preserve audit denial receipt",
+ "Notify project owner and institutional admin",
+ "Require corrected scope, MFA, expiry, evidence, or sponsorship",
+ ],
+ };
+ }
+
+ if (!ownerApproved && !requesterIsOwner && request.afterHoursJustification) {
+ return {
+ ...base,
+ status: APPROVE_WITH_REVIEW,
+ reasons: ["after-hours emergency access can proceed with mandatory owner review"],
+ actions: [
+ "Grant temporary scoped access",
+ "Queue owner review within 24 hours",
+ "Auto-expire access and attach audit receipt",
+ ],
+ };
+ }
+
+ return {
+ ...base,
+ status: APPROVED,
+ reasons: ["break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates"],
+ actions: [
+ "Grant temporary scoped access",
+ "Auto-expire access at requested expiry",
+ "Preserve immutable audit receipt and post-access review task",
+ ],
+ };
+}
+
+function analyzeBreakGlassAccess(packet, options = {}) {
+ requireFields(packet, ["projects", "requests"], "break-glass packet");
+ if (!Array.isArray(packet.projects) || !Array.isArray(packet.requests)) {
+ throw new Error("projects and requests must be arrays");
+ }
+
+ const asOf = parseDate(options.asOf || packet.asOf || new Date().toISOString(), "asOf");
+ const context = {
+ projects: projectLookup(packet.projects),
+ };
+ const decisions = packet.requests.map((request) => evaluateRequest(request, context));
+ const totals = decisions.reduce(
+ (acc, decision) => {
+ acc.totalRequests += 1;
+ acc.byStatus[decision.status] = (acc.byStatus[decision.status] || 0) + 1;
+ if (decision.status === APPROVED || decision.status === APPROVE_WITH_REVIEW) {
+ acc.temporaryAccessGrants += 1;
+ }
+ if (decision.status === STEWARD_REVIEW || decision.status === DENIED) {
+ acc.blockedOrReviewRequests += 1;
+ }
+ return acc;
+ },
+ {
+ totalRequests: 0,
+ temporaryAccessGrants: 0,
+ blockedOrReviewRequests: 0,
+ byStatus: {},
+ }
+ );
+ const result = {
+ asOf: asOf.toISOString(),
+ totals,
+ decisions,
+ };
+
+ return {
+ ...result,
+ auditDigest: createAuditDigest(result),
+ };
+}
+
+function renderMarkdownReport(result) {
+ const lines = [
+ "# Project Break-Glass Access Guard",
+ "",
+ `As of: ${result.asOf}`,
+ `Audit digest: \`${result.auditDigest}\``,
+ "",
+ "## Totals",
+ "",
+ `- Requests evaluated: ${result.totals.totalRequests}`,
+ `- Temporary access grants: ${result.totals.temporaryAccessGrants}`,
+ `- Blocked or review requests: ${result.totals.blockedOrReviewRequests}`,
+ ];
+
+ for (const [status, count] of Object.entries(result.totals.byStatus).sort()) {
+ lines.push(`- ${status}: ${count}`);
+ }
+
+ lines.push("", "## Request Decisions", "");
+ for (const decision of result.decisions) {
+ lines.push(
+ `### ${decision.id}`,
+ "",
+ `- Project: ${decision.projectId}`,
+ `- Requester: ${decision.requesterId} (${decision.requesterRole})`,
+ `- Status: ${decision.status}`,
+ `- Expires in minutes: ${decision.expiresInMinutes}`,
+ `- Scopes: ${decision.requestedScopes.join(", ")}`,
+ `- Reasons: ${decision.reasons.join("; ")}`,
+ `- Actions: ${decision.actions.join("; ")}`,
+ ""
+ );
+ }
+
+ return `${lines.join("\n").trimEnd()}\n`;
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function renderSvgSummary(result) {
+ const rows = Object.entries(result.totals.byStatus)
+ .sort()
+ .map(([status, count], index) => {
+ const y = 154 + index * 46;
+ const width = Math.max(44, count * 92);
+ return `${escapeXml(status)}
+
+ ${count}`;
+ })
+ .join("\n ");
+
+ return `
+`;
+}
+
+module.exports = {
+ APPROVED,
+ APPROVE_WITH_REVIEW,
+ STEWARD_REVIEW,
+ DENIED,
+ analyzeBreakGlassAccess,
+ createAuditDigest,
+ renderMarkdownReport,
+ renderSvgSummary,
+};
diff --git a/project-break-glass-access-guard/package.json b/project-break-glass-access-guard/package.json
new file mode 100644
index 00000000..8557ce7f
--- /dev/null
+++ b/project-break-glass-access-guard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "project-break-glass-access-guard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Synthetic project break-glass access and post-access review guard for SCIBASE issue #11.",
+ "main": "index.js",
+ "scripts": {
+ "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check test.js",
+ "test": "node test.js",
+ "demo": "node demo.js"
+ },
+ "license": "MIT"
+}
diff --git a/project-break-glass-access-guard/reports/demo.mp4 b/project-break-glass-access-guard/reports/demo.mp4
new file mode 100644
index 00000000..7fa1b7e9
Binary files /dev/null and b/project-break-glass-access-guard/reports/demo.mp4 differ
diff --git a/project-break-glass-access-guard/reports/reviewer-packet.md b/project-break-glass-access-guard/reports/reviewer-packet.md
new file mode 100644
index 00000000..f864c6c8
--- /dev/null
+++ b/project-break-glass-access-guard/reports/reviewer-packet.md
@@ -0,0 +1,65 @@
+# Project Break-Glass Access Guard
+
+As of: 2026-05-22T12:00:00.000Z
+Audit digest: `e0e335c11209ce7c1812438f76265bb0adffcfbaee4cc16b489e5d2aebefad03`
+
+## Totals
+
+- Requests evaluated: 5
+- Temporary access grants: 3
+- Blocked or review requests: 2
+- approve_with_review: 1
+- approved: 2
+- steward_review: 2
+
+## Request Decisions
+
+### bg-001
+
+- Project: project-sepsis-lockdown
+- Requester: admin-noor (Admin)
+- Status: approve_with_review
+- Expires in minutes: 90
+- Scopes: manuscript/results:write, discussion/incident:write
+- Reasons: after-hours emergency access can proceed with mandatory owner review
+- Actions: Grant temporary scoped access; Queue owner review within 24 hours; Auto-expire access and attach audit receipt
+
+### bg-002
+
+- Project: project-sepsis-lockdown
+- Requester: viewer-eli (Viewer)
+- Status: steward_review
+- Expires in minutes: 1440
+- Scopes: dataset/raw:download
+- Reasons: requester role is not allowed to initiate break-glass access; reason code is not recognized for emergency access; requested scopes are missing or not least-privilege read/write scopes; MFA verification is stale for emergency access; break-glass expiry must be positive and no longer than 120 minutes; immutable audit receipt is missing; post-access review is missing or due more than 24 hours later; request touches protected project data classes; missing owner sponsor approval or after-hours justification
+- Actions: Route to steward review before any access is granted; Notify project owner and institutional admin; Require corrected scope, MFA, expiry, evidence, or sponsorship
+
+### bg-003
+
+- Project: project-climate-preprint
+- Requester: owner-mei (Owner)
+- Status: approved
+- Expires in minutes: 60
+- Scopes: preprint/submission:write
+- Reasons: break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates
+- Actions: Grant temporary scoped access; Auto-expire access at requested expiry; Preserve immutable audit receipt and post-access review task
+
+### bg-004
+
+- Project: project-climate-preprint
+- Requester: admin-ravi (Admin)
+- Status: steward_review
+- Expires in minutes: 480
+- Scopes: notebooks/final-run:write, results/figures:write
+- Reasons: break-glass expiry must be positive and no longer than 120 minutes; post-access review is missing or due more than 24 hours later; request touches protected project data classes
+- Actions: Route to steward review before any access is granted; Notify project owner and institutional admin; Require corrected scope, MFA, expiry, evidence, or sponsorship
+
+### bg-005
+
+- Project: project-climate-preprint
+- Requester: admin-ravi (Admin)
+- Status: approved
+- Expires in minutes: 45
+- Scopes: notebooks/final-run:write
+- Reasons: break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates
+- Actions: Grant temporary scoped access; Auto-expire access at requested expiry; Preserve immutable audit receipt and post-access review task
diff --git a/project-break-glass-access-guard/reports/summary.json b/project-break-glass-access-guard/reports/summary.json
new file mode 100644
index 00000000..511fb761
--- /dev/null
+++ b/project-break-glass-access-guard/reports/summary.json
@@ -0,0 +1,143 @@
+{
+ "asOf": "2026-05-22T12:00:00.000Z",
+ "totals": {
+ "totalRequests": 5,
+ "temporaryAccessGrants": 3,
+ "blockedOrReviewRequests": 2,
+ "byStatus": {
+ "approve_with_review": 1,
+ "steward_review": 2,
+ "approved": 2
+ }
+ },
+ "decisions": [
+ {
+ "id": "bg-001",
+ "projectId": "project-sepsis-lockdown",
+ "requesterId": "admin-noor",
+ "requesterRole": "Admin",
+ "reasonCode": "incident_response",
+ "requestedScopes": [
+ "manuscript/results:write",
+ "discussion/incident:write"
+ ],
+ "requestedDataClasses": [
+ "derived_results"
+ ],
+ "expiresInMinutes": 90,
+ "status": "approve_with_review",
+ "reasons": [
+ "after-hours emergency access can proceed with mandatory owner review"
+ ],
+ "actions": [
+ "Grant temporary scoped access",
+ "Queue owner review within 24 hours",
+ "Auto-expire access and attach audit receipt"
+ ]
+ },
+ {
+ "id": "bg-002",
+ "projectId": "project-sepsis-lockdown",
+ "requesterId": "viewer-eli",
+ "requesterRole": "Viewer",
+ "reasonCode": "manual_fix",
+ "requestedScopes": [
+ "dataset/raw:download"
+ ],
+ "requestedDataClasses": [
+ "human_subjects"
+ ],
+ "expiresInMinutes": 1440,
+ "status": "steward_review",
+ "reasons": [
+ "requester role is not allowed to initiate break-glass access",
+ "reason code is not recognized for emergency access",
+ "requested scopes are missing or not least-privilege read/write scopes",
+ "MFA verification is stale for emergency access",
+ "break-glass expiry must be positive and no longer than 120 minutes",
+ "immutable audit receipt is missing",
+ "post-access review is missing or due more than 24 hours later",
+ "request touches protected project data classes",
+ "missing owner sponsor approval or after-hours justification"
+ ],
+ "actions": [
+ "Route to steward review before any access is granted",
+ "Notify project owner and institutional admin",
+ "Require corrected scope, MFA, expiry, evidence, or sponsorship"
+ ]
+ },
+ {
+ "id": "bg-003",
+ "projectId": "project-climate-preprint",
+ "requesterId": "owner-mei",
+ "requesterRole": "Owner",
+ "reasonCode": "submission_deadline",
+ "requestedScopes": [
+ "preprint/submission:write"
+ ],
+ "requestedDataClasses": [
+ "public_metadata"
+ ],
+ "expiresInMinutes": 60,
+ "status": "approved",
+ "reasons": [
+ "break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates"
+ ],
+ "actions": [
+ "Grant temporary scoped access",
+ "Auto-expire access at requested expiry",
+ "Preserve immutable audit receipt and post-access review task"
+ ]
+ },
+ {
+ "id": "bg-004",
+ "projectId": "project-climate-preprint",
+ "requesterId": "admin-ravi",
+ "requesterRole": "Admin",
+ "reasonCode": "submission_deadline",
+ "requestedScopes": [
+ "notebooks/final-run:write",
+ "results/figures:write"
+ ],
+ "requestedDataClasses": [
+ "embargoed_partner_data"
+ ],
+ "expiresInMinutes": 480,
+ "status": "steward_review",
+ "reasons": [
+ "break-glass expiry must be positive and no longer than 120 minutes",
+ "post-access review is missing or due more than 24 hours later",
+ "request touches protected project data classes"
+ ],
+ "actions": [
+ "Route to steward review before any access is granted",
+ "Notify project owner and institutional admin",
+ "Require corrected scope, MFA, expiry, evidence, or sponsorship"
+ ]
+ },
+ {
+ "id": "bg-005",
+ "projectId": "project-climate-preprint",
+ "requesterId": "admin-ravi",
+ "requesterRole": "Admin",
+ "reasonCode": "incident_response",
+ "requestedScopes": [
+ "notebooks/final-run:write"
+ ],
+ "requestedDataClasses": [
+ "derived_results"
+ ],
+ "expiresInMinutes": 45,
+ "status": "approved",
+ "reasons": [
+ "break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates"
+ ],
+ "actions": [
+ "Grant temporary scoped access",
+ "Auto-expire access at requested expiry",
+ "Preserve immutable audit receipt and post-access review task"
+ ]
+ }
+ ],
+ "auditDigest": "e0e335c11209ce7c1812438f76265bb0adffcfbaee4cc16b489e5d2aebefad03"
+}
diff --git a/project-break-glass-access-guard/reports/summary.svg b/project-break-glass-access-guard/reports/summary.svg
new file mode 100644
index 00000000..03a93769
--- /dev/null
+++ b/project-break-glass-access-guard/reports/summary.svg
@@ -0,0 +1,26 @@
+
diff --git a/project-break-glass-access-guard/requirements-map.md b/project-break-glass-access-guard/requirements-map.md
new file mode 100644
index 00000000..3cbd295c
--- /dev/null
+++ b/project-break-glass-access-guard/requirements-map.md
@@ -0,0 +1,19 @@
+# Requirements Map
+
+| Issue #11 area | Coverage |
+| --- | --- |
+| Authentication and identity | Requires fresh MFA before emergency access can proceed. |
+| Project spaces | Models locked manuscript, notebook, discussion, and data sections. |
+| Permissions and access control | Enforces owner/admin initiation, least-privilege scopes, expiry, and protected data holds. |
+| Custom sharing | Prevents broad or long-lived emergency grants from bypassing standard access rules. |
+| Project-level audit log | Requires immutable audit receipts and post-access review tasks. |
+
+## Non-overlap
+
+This is not a broad workspace/RBAC ledger, privacy access review, member
+lifecycle/offboarding flow, institutional recertification module, anonymous
+review escrow, identity merge/export, data-room consent ledger, researcher
+profile sync, archive handoff, access-audit anomaly monitor, role delegation
+guard, invitation-domain/MFA guard, funding-attribution guard, service-token
+governance module, or deletion/erasure guard. It focuses on temporary
+break-glass emergency access and mandatory post-access review.
diff --git a/project-break-glass-access-guard/sample-data.js b/project-break-glass-access-guard/sample-data.js
new file mode 100644
index 00000000..a5c6881a
--- /dev/null
+++ b/project-break-glass-access-guard/sample-data.js
@@ -0,0 +1,110 @@
+const sampleBreakGlassPacket = {
+ asOf: "2026-05-22T12:00:00.000Z",
+ projects: [
+ {
+ projectId: "project-sepsis-lockdown",
+ visibility: "institutional_only",
+ owners: ["owner-ada"],
+ admins: ["admin-noor"],
+ protectedDataClasses: ["human_subjects", "restricted_raw_data"],
+ lockedSections: ["dataset/raw", "manuscript/results"],
+ },
+ {
+ projectId: "project-climate-preprint",
+ visibility: "private",
+ owners: ["owner-mei"],
+ admins: ["admin-ravi"],
+ protectedDataClasses: ["embargoed_partner_data"],
+ lockedSections: ["preprint/submission", "notebooks/final-run"],
+ },
+ ],
+ requests: [
+ {
+ id: "bg-001",
+ projectId: "project-sepsis-lockdown",
+ requesterId: "admin-noor",
+ requesterRole: "Admin",
+ reasonCode: "incident_response",
+ reasonDetail: "Restore locked manuscript section after failed publication export.",
+ requestedScopes: ["manuscript/results:write", "discussion/incident:write"],
+ requestedDataClasses: ["derived_results"],
+ requestedAt: "2026-05-22T03:30:00.000Z",
+ expiresAt: "2026-05-22T05:00:00.000Z",
+ mfaVerifiedAt: "2026-05-22T03:20:00.000Z",
+ sponsorApprovals: [],
+ afterHoursJustification: "Publication deadline with locked owner timezone.",
+ auditReceiptIds: ["audit-bg-001"],
+ postReviewDueAt: "2026-05-23T03:00:00.000Z",
+ },
+ {
+ id: "bg-002",
+ projectId: "project-sepsis-lockdown",
+ requesterId: "viewer-eli",
+ requesterRole: "Viewer",
+ reasonCode: "manual_fix",
+ reasonDetail: "Need raw data download to inspect anomaly.",
+ requestedScopes: ["dataset/raw:download"],
+ requestedDataClasses: ["human_subjects"],
+ requestedAt: "2026-05-22T04:00:00.000Z",
+ expiresAt: "2026-05-23T04:00:00.000Z",
+ mfaVerifiedAt: "2026-05-21T01:00:00.000Z",
+ sponsorApprovals: [],
+ afterHoursJustification: "",
+ auditReceiptIds: [],
+ postReviewDueAt: "",
+ },
+ {
+ id: "bg-003",
+ projectId: "project-climate-preprint",
+ requesterId: "owner-mei",
+ requesterRole: "Owner",
+ reasonCode: "submission_deadline",
+ reasonDetail: "Need final preprint metadata correction before repository deposit.",
+ requestedScopes: ["preprint/submission:write"],
+ requestedDataClasses: ["public_metadata"],
+ requestedAt: "2026-05-22T10:00:00.000Z",
+ expiresAt: "2026-05-22T11:00:00.000Z",
+ mfaVerifiedAt: "2026-05-22T09:55:00.000Z",
+ sponsorApprovals: [],
+ afterHoursJustification: "",
+ auditReceiptIds: ["audit-bg-003"],
+ postReviewDueAt: "2026-05-23T10:00:00.000Z",
+ },
+ {
+ id: "bg-004",
+ projectId: "project-climate-preprint",
+ requesterId: "admin-ravi",
+ requesterRole: "Admin",
+ reasonCode: "submission_deadline",
+ reasonDetail: "Update final notebook output and preprint figures.",
+ requestedScopes: ["notebooks/final-run:write", "results/figures:write"],
+ requestedDataClasses: ["embargoed_partner_data"],
+ requestedAt: "2026-05-22T10:15:00.000Z",
+ expiresAt: "2026-05-22T18:15:00.000Z",
+ mfaVerifiedAt: "2026-05-22T10:00:00.000Z",
+ sponsorApprovals: ["owner-mei"],
+ afterHoursJustification: "",
+ auditReceiptIds: ["audit-bg-004"],
+ postReviewDueAt: "2026-05-25T10:00:00.000Z",
+ },
+ {
+ id: "bg-005",
+ projectId: "project-climate-preprint",
+ requesterId: "admin-ravi",
+ requesterRole: "Admin",
+ reasonCode: "incident_response",
+ reasonDetail: "Emergency rollback after notebook output corruption.",
+ requestedScopes: ["notebooks/final-run:write"],
+ requestedDataClasses: ["derived_results"],
+ requestedAt: "2026-05-22T11:00:00.000Z",
+ expiresAt: "2026-05-22T11:45:00.000Z",
+ mfaVerifiedAt: "2026-05-22T10:59:00.000Z",
+ sponsorApprovals: ["owner-mei"],
+ afterHoursJustification: "",
+ auditReceiptIds: ["audit-bg-005"],
+ postReviewDueAt: "2026-05-23T11:00:00.000Z",
+ },
+ ],
+};
+
+module.exports = { sampleBreakGlassPacket };
diff --git a/project-break-glass-access-guard/test.js b/project-break-glass-access-guard/test.js
new file mode 100644
index 00000000..62701aa4
--- /dev/null
+++ b/project-break-glass-access-guard/test.js
@@ -0,0 +1,49 @@
+const assert = require("node:assert/strict");
+const {
+ APPROVED,
+ APPROVE_WITH_REVIEW,
+ DENIED,
+ STEWARD_REVIEW,
+ analyzeBreakGlassAccess,
+ createAuditDigest,
+ renderMarkdownReport,
+ renderSvgSummary,
+} = require("./index");
+const { sampleBreakGlassPacket } = require("./sample-data");
+
+const result = analyzeBreakGlassAccess(sampleBreakGlassPacket);
+
+assert.equal(result.totals.totalRequests, 5);
+assert.equal(result.totals.temporaryAccessGrants, 3);
+assert.equal(result.totals.blockedOrReviewRequests, 2);
+assert.equal(result.totals.byStatus[APPROVED], 2);
+assert.equal(result.totals.byStatus[APPROVE_WITH_REVIEW], 1);
+assert.equal(result.totals.byStatus[DENIED] || 0, 0);
+assert.equal(result.totals.byStatus[STEWARD_REVIEW], 2);
+
+const deniedViewer = result.decisions.find((decision) => decision.id === "bg-002");
+assert.equal(deniedViewer.status, STEWARD_REVIEW);
+assert.match(deniedViewer.reasons.join(" "), /not allowed/);
+assert.match(deniedViewer.reasons.join(" "), /protected project data/);
+
+const afterHours = result.decisions.find((decision) => decision.id === "bg-001");
+assert.equal(afterHours.status, APPROVE_WITH_REVIEW);
+assert.equal(afterHours.expiresInMinutes, 90);
+
+const approvedOwner = result.decisions.find((decision) => decision.id === "bg-003");
+assert.equal(approvedOwner.status, APPROVED);
+
+const longExpiry = result.decisions.find((decision) => decision.id === "bg-004");
+assert.equal(longExpiry.status, STEWARD_REVIEW);
+assert.match(longExpiry.reasons.join(" "), /no longer than 120 minutes/);
+
+assert.equal(createAuditDigest(result), result.auditDigest);
+assert.match(renderMarkdownReport(result), /Project Break-Glass Access Guard/);
+assert.match(renderSvgSummary(result), /Grants 3/);
+
+assert.throws(
+ () => analyzeBreakGlassAccess({ projects: [], requests: [{}] }),
+ /missing required break-glass request field: id/
+);
+
+console.log("project break-glass access guard tests passed");