diff --git a/promotion-dossier-export-guard/README.md b/promotion-dossier-export-guard/README.md
new file mode 100644
index 00000000..61abca1d
--- /dev/null
+++ b/promotion-dossier-export-guard/README.md
@@ -0,0 +1,36 @@
+# Promotion Dossier Export Guard
+
+This self-contained Community & User Reputation System slice validates synthetic reputation and contribution dossier exports before they are shared with institutions, funders, tenure committees, annual-review offices, or promotion workflows.
+
+The guard is distinct from broad reputation ledgers, CRediT taxonomy validation, contributor attestation, review calibration, workload equity, leaderboard privacy, sybil detection, probation/reinstatement, appeal evidence, and peer-review recertification. It focuses only on institution-facing export packets.
+
+## What it checks
+
+- contributor export consent and opt-out state
+- anonymous or double-blind reviewer redaction
+- semi-private review export permission
+- CRediT contribution evidence links
+- badge and reputation evidence freshness
+- citation/profile export purpose boundaries
+- conflict note redaction
+- reviewer-safe remediation before dossier release
+
+## Run locally
+
+```sh
+npm test
+npm run demo
+swift scripts/make-demo-video.swift artifacts/promotion-dossier-demo.mp4
+```
+
+The demo writes reviewer artifacts under `artifacts/`:
+
+- `promotion-dossier-results.json`
+- `promotion-dossier-report.md`
+- `promotion-dossier-summary.svg`
+- `promotion-dossier-demo.mp4`
+- `demo-transcript.md`
+
+## Boundaries
+
+All packets are synthetic. The module does not call institutional systems, HR systems, private profile stores, live reputation services, credentials, external APIs, or payment systems.
diff --git a/promotion-dossier-export-guard/REQUIREMENT_MAP.md b/promotion-dossier-export-guard/REQUIREMENT_MAP.md
new file mode 100644
index 00000000..a830b4a7
--- /dev/null
+++ b/promotion-dossier-export-guard/REQUIREMENT_MAP.md
@@ -0,0 +1,16 @@
+# Requirement Map
+
+| Issue requirement | Implementation |
+| --- | --- |
+| Structured peer reviews and comments | Validates review visibility, anonymous/double-blind redaction, and semi-private export permission before dossier release. |
+| Review history tracked on profiles | Ensures reviewer-safe history is exportable only when visibility rules allow it. |
+| Contributor credits | Requires CRediT-style contribution evidence links and timestamps before institutional export. |
+| Visible credit on researcher profiles and citation pages | Checks profile export purpose, opt-out state, and contributor consent. |
+| Reputation scoring | Reviews score deltas and badge evidence freshness before reputation is used in promotion or grant packets. |
+| Institutional reporting and promotion support | Produces deterministic RELEASE, REVIEW, and HOLD decisions for tenure, promotion, annual-review, and grant-report exports. |
+| Reviewer-ready evidence | Demo script generates JSON, Markdown, SVG, transcript, and MP4 artifacts from synthetic dossier packets. |
+| Safe contribution boundary | Uses synthetic records only; no live people, credentials, private profile stores, HR systems, or external APIs. |
+
+## Distinct slice statement
+
+This contribution focuses only on institution-facing reputation dossier export readiness. It does not implement general scoring, CRediT role validation, contributor attestation, workload equity, leaderboard privacy, abuse detection, probation/reinstatement, appeal evidence, or peer-review evidence recertification.
diff --git a/promotion-dossier-export-guard/artifacts/demo-transcript.md b/promotion-dossier-export-guard/artifacts/demo-transcript.md
new file mode 100644
index 00000000..2e6bbb02
--- /dev/null
+++ b/promotion-dossier-export-guard/artifacts/demo-transcript.md
@@ -0,0 +1,14 @@
+# Demo Transcript
+
+1. Load four synthetic institution-facing reputation dossier packets.
+2. Evaluate consent, opt-out state, anonymous review redaction, semi-private review authorization, credit evidence, badge freshness, and conflict notes.
+3. Emit deterministic export decisions: RELEASE_DOSSIER, REVIEW_BEFORE_EXPORT, or HOLD_EXPORT.
+4. Write JSON, Markdown, and SVG artifacts for reviewer replay.
+
+## Demo Output
+
+- Release: 1
+- Review: 1
+- Hold: 2
+- Held dossiers: dossier-opt-out, dossier-blind-leak
+- Review dossiers: dossier-stale-review
diff --git a/promotion-dossier-export-guard/artifacts/promotion-dossier-demo.mp4 b/promotion-dossier-export-guard/artifacts/promotion-dossier-demo.mp4
new file mode 100644
index 00000000..eb47e402
Binary files /dev/null and b/promotion-dossier-export-guard/artifacts/promotion-dossier-demo.mp4 differ
diff --git a/promotion-dossier-export-guard/artifacts/promotion-dossier-report.md b/promotion-dossier-export-guard/artifacts/promotion-dossier-report.md
new file mode 100644
index 00000000..2a150777
--- /dev/null
+++ b/promotion-dossier-export-guard/artifacts/promotion-dossier-report.md
@@ -0,0 +1,38 @@
+# Promotion Dossier Export Report
+
+As of: 2026-06-18
+
+## Summary
+
+- Total dossiers: 4
+- Release: 1
+- Review: 1
+- Hold: 2
+
+## Dossier Decisions
+
+| Researcher | Purpose | Recipient | Decision | Primary reason |
+| --- | --- | --- | --- | --- |
+| Dr. Lina Park | promotion | institutional-promotion-committee | RELEASE_DOSSIER | All export checks passed |
+| Dr. Amara Singh | annual-review | department-chair | HOLD_EXPORT | CONTRIBUTOR_OPT_OUT: Researcher has opted out of institution-facing dossier export. |
+| Dr. Theo Imani | grant-report | external-funder | HOLD_EXPORT | BLIND_REVIEW_IDENTITY_LEAK: Review review-blind-2 is anonymous but reviewer identity is not safely redacted. |
+| Dr. Noura Bell | tenure | faculty-affairs | REVIEW_BEFORE_EXPORT | BADGE_EVIDENCE_STALE: Badge reproducibility-badge evidence is 550 days old. |
+
+## Remediation Actions
+
+### Dr. Lina Park
+- Release dossier to the configured institution-facing recipient.
+
+### Dr. Amara Singh
+- Remove this dossier from the export batch or collect updated consent.
+
+### Dr. Theo Imani
+- Redact reviewer identity and regenerate the dossier.
+- Exclude the restricted review or attach author export consent.
+- Refresh badge evidence or annotate the dossier with stale-evidence status.
+- Attach source reputation events before export.
+- Replace internal conflict notes with a reviewer-safe export summary.
+
+### Dr. Noura Bell
+- Attach contribution evidence before using this credit in a dossier.
+- Refresh badge evidence or annotate the dossier with stale-evidence status.
diff --git a/promotion-dossier-export-guard/artifacts/promotion-dossier-results.json b/promotion-dossier-export-guard/artifacts/promotion-dossier-results.json
new file mode 100644
index 00000000..76658742
--- /dev/null
+++ b/promotion-dossier-export-guard/artifacts/promotion-dossier-results.json
@@ -0,0 +1,214 @@
+{
+ "asOf": "2026-06-18",
+ "policy": {
+ "evidenceFreshnessDays": 365,
+ "restrictedReviewRequiresAuthorConsent": true
+ },
+ "summary": {
+ "totalDossiers": 4,
+ "release": 1,
+ "review": 1,
+ "hold": 2,
+ "heldDossierIds": [
+ "dossier-opt-out",
+ "dossier-blind-leak"
+ ],
+ "reviewDossierIds": [
+ "dossier-stale-review"
+ ],
+ "topRisks": [
+ {
+ "dossierId": "dossier-opt-out",
+ "severity": "HOLD_EXPORT",
+ "code": "CONTRIBUTOR_OPT_OUT"
+ },
+ {
+ "dossierId": "dossier-blind-leak",
+ "severity": "HOLD_EXPORT",
+ "code": "BLIND_REVIEW_IDENTITY_LEAK"
+ },
+ {
+ "dossierId": "dossier-blind-leak",
+ "severity": "HOLD_EXPORT",
+ "code": "RESTRICTED_REVIEW_EXPORT_NOT_AUTHORIZED"
+ },
+ {
+ "dossierId": "dossier-blind-leak",
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "BADGE_EVIDENCE_STALE"
+ },
+ {
+ "dossierId": "dossier-blind-leak",
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "CONFLICT_NOTES_REQUIRE_REDACTION"
+ },
+ {
+ "dossierId": "dossier-blind-leak",
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "REPUTATION_DELTA_SOURCE_MISSING"
+ },
+ {
+ "dossierId": "dossier-stale-review",
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "BADGE_EVIDENCE_STALE"
+ },
+ {
+ "dossierId": "dossier-stale-review",
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "CREDIT_EVIDENCE_MISSING"
+ }
+ ]
+ },
+ "results": [
+ {
+ "dossierId": "dossier-release",
+ "researcher": "Dr. Lina Park",
+ "purpose": "promotion",
+ "recipient": "institutional-promotion-committee",
+ "decision": "RELEASE_DOSSIER",
+ "exportSignals": {
+ "reviewCount": 2,
+ "creditCount": 2,
+ "badgeCount": 1,
+ "blindReviewCount": 1,
+ "restrictedReviewCount": 1
+ },
+ "evidence": {
+ "exportConsentAt": "2026-04-12",
+ "optOut": false,
+ "reputationSourceEventIds": [
+ "review-evidence-100",
+ "credit-data-221",
+ "badge-open-2026"
+ ]
+ },
+ "reasons": [],
+ "actions": [],
+ "riskScore": 0
+ },
+ {
+ "dossierId": "dossier-opt-out",
+ "researcher": "Dr. Amara Singh",
+ "purpose": "annual-review",
+ "recipient": "department-chair",
+ "decision": "HOLD_EXPORT",
+ "exportSignals": {
+ "reviewCount": 1,
+ "creditCount": 1,
+ "badgeCount": 0,
+ "blindReviewCount": 0,
+ "restrictedReviewCount": 0
+ },
+ "evidence": {
+ "exportConsentAt": "2025-11-03",
+ "optOut": true,
+ "reputationSourceEventIds": [
+ "review-evidence-200"
+ ]
+ },
+ "reasons": [
+ {
+ "severity": "HOLD_EXPORT",
+ "code": "CONTRIBUTOR_OPT_OUT",
+ "message": "Researcher has opted out of institution-facing dossier export."
+ }
+ ],
+ "actions": [
+ "Remove this dossier from the export batch or collect updated consent."
+ ],
+ "riskScore": 2
+ },
+ {
+ "dossierId": "dossier-blind-leak",
+ "researcher": "Dr. Theo Imani",
+ "purpose": "grant-report",
+ "recipient": "external-funder",
+ "decision": "HOLD_EXPORT",
+ "exportSignals": {
+ "reviewCount": 2,
+ "creditCount": 1,
+ "badgeCount": 1,
+ "blindReviewCount": 1,
+ "restrictedReviewCount": 2
+ },
+ "evidence": {
+ "exportConsentAt": "2026-05-08",
+ "optOut": false,
+ "reputationSourceEventIds": []
+ },
+ "reasons": [
+ {
+ "severity": "HOLD_EXPORT",
+ "code": "BLIND_REVIEW_IDENTITY_LEAK",
+ "message": "Review review-blind-2 is anonymous but reviewer identity is not safely redacted."
+ },
+ {
+ "severity": "HOLD_EXPORT",
+ "code": "RESTRICTED_REVIEW_EXPORT_NOT_AUTHORIZED",
+ "message": "Review review-semi-1 is semi-private and lacks author consent for export."
+ },
+ {
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "BADGE_EVIDENCE_STALE",
+ "message": "Badge trusted-reviewer evidence is 443 days old."
+ },
+ {
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "CONFLICT_NOTES_REQUIRE_REDACTION",
+ "message": "Conflict notes are present and must be redacted or summarized before export."
+ },
+ {
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "REPUTATION_DELTA_SOURCE_MISSING",
+ "message": "Reputation delta is present without source event ids."
+ }
+ ],
+ "actions": [
+ "Redact reviewer identity and regenerate the dossier.",
+ "Exclude the restricted review or attach author export consent.",
+ "Refresh badge evidence or annotate the dossier with stale-evidence status.",
+ "Attach source reputation events before export.",
+ "Replace internal conflict notes with a reviewer-safe export summary."
+ ],
+ "riskScore": 7
+ },
+ {
+ "dossierId": "dossier-stale-review",
+ "researcher": "Dr. Noura Bell",
+ "purpose": "tenure",
+ "recipient": "faculty-affairs",
+ "decision": "REVIEW_BEFORE_EXPORT",
+ "exportSignals": {
+ "reviewCount": 1,
+ "creditCount": 1,
+ "badgeCount": 1,
+ "blindReviewCount": 0,
+ "restrictedReviewCount": 0
+ },
+ "evidence": {
+ "exportConsentAt": "2026-02-01",
+ "optOut": false,
+ "reputationSourceEventIds": [
+ "review-evidence-401"
+ ]
+ },
+ "reasons": [
+ {
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "BADGE_EVIDENCE_STALE",
+ "message": "Badge reproducibility-badge evidence is 550 days old."
+ },
+ {
+ "severity": "REVIEW_BEFORE_EXPORT",
+ "code": "CREDIT_EVIDENCE_MISSING",
+ "message": "CRediT role \"Writing - review and editing\" lacks an evidence id."
+ }
+ ],
+ "actions": [
+ "Attach contribution evidence before using this credit in a dossier.",
+ "Refresh badge evidence or annotate the dossier with stale-evidence status."
+ ],
+ "riskScore": 2
+ }
+ ]
+}
diff --git a/promotion-dossier-export-guard/artifacts/promotion-dossier-summary.svg b/promotion-dossier-export-guard/artifacts/promotion-dossier-summary.svg
new file mode 100644
index 00000000..b115f3b9
--- /dev/null
+++ b/promotion-dossier-export-guard/artifacts/promotion-dossier-summary.svg
@@ -0,0 +1,12 @@
+
diff --git a/promotion-dossier-export-guard/examples/dossier-packets.json b/promotion-dossier-export-guard/examples/dossier-packets.json
new file mode 100644
index 00000000..728086aa
--- /dev/null
+++ b/promotion-dossier-export-guard/examples/dossier-packets.json
@@ -0,0 +1,184 @@
+{
+ "asOf": "2026-06-18",
+ "policy": {
+ "evidenceFreshnessDays": 365,
+ "restrictedReviewRequiresAuthorConsent": true
+ },
+ "dossiers": [
+ {
+ "id": "dossier-release",
+ "researcher": "Dr. Lina Park",
+ "purpose": "promotion",
+ "recipient": "institutional-promotion-committee",
+ "consent": {
+ "exportConsentAt": "2026-04-12",
+ "optOut": false
+ },
+ "reviews": [
+ {
+ "id": "review-public-1",
+ "mode": "public",
+ "reviewerName": "Dr. Mateo Cruz",
+ "redacted": false,
+ "authorConsent": true,
+ "evidenceId": "review-evidence-100"
+ },
+ {
+ "id": "review-blind-1",
+ "mode": "double-blind",
+ "reviewerName": null,
+ "redacted": true,
+ "authorConsent": true,
+ "evidenceId": "review-evidence-101"
+ }
+ ],
+ "credits": [
+ {
+ "role": "Data curation",
+ "timestamp": "2026-01-21",
+ "evidenceId": "credit-data-221"
+ },
+ {
+ "role": "Software",
+ "timestamp": "2026-02-14",
+ "evidenceId": "credit-software-114"
+ }
+ ],
+ "badges": [
+ {
+ "id": "open-science-champion",
+ "grantedAt": "2026-03-10",
+ "evidenceId": "badge-open-2026"
+ }
+ ],
+ "reputation": {
+ "score": 84,
+ "delta": 7,
+ "sourceEventIds": ["review-evidence-100", "credit-data-221", "badge-open-2026"]
+ },
+ "conflictNotes": []
+ },
+ {
+ "id": "dossier-opt-out",
+ "researcher": "Dr. Amara Singh",
+ "purpose": "annual-review",
+ "recipient": "department-chair",
+ "consent": {
+ "exportConsentAt": "2025-11-03",
+ "optOut": true
+ },
+ "reviews": [
+ {
+ "id": "review-public-2",
+ "mode": "public",
+ "reviewerName": "Dr. N. Rivera",
+ "redacted": false,
+ "authorConsent": true,
+ "evidenceId": "review-evidence-200"
+ }
+ ],
+ "credits": [
+ {
+ "role": "Investigation",
+ "timestamp": "2026-02-02",
+ "evidenceId": "credit-investigation-202"
+ }
+ ],
+ "badges": [],
+ "reputation": {
+ "score": 61,
+ "delta": 2,
+ "sourceEventIds": ["review-evidence-200"]
+ },
+ "conflictNotes": []
+ },
+ {
+ "id": "dossier-blind-leak",
+ "researcher": "Dr. Theo Imani",
+ "purpose": "grant-report",
+ "recipient": "external-funder",
+ "consent": {
+ "exportConsentAt": "2026-05-08",
+ "optOut": false
+ },
+ "reviews": [
+ {
+ "id": "review-blind-2",
+ "mode": "anonymous",
+ "reviewerName": "Dr. Hidden Reviewer",
+ "redacted": false,
+ "authorConsent": true,
+ "evidenceId": "review-evidence-301"
+ },
+ {
+ "id": "review-semi-1",
+ "mode": "semi-private",
+ "reviewerName": "Dr. Kay Holt",
+ "redacted": true,
+ "authorConsent": false,
+ "evidenceId": "review-evidence-302"
+ }
+ ],
+ "credits": [
+ {
+ "role": "Formal analysis",
+ "timestamp": "2026-01-05",
+ "evidenceId": "credit-analysis-301"
+ }
+ ],
+ "badges": [
+ {
+ "id": "trusted-reviewer",
+ "grantedAt": "2025-04-01",
+ "evidenceId": "badge-reviewer-old"
+ }
+ ],
+ "reputation": {
+ "score": 73,
+ "delta": 9,
+ "sourceEventIds": []
+ },
+ "conflictNotes": ["Unredacted committee conflict note should not leave the platform."]
+ },
+ {
+ "id": "dossier-stale-review",
+ "researcher": "Dr. Noura Bell",
+ "purpose": "tenure",
+ "recipient": "faculty-affairs",
+ "consent": {
+ "exportConsentAt": "2026-02-01",
+ "optOut": false
+ },
+ "reviews": [
+ {
+ "id": "review-public-3",
+ "mode": "public",
+ "reviewerName": "Dr. Owen Liu",
+ "redacted": false,
+ "authorConsent": true,
+ "evidenceId": "review-evidence-401"
+ }
+ ],
+ "credits": [
+ {
+ "role": "Writing - review and editing",
+ "timestamp": "2025-08-02",
+ "evidenceId": ""
+ }
+ ],
+ "badges": [
+ {
+ "id": "reproducibility-badge",
+ "grantedAt": "2024-12-15",
+ "evidenceId": "badge-repro-2024"
+ }
+ ],
+ "reputation": {
+ "score": 79,
+ "delta": 4,
+ "sourceEventIds": ["review-evidence-401"]
+ },
+ "conflictNotes": []
+ }
+ ]
+}
diff --git a/promotion-dossier-export-guard/package.json b/promotion-dossier-export-guard/package.json
new file mode 100644
index 00000000..53808d05
--- /dev/null
+++ b/promotion-dossier-export-guard/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "promotion-dossier-export-guard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Offline guard for institution-facing community reputation dossier exports.",
+ "type": "commonjs",
+ "scripts": {
+ "test": "node --test",
+ "demo": "node scripts/demo.js"
+ }
+}
diff --git a/promotion-dossier-export-guard/scripts/demo.js b/promotion-dossier-export-guard/scripts/demo.js
new file mode 100644
index 00000000..87f48953
--- /dev/null
+++ b/promotion-dossier-export-guard/scripts/demo.js
@@ -0,0 +1,47 @@
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { buildMarkdownReport, buildSvgSummary, evaluateBatch } = require('../src');
+
+const root = path.join(__dirname, '..');
+const examplesPath = path.join(root, 'examples', 'dossier-packets.json');
+const artifactsDir = path.join(root, 'artifacts');
+const packet = JSON.parse(fs.readFileSync(examplesPath, 'utf8'));
+const batch = evaluateBatch(packet);
+
+fs.mkdirSync(artifactsDir, { recursive: true });
+fs.writeFileSync(
+ path.join(artifactsDir, 'promotion-dossier-results.json'),
+ `${JSON.stringify(batch, null, 2)}\n`
+);
+fs.writeFileSync(
+ path.join(artifactsDir, 'promotion-dossier-report.md'),
+ buildMarkdownReport(batch)
+);
+fs.writeFileSync(
+ path.join(artifactsDir, 'promotion-dossier-summary.svg'),
+ buildSvgSummary(batch)
+);
+fs.writeFileSync(
+ path.join(artifactsDir, 'demo-transcript.md'),
+ [
+ '# Demo Transcript',
+ '',
+ '1. Load four synthetic institution-facing reputation dossier packets.',
+ '2. Evaluate consent, opt-out state, anonymous review redaction, semi-private review authorization, credit evidence, badge freshness, and conflict notes.',
+ '3. Emit deterministic export decisions: RELEASE_DOSSIER, REVIEW_BEFORE_EXPORT, or HOLD_EXPORT.',
+ '4. Write JSON, Markdown, and SVG artifacts for reviewer replay.',
+ '',
+ '## Demo Output',
+ '',
+ `- Release: ${batch.summary.release}`,
+ `- Review: ${batch.summary.review}`,
+ `- Hold: ${batch.summary.hold}`,
+ `- Held dossiers: ${batch.summary.heldDossierIds.join(', ') || 'none'}`,
+ `- Review dossiers: ${batch.summary.reviewDossierIds.join(', ') || 'none'}`
+ ].join('\n') + '\n'
+);
+
+console.log(`Generated promotion dossier artifacts for ${batch.summary.totalDossiers} dossiers.`);
+console.log(`Release=${batch.summary.release} Review=${batch.summary.review} Hold=${batch.summary.hold}`);
diff --git a/promotion-dossier-export-guard/scripts/make-demo-video.swift b/promotion-dossier-export-guard/scripts/make-demo-video.swift
new file mode 100644
index 00000000..a5fd2d87
--- /dev/null
+++ b/promotion-dossier-export-guard/scripts/make-demo-video.swift
@@ -0,0 +1,150 @@
+import AVFoundation
+import CoreGraphics
+import CoreText
+import Foundation
+
+let outputPath = CommandLine.arguments.dropFirst().first ?? "artifacts/promotion-dossier-demo.mp4"
+let outputURL = URL(fileURLWithPath: outputPath)
+try? FileManager.default.removeItem(at: outputURL)
+try FileManager.default.createDirectory(
+ at: outputURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+)
+
+let width = 1280
+let height = 720
+let fps: Int32 = 30
+let totalFrames = Int(fps) * 6
+
+let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
+let input = AVAssetWriterInput(
+ mediaType: .video,
+ outputSettings: [
+ AVVideoCodecKey: AVVideoCodecType.h264,
+ AVVideoWidthKey: width,
+ AVVideoHeightKey: height
+ ]
+)
+input.expectsMediaDataInRealTime = false
+
+let adaptor = AVAssetWriterInputPixelBufferAdaptor(
+ assetWriterInput: input,
+ sourcePixelBufferAttributes: [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB,
+ kCVPixelBufferWidthKey as String: width,
+ kCVPixelBufferHeightKey as String: height
+ ]
+)
+
+writer.add(input)
+writer.startWriting()
+writer.startSession(atSourceTime: .zero)
+
+func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = 1) -> CGColor {
+ CGColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha)
+}
+
+func drawText(_ context: CGContext, _ text: String, x: CGFloat, y: CGFloat, size: CGFloat, color textColor: CGColor, weight: String = "Regular") {
+ let font = CTFontCreateWithName("Helvetica-\(weight)" as CFString, size, nil)
+ let attributes: [CFString: Any] = [
+ kCTFontAttributeName: font,
+ kCTForegroundColorAttributeName: textColor
+ ]
+ let attributed = CFAttributedStringCreate(nil, text as CFString, attributes as CFDictionary)!
+ let line = CTLineCreateWithAttributedString(attributed)
+ context.textPosition = CGPoint(x: x, y: y)
+ CTLineDraw(line, context)
+}
+
+func fillRounded(_ context: CGContext, rect: CGRect, radius: CGFloat, fill: CGColor) {
+ let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
+ context.addPath(path)
+ context.setFillColor(fill)
+ context.fillPath()
+}
+
+func renderFrame(_ buffer: CVPixelBuffer, frame: Int) {
+ CVPixelBufferLockBaseAddress(buffer, [])
+ defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
+
+ let context = CGContext(
+ data: CVPixelBufferGetBaseAddress(buffer),
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: CVPixelBufferGetBytesPerRow(buffer),
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue
+ )!
+
+ context.setFillColor(color(248, 250, 252))
+ context.fill(CGRect(x: 0, y: 0, width: width, height: height))
+ context.translateBy(x: 0, y: CGFloat(height))
+ context.scaleBy(x: 1, y: -1)
+
+ let progress = min(1, CGFloat(frame) / CGFloat(totalFrames - 1))
+ let reveal = min(1, progress * 1.35)
+
+ drawText(context, "Promotion Dossier Export Guard", x: 72, y: 92, size: 44, color: color(17, 24, 39), weight: "Bold")
+ drawText(context, "Community & Reputation issue #15 - synthetic replay packet", x: 74, y: 132, size: 22, color: color(71, 85, 105))
+
+ fillRounded(context, rect: CGRect(x: 74, y: 178, width: 1132, height: 84), radius: 16, fill: color(255, 255, 255))
+ drawText(context, "Institution-facing export result", x: 104, y: 214, size: 24, color: color(15, 23, 42), weight: "Bold")
+ drawText(context, "1 release | 1 review | 2 holds", x: 104, y: 246, size: 26, color: color(30, 41, 59))
+
+ let barY: CGFloat = 305
+ let maxWidth: CGFloat = 1000 * reveal
+ fillRounded(context, rect: CGRect(x: 74, y: barY, width: maxWidth * 0.25, height: 36), radius: 8, fill: color(22, 163, 74))
+ fillRounded(context, rect: CGRect(x: 74 + maxWidth * 0.25, y: barY, width: maxWidth * 0.25, height: 36), radius: 8, fill: color(202, 138, 4))
+ fillRounded(context, rect: CGRect(x: 74 + maxWidth * 0.5, y: barY, width: maxWidth * 0.5, height: 36), radius: 8, fill: color(234, 88, 12))
+
+ let rows = [
+ ("Dr. Lina Park", "RELEASE_DOSSIER", color(21, 128, 61)),
+ ("Dr. Noura Bell", "REVIEW_BEFORE_EXPORT", color(161, 98, 7)),
+ ("Dr. Amara Singh", "HOLD_EXPORT", color(194, 65, 12)),
+ ("Dr. Theo Imani", "HOLD_EXPORT", color(194, 65, 12))
+ ]
+
+ for (index, row) in rows.enumerated() {
+ let y = CGFloat(400 + index * 48)
+ if progress > CGFloat(index) * 0.14 {
+ fillRounded(context, rect: CGRect(x: 74, y: y - 26, width: 1132, height: 36), radius: 8, fill: color(255, 255, 255))
+ drawText(context, row.0, x: 98, y: y, size: 22, color: color(30, 41, 59))
+ drawText(context, row.1, x: 820, y: y, size: 20, color: row.2, weight: "Bold")
+ }
+ }
+
+ drawText(context, "Checks: consent, opt-out, blind review redaction, CRediT evidence, badge freshness.", x: 74, y: 650, size: 19, color: color(71, 85, 105))
+}
+
+for frame in 0.. SEVERITY_RANK[current] ? incoming : current;
+}
+
+function unique(values) {
+ return [...new Set(values.filter(Boolean))];
+}
+
+function evaluateDossier(dossier, options = {}) {
+ const policy = { ...DEFAULT_POLICY, ...(options.policy || {}) };
+ const asOf = parseDate(options.asOf, 'asOf') || new Date();
+ const consent = dossier.consent || {};
+ const reviews = dossier.reviews || [];
+ const credits = dossier.credits || [];
+ const badges = dossier.badges || [];
+ const reputation = dossier.reputation || {};
+ let decision = 'RELEASE_DOSSIER';
+ const reasons = [];
+ const actions = [];
+
+ function add(severity, code, message, action) {
+ decision = classify(decision, severity);
+ reasons.push({ severity, code, message });
+ if (action) actions.push(action);
+ }
+
+ if (consent.optOut) {
+ add(
+ 'HOLD_EXPORT',
+ 'CONTRIBUTOR_OPT_OUT',
+ 'Researcher has opted out of institution-facing dossier export.',
+ 'Remove this dossier from the export batch or collect updated consent.'
+ );
+ }
+
+ if (!consent.exportConsentAt) {
+ add(
+ 'HOLD_EXPORT',
+ 'EXPORT_CONSENT_MISSING',
+ 'No contributor export consent timestamp is attached.',
+ 'Collect export consent before sharing the dossier.'
+ );
+ }
+
+ for (const review of reviews) {
+ if (BLIND_MODES.has(review.mode) && (review.reviewerName || review.redacted !== true)) {
+ add(
+ 'HOLD_EXPORT',
+ 'BLIND_REVIEW_IDENTITY_LEAK',
+ `Review ${review.id} is ${review.mode} but reviewer identity is not safely redacted.`,
+ 'Redact reviewer identity and regenerate the dossier.'
+ );
+ }
+
+ if (
+ policy.restrictedReviewRequiresAuthorConsent &&
+ RESTRICTED_REVIEW_MODES.has(review.mode) &&
+ !review.authorConsent
+ ) {
+ add(
+ 'HOLD_EXPORT',
+ 'RESTRICTED_REVIEW_EXPORT_NOT_AUTHORIZED',
+ `Review ${review.id} is ${review.mode} and lacks author consent for export.`,
+ 'Exclude the restricted review or attach author export consent.'
+ );
+ }
+
+ if (!review.evidenceId) {
+ add(
+ 'REVIEW_BEFORE_EXPORT',
+ 'REVIEW_EVIDENCE_MISSING',
+ `Review ${review.id} lacks evidence linkage.`,
+ 'Attach review evidence before export.'
+ );
+ }
+ }
+
+ for (const credit of credits) {
+ if (!credit.evidenceId) {
+ add(
+ 'REVIEW_BEFORE_EXPORT',
+ 'CREDIT_EVIDENCE_MISSING',
+ `CRediT role "${credit.role}" lacks an evidence id.`,
+ 'Attach contribution evidence before using this credit in a dossier.'
+ );
+ }
+ if (!credit.timestamp) {
+ add(
+ 'REVIEW_BEFORE_EXPORT',
+ 'CREDIT_TIMESTAMP_MISSING',
+ `CRediT role "${credit.role}" lacks a timestamp.`,
+ 'Add timestamped contribution evidence.'
+ );
+ }
+ }
+
+ for (const badge of badges) {
+ const grantedAt = parseDate(badge.grantedAt, `badge.${badge.id}.grantedAt`);
+ if (grantedAt && ageInDays(grantedAt, asOf) > policy.evidenceFreshnessDays) {
+ add(
+ 'REVIEW_BEFORE_EXPORT',
+ 'BADGE_EVIDENCE_STALE',
+ `Badge ${badge.id} evidence is ${ageInDays(grantedAt, asOf)} days old.`,
+ 'Refresh badge evidence or annotate the dossier with stale-evidence status.'
+ );
+ }
+ if (!badge.evidenceId) {
+ add(
+ 'REVIEW_BEFORE_EXPORT',
+ 'BADGE_EVIDENCE_MISSING',
+ `Badge ${badge.id} lacks evidence linkage.`,
+ 'Attach badge evidence before export.'
+ );
+ }
+ }
+
+ if (Number(reputation.delta || 0) !== 0 && (!reputation.sourceEventIds || reputation.sourceEventIds.length === 0)) {
+ add(
+ 'REVIEW_BEFORE_EXPORT',
+ 'REPUTATION_DELTA_SOURCE_MISSING',
+ 'Reputation delta is present without source event ids.',
+ 'Attach source reputation events before export.'
+ );
+ }
+
+ if ((dossier.conflictNotes || []).length > 0) {
+ add(
+ 'REVIEW_BEFORE_EXPORT',
+ 'CONFLICT_NOTES_REQUIRE_REDACTION',
+ 'Conflict notes are present and must be redacted or summarized before export.',
+ 'Replace internal conflict notes with a reviewer-safe export summary.'
+ );
+ }
+
+ const sortedReasons = reasons.sort(
+ (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || a.code.localeCompare(b.code)
+ );
+
+ return {
+ dossierId: dossier.id,
+ researcher: dossier.researcher,
+ purpose: dossier.purpose,
+ recipient: dossier.recipient,
+ decision,
+ exportSignals: {
+ reviewCount: reviews.length,
+ creditCount: credits.length,
+ badgeCount: badges.length,
+ blindReviewCount: reviews.filter((review) => BLIND_MODES.has(review.mode)).length,
+ restrictedReviewCount: reviews.filter((review) => RESTRICTED_REVIEW_MODES.has(review.mode)).length
+ },
+ evidence: {
+ exportConsentAt: consent.exportConsentAt || null,
+ optOut: Boolean(consent.optOut),
+ reputationSourceEventIds: reputation.sourceEventIds || []
+ },
+ reasons: sortedReasons,
+ actions: unique(actions),
+ riskScore: sortedReasons.reduce((total, reason) => total + SEVERITY_RANK[reason.severity], 0)
+ };
+}
+
+function evaluateBatch(packet, options = {}) {
+ const asOf = options.asOf || packet.asOf;
+ const policy = { ...DEFAULT_POLICY, ...(packet.policy || {}), ...(options.policy || {}) };
+ const results = (packet.dossiers || []).map((dossier) => evaluateDossier(dossier, { asOf, policy }));
+ return {
+ asOf,
+ policy,
+ summary: summarizeResults(results),
+ results
+ };
+}
+
+function summarizeResults(results) {
+ const summary = {
+ totalDossiers: results.length,
+ release: 0,
+ review: 0,
+ hold: 0,
+ heldDossierIds: [],
+ reviewDossierIds: [],
+ topRisks: []
+ };
+
+ for (const result of results) {
+ if (result.decision === 'RELEASE_DOSSIER') summary.release += 1;
+ if (result.decision === 'REVIEW_BEFORE_EXPORT') {
+ summary.review += 1;
+ summary.reviewDossierIds.push(result.dossierId);
+ }
+ if (result.decision === 'HOLD_EXPORT') {
+ summary.hold += 1;
+ summary.heldDossierIds.push(result.dossierId);
+ }
+ }
+
+ summary.topRisks = results
+ .flatMap((result) =>
+ result.reasons.map((reason) => ({
+ dossierId: result.dossierId,
+ severity: reason.severity,
+ code: reason.code
+ }))
+ )
+ .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
+ .slice(0, 8);
+
+ return summary;
+}
+
+function buildMarkdownReport(batch) {
+ const lines = [
+ '# Promotion Dossier Export Report',
+ '',
+ `As of: ${batch.asOf}`,
+ '',
+ '## Summary',
+ '',
+ `- Total dossiers: ${batch.summary.totalDossiers}`,
+ `- Release: ${batch.summary.release}`,
+ `- Review: ${batch.summary.review}`,
+ `- Hold: ${batch.summary.hold}`,
+ '',
+ '## Dossier Decisions',
+ '',
+ '| Researcher | Purpose | Recipient | Decision | Primary reason |',
+ '| --- | --- | --- | --- | --- |'
+ ];
+
+ for (const result of batch.results) {
+ const primary = result.reasons[0]
+ ? `${result.reasons[0].code}: ${result.reasons[0].message}`
+ : 'All export checks passed';
+ lines.push(
+ `| ${result.researcher} | ${result.purpose} | ${result.recipient} | ${result.decision} | ${primary} |`
+ );
+ }
+
+ lines.push('', '## Remediation Actions', '');
+ for (const result of batch.results) {
+ lines.push(`### ${result.researcher}`);
+ if (result.actions.length === 0) {
+ lines.push('- Release dossier to the configured institution-facing recipient.');
+ } else {
+ for (const action of result.actions) lines.push(`- ${action}`);
+ }
+ lines.push('');
+ }
+
+ while (lines[lines.length - 1] === '') lines.pop();
+ return `${lines.join('\n')}\n`;
+}
+
+function buildSvgSummary(batch) {
+ const width = 860;
+ const height = 330;
+ const releaseWidth = batch.summary.release * 110;
+ const reviewWidth = batch.summary.review * 110;
+ const holdWidth = batch.summary.hold * 110;
+ const escape = (value) =>
+ String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+
+ const rows = batch.results
+ .map((result, index) => {
+ const y = 176 + index * 30;
+ const color =
+ result.decision === 'HOLD_EXPORT'
+ ? '#c2410c'
+ : result.decision === 'REVIEW_BEFORE_EXPORT'
+ ? '#a16207'
+ : '#15803d';
+ return `${escape(result.researcher)}${result.decision}`;
+ })
+ .join('');
+
+ return `
+`;
+}
+
+module.exports = {
+ DEFAULT_POLICY,
+ buildMarkdownReport,
+ buildSvgSummary,
+ evaluateBatch,
+ evaluateDossier,
+ summarizeResults
+};
diff --git a/promotion-dossier-export-guard/test/promotionDossierExportGuard.test.js b/promotion-dossier-export-guard/test/promotionDossierExportGuard.test.js
new file mode 100644
index 00000000..653afb30
--- /dev/null
+++ b/promotion-dossier-export-guard/test/promotionDossierExportGuard.test.js
@@ -0,0 +1,81 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+const {
+ buildMarkdownReport,
+ buildSvgSummary,
+ evaluateBatch,
+ evaluateDossier
+} = require('../src');
+
+const packet = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '..', 'examples', 'dossier-packets.json'), 'utf8')
+);
+
+function dossier(id) {
+ return packet.dossiers.find((entry) => entry.id === id);
+}
+
+test('releases a dossier when consent, redaction, and evidence are present', () => {
+ const result = evaluateDossier(dossier('dossier-release'), {
+ asOf: packet.asOf,
+ policy: packet.policy
+ });
+
+ assert.equal(result.decision, 'RELEASE_DOSSIER');
+ assert.equal(result.reasons.length, 0);
+ assert.equal(result.exportSignals.blindReviewCount, 1);
+});
+
+test('holds exports for contributors who opted out', () => {
+ const result = evaluateDossier(dossier('dossier-opt-out'), {
+ asOf: packet.asOf,
+ policy: packet.policy
+ });
+
+ assert.equal(result.decision, 'HOLD_EXPORT');
+ assert.ok(result.reasons.some((reason) => reason.code === 'CONTRIBUTOR_OPT_OUT'));
+});
+
+test('holds exports with blind reviewer leaks and restricted review authorization gaps', () => {
+ const result = evaluateDossier(dossier('dossier-blind-leak'), {
+ asOf: packet.asOf,
+ policy: packet.policy
+ });
+
+ assert.equal(result.decision, 'HOLD_EXPORT');
+ assert.ok(result.reasons.some((reason) => reason.code === 'BLIND_REVIEW_IDENTITY_LEAK'));
+ assert.ok(
+ result.reasons.some((reason) => reason.code === 'RESTRICTED_REVIEW_EXPORT_NOT_AUTHORIZED')
+ );
+ assert.ok(result.reasons.some((reason) => reason.code === 'REPUTATION_DELTA_SOURCE_MISSING'));
+});
+
+test('routes stale or incomplete dossier evidence to review', () => {
+ const result = evaluateDossier(dossier('dossier-stale-review'), {
+ asOf: packet.asOf,
+ policy: packet.policy
+ });
+
+ assert.equal(result.decision, 'REVIEW_BEFORE_EXPORT');
+ assert.ok(result.reasons.some((reason) => reason.code === 'CREDIT_EVIDENCE_MISSING'));
+ assert.ok(result.reasons.some((reason) => reason.code === 'BADGE_EVIDENCE_STALE'));
+});
+
+test('batch summaries and reviewer artifacts are deterministic', () => {
+ const batch = evaluateBatch(packet);
+ const report = buildMarkdownReport(batch);
+ const svg = buildSvgSummary(batch);
+
+ assert.equal(batch.summary.totalDossiers, 4);
+ assert.equal(batch.summary.release, 1);
+ assert.equal(batch.summary.review, 1);
+ assert.equal(batch.summary.hold, 2);
+ assert.deepEqual(batch.summary.heldDossierIds, ['dossier-opt-out', 'dossier-blind-leak']);
+ assert.match(report, /Promotion Dossier Export Report/);
+ assert.match(report, /BLIND_REVIEW_IDENTITY_LEAK/);
+ assert.match(svg, /Promotion dossier export guard summary/);
+});