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 @@ + + Promotion dossier export guard summary + Release, review, and hold counts for synthetic institution-facing reputation dossier exports. + + Promotion Dossier Export Guard + Synthetic reviewer packet for Community & Reputation issue #15 + + + + Release: 1 | Review: 1 | Hold: 2 + Dr. Lina ParkRELEASE_DOSSIERDr. Amara SinghHOLD_EXPORTDr. Theo ImaniHOLD_EXPORTDr. Noura BellREVIEW_BEFORE_EXPORT + 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 ` + Promotion dossier export guard summary + Release, review, and hold counts for synthetic institution-facing reputation dossier exports. + + Promotion Dossier Export Guard + Synthetic reviewer packet for Community & Reputation issue #15 + + + + Release: ${batch.summary.release} | Review: ${batch.summary.review} | Hold: ${batch.summary.hold} + ${rows} + +`; +} + +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/); +});