diff --git a/peer-review-template-rubric-guard/README.md b/peer-review-template-rubric-guard/README.md
new file mode 100644
index 00000000..3833d63d
--- /dev/null
+++ b/peer-review-template-rubric-guard/README.md
@@ -0,0 +1,33 @@
+# Peer Review Template Rubric Guard
+
+Self-contained reviewer module for issue #15, Community & User Reputation System.
+
+The guard validates discipline-specific structured peer-review templates before their reviews can affect project timelines, reviewer profiles, citation pages, or reputation deltas.
+
+## What It Does
+
+- Holds templates that miss required rubric dimensions: clarity, rigor, novelty, and reproducibility.
+- Blocks score scales outside a comparable 0..10 range.
+- Requires rubric evidence anchors for reviewed documents, datasets, code, and notebooks.
+- Routes anonymous or double-blind privacy gaps to curator review.
+- Requires reviewer declarations for conflicts, method expertise, and data access.
+- Checks profile/citation publication packets for rubric versioning and anonymous identity redaction.
+- Generates deterministic JSON, Markdown, SVG, and MP4 artifacts from synthetic fixtures only.
+
+## Files
+
+- `index.js` - dependency-free rubric evaluator and Markdown packet builder.
+- `sample-data.js` - synthetic templates for held, curator-review, and publish decisions.
+- `test.js` - Node tests for blocking, review-only, and publication-ready templates.
+- `demo.js` - report generator and optional MP4 artifact writer.
+- `requirements-map.md` - issue requirement mapping.
+- `reports/` - generated reviewer artifacts.
+
+## Run
+
+```bash
+node --test peer-review-template-rubric-guard/test.js
+FFMPEG_PATH=/path/to/ffmpeg node peer-review-template-rubric-guard/demo.js
+```
+
+No live reviewers, profiles, projects, identities, credentials, external services, or network calls are used.
diff --git a/peer-review-template-rubric-guard/demo.js b/peer-review-template-rubric-guard/demo.js
new file mode 100644
index 00000000..49262f43
--- /dev/null
+++ b/peer-review-template-rubric-guard/demo.js
@@ -0,0 +1,147 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const {spawnSync} = require('node:child_process');
+
+const {
+ evaluatePeerReviewTemplateRubric,
+ buildRubricGuardPacket,
+} = require('./index');
+const {scenarios} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+const framesDir = path.join(reportsDir, 'frames');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const evaluations = scenarios.map((scenario) => ({
+ scenario: scenario.name,
+ ...evaluatePeerReviewTemplateRubric(scenario),
+}));
+
+const decisionCounts = evaluations.reduce((counts, item) => {
+ counts[item.decision] = (counts[item.decision] || 0) + 1;
+ return counts;
+}, {});
+const totals = evaluations.reduce(
+ (sum, item) => {
+ sum.findings += item.summary.findingCount;
+ sum.blocking += item.summary.blockingFindingCount;
+ sum.review += item.summary.reviewFindingCount;
+ sum.actions += item.curatorActions.length;
+ return sum;
+ },
+ {findings: 0, blocking: 0, review: 0, actions: 0}
+);
+
+const packetJson = JSON.stringify(evaluations, null, 2);
+const reviewerPacket = evaluations.map(buildRubricGuardPacket).join('\n---\n');
+const svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'rubric-guard-packet.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'rubric-guard-review.md'), reviewerPacket);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) {
+ const [r, g, b] = color;
+ const height = Math.floor(buffer.length / (width * 3));
+ const x1 = Math.min(width, x0 + rectWidth);
+ const y1 = Math.min(height, y0 + rectHeight);
+ for (let y = Math.max(0, y0); y < y1; y += 1) {
+ for (let x = Math.max(0, x0); x < x1; x += 1) {
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+ }
+ }
+}
+
+function writePpmFrame(filePath, frameIndex, frameCount) {
+ const width = 640;
+ const height = 360;
+ const buffer = Buffer.alloc(width * height * 3);
+ for (let i = 0; i < width * height; i += 1) {
+ buffer[i * 3] = 23;
+ buffer[i * 3 + 1] = 32;
+ buffer[i * 3 + 2] = 42;
+ }
+
+ const progress = frameIndex / Math.max(1, frameCount - 1);
+ fillRect(buffer, width, 42, 42, 556, 42, [248, 250, 252]);
+ fillRect(buffer, width, 42, 112, 150, 118, [127, 29, 29]);
+ fillRect(buffer, width, 245, 112, 150, 118, [133, 77, 14]);
+ fillRect(buffer, width, 448, 112, 150, 118, [22, 101, 52]);
+ fillRect(buffer, width, 78, 260, 112, 30, [248, 113, 113]);
+ fillRect(buffer, width, 264, 260, 112, 30, [251, 191, 36]);
+ fillRect(buffer, width, 450, 260, 112, 30, [74, 222, 128]);
+ fillRect(buffer, width, 42, 318, Math.round(556 * progress), 18, [191, 219, 254]);
+ fillRect(buffer, width, 42 + Math.round(508 * progress), 309, 48, 36, [241, 245, 249]);
+
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
+ fs.writeFileSync(filePath, Buffer.concat([header, buffer]));
+}
+
+function createDemoVideo() {
+ const ffmpegPath = process.env.FFMPEG_PATH;
+ if (!ffmpegPath) {
+ console.log('FFMPEG_PATH not set; skipped MP4 generation.');
+ return;
+ }
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ fs.mkdirSync(framesDir, {recursive: true});
+ const frameCount = 72;
+ for (let index = 0; index < frameCount; index += 1) {
+ writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount);
+ }
+
+ const output = path.join(reportsDir, 'demo.mp4');
+ const result = spawnSync(ffmpegPath, [
+ '-y',
+ '-framerate',
+ '24',
+ '-i',
+ path.join(framesDir, 'frame-%03d.ppm'),
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ output,
+ ], {encoding: 'utf8'});
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ if (result.status !== 0) {
+ throw new Error(result.stderr || 'ffmpeg failed');
+ }
+}
+
+createDemoVideo();
+
+console.log(JSON.stringify({
+ scenarios: evaluations.length,
+ reportsDir,
+ decisions: decisionCounts,
+ totals,
+}, null, 2));
diff --git a/peer-review-template-rubric-guard/index.js b/peer-review-template-rubric-guard/index.js
new file mode 100644
index 00000000..8695db72
--- /dev/null
+++ b/peer-review-template-rubric-guard/index.js
@@ -0,0 +1,262 @@
+const REQUIRED_DIMENSIONS = ['clarity', 'rigor', 'novelty', 'reproducibility'];
+const REQUIRED_DECLARATIONS = ['conflictOfInterest', 'methodExpertise', 'dataAccess'];
+
+function list(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function clean(value) {
+ return String(value || '').trim();
+}
+
+function normalize(value) {
+ return clean(value).toLowerCase();
+}
+
+function finding(type, severity, subject, message) {
+ return {type, severity, subject, message};
+}
+
+function action(type, target, reason) {
+ return {type, target, reason};
+}
+
+function dimensionNames(input) {
+ return new Set(list(input.dimensions).map((dimension) => normalize(dimension.name)));
+}
+
+function validateRequiredDimensions(input, findings, curatorActions) {
+ const names = dimensionNames(input);
+ for (const required of REQUIRED_DIMENSIONS) {
+ if (!names.has(required)) {
+ findings.push(
+ finding(
+ 'missing-required-rubric-dimension',
+ 'block',
+ required,
+ `Template is missing the required ${required} rubric dimension.`
+ )
+ );
+ curatorActions.push(
+ action(
+ 'add-rubric-dimension',
+ required,
+ `Add a ${required} scoring dimension before this peer-review template can affect reputation.`
+ )
+ );
+ }
+ }
+}
+
+function validateScoreScales(input, findings, curatorActions) {
+ for (const dimension of list(input.dimensions)) {
+ const scale = dimension.scoreScale || {};
+ const min = Number(scale.min);
+ const max = Number(scale.max);
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max > 10 || min >= max) {
+ findings.push(
+ finding(
+ 'score-scale-out-of-bounds',
+ 'block',
+ dimension.name || 'unknown dimension',
+ `${dimension.name || 'Rubric dimension'} uses score scale ${scale.min}..${scale.max}, outside the supported 0..10 increasing range.`
+ )
+ );
+ curatorActions.push(
+ action(
+ 'normalize-score-scale',
+ dimension.name || 'unknown dimension',
+ 'Use an increasing numeric score scale within 0..10 so reputation deltas are comparable.'
+ )
+ );
+ }
+ }
+}
+
+function validateEvidenceAnchors(input, findings, curatorActions) {
+ const coveredArtifacts = new Set();
+ for (const dimension of list(input.dimensions)) {
+ for (const anchor of list(dimension.evidenceAnchors)) {
+ coveredArtifacts.add(normalize(anchor));
+ }
+ }
+
+ for (const artifactType of list(input.reviewedArtifactTypes)) {
+ if (!coveredArtifacts.has(normalize(artifactType))) {
+ findings.push(
+ finding(
+ 'evidence-anchor-missing',
+ 'block',
+ artifactType,
+ `No rubric dimension requires evidence anchored to ${artifactType}.`
+ )
+ );
+ curatorActions.push(
+ action(
+ 'add-evidence-anchor',
+ artifactType,
+ `Require at least one scoring dimension to cite ${artifactType} evidence before profile or timeline credit is computed.`
+ )
+ );
+ }
+ }
+}
+
+function validateReviewMode(input, findings, curatorActions) {
+ const visibility = normalize(input.visibilityMode);
+ const mode = input.reviewMode || {};
+ const requiresBlindSafety = visibility.includes('blind') || visibility.includes('anonymous');
+ const safeLabel = !['real-name', 'public-name'].includes(normalize(mode.anonymousLabelPolicy));
+ if (requiresBlindSafety && (!mode.authorIdentityHidden || !mode.reviewerIdentityHidden || !safeLabel)) {
+ findings.push(
+ finding(
+ 'anonymous-mode-privacy-gap',
+ 'review',
+ input.visibilityMode || 'review mode',
+ 'Anonymous or blind review mode does not hide both author and reviewer identities with a safe label policy.'
+ )
+ );
+ curatorActions.push(
+ action(
+ 'repair-review-mode-privacy',
+ input.templateId || 'template',
+ 'Hide author and reviewer identities and replace real-name labels before publishing review receipts.'
+ )
+ );
+ }
+}
+
+function validateReviewerDeclarations(input, findings, curatorActions) {
+ const declarations = input.reviewerDeclarations || {};
+ for (const declaration of REQUIRED_DECLARATIONS) {
+ if (declarations[declaration] !== true) {
+ findings.push(
+ finding(
+ 'reviewer-declaration-incomplete',
+ 'review',
+ declaration,
+ `Reviewer declaration ${declaration} must be completed before reputation deltas are trusted.`
+ )
+ );
+ curatorActions.push(
+ action(
+ 'complete-reviewer-declaration',
+ declaration,
+ 'Require reviewer attestations before template output affects profiles or leaderboards.'
+ )
+ );
+ }
+ }
+}
+
+function validateProfilePacket(input, findings, curatorActions) {
+ const targets = new Set(list(input.publicationTargets).map(normalize));
+ const packet = input.profilePacket || {};
+ const needsProfileSafety = targets.has('reviewer-profile') || targets.has('citation-page');
+ if (needsProfileSafety && (!packet.includesRubricVersion || !packet.redactsAnonymousIdentities)) {
+ findings.push(
+ finding(
+ 'profile-publication-packet-incomplete',
+ 'review',
+ input.templateId || 'template',
+ 'Profile or citation-page publication packet must include rubric versioning and anonymous identity redaction.'
+ )
+ );
+ curatorActions.push(
+ action(
+ 'complete-profile-publication-packet',
+ input.templateId || 'template',
+ 'Include rubric version metadata and anonymous identity redaction before profile or citation publication.'
+ )
+ );
+ }
+}
+
+function summarize(findings) {
+ const blockingFindingCount = findings.filter((item) => item.severity === 'block').length;
+ const reviewFindingCount = findings.filter((item) => item.severity !== 'block').length;
+ return {
+ findingCount: findings.length,
+ blockingFindingCount,
+ reviewFindingCount,
+ };
+}
+
+function evaluatePeerReviewTemplateRubric(input = {}) {
+ const findings = [];
+ const curatorActions = [];
+
+ validateRequiredDimensions(input, findings, curatorActions);
+ validateScoreScales(input, findings, curatorActions);
+ validateEvidenceAnchors(input, findings, curatorActions);
+ validateReviewMode(input, findings, curatorActions);
+ validateReviewerDeclarations(input, findings, curatorActions);
+ validateProfilePacket(input, findings, curatorActions);
+
+ const summary = summarize(findings);
+ const decision = summary.blockingFindingCount > 0
+ ? 'hold-template'
+ : summary.reviewFindingCount > 0
+ ? 'curator-review'
+ : 'publish-template';
+ const profileUnsafeFindingTypes = new Set(['anonymous-mode-privacy-gap', 'profile-publication-packet-incomplete']);
+ const profileSafe = !findings.some((item) => profileUnsafeFindingTypes.has(item.type));
+
+ return {
+ generatedAt: input.generatedAt || new Date(0).toISOString(),
+ templateId: input.templateId || 'unknown-template',
+ discipline: input.discipline || 'unknown-discipline',
+ decision,
+ summary,
+ findings,
+ curatorActions,
+ templatePacket: {
+ templateId: input.templateId || 'unknown-template',
+ discipline: input.discipline || 'unknown-discipline',
+ decision,
+ profileSafe,
+ rubricScore: Math.max(0, 100 - summary.blockingFindingCount * 15 - summary.reviewFindingCount * 8),
+ requiredDimensions: REQUIRED_DIMENSIONS,
+ reviewedArtifactTypes: list(input.reviewedArtifactTypes),
+ publicationTargets: list(input.publicationTargets),
+ },
+ };
+}
+
+function buildRubricGuardPacket(result) {
+ const lines = [
+ `# Peer Review Template Rubric Guard: ${result.templateId}`,
+ '',
+ `Discipline: ${result.discipline}`,
+ `Decision: ${result.decision}`,
+ `Generated: ${result.generatedAt}`,
+ `Rubric score: ${result.templatePacket.rubricScore}`,
+ `Profile safe: ${result.templatePacket.profileSafe}`,
+ '',
+ '## Findings',
+ ];
+
+ if (result.findings.length === 0) {
+ lines.push('- None');
+ } else {
+ for (const item of result.findings) {
+ lines.push(`- ${item.type}: ${item.subject} - ${item.message}`);
+ }
+ }
+
+ lines.push('', '## Curator Actions');
+ if (result.curatorActions.length === 0) {
+ lines.push('- No curator action required');
+ } else {
+ for (const item of result.curatorActions) {
+ lines.push(`- ${item.type}: ${item.target} - ${item.reason}`);
+ }
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+module.exports = {
+ evaluatePeerReviewTemplateRubric,
+ buildRubricGuardPacket,
+};
diff --git a/peer-review-template-rubric-guard/reports/demo.mp4 b/peer-review-template-rubric-guard/reports/demo.mp4
new file mode 100644
index 00000000..d8d69958
Binary files /dev/null and b/peer-review-template-rubric-guard/reports/demo.mp4 differ
diff --git a/peer-review-template-rubric-guard/reports/rubric-guard-packet.json b/peer-review-template-rubric-guard/reports/rubric-guard-packet.json
new file mode 100644
index 00000000..b2374649
--- /dev/null
+++ b/peer-review-template-rubric-guard/reports/rubric-guard-packet.json
@@ -0,0 +1,224 @@
+[
+ {
+ "scenario": "Biology fast-track template misses rubric and privacy requirements",
+ "generatedAt": "2026-05-22T17:00:00Z",
+ "templateId": "tpl-biology-fast-track",
+ "discipline": "biology",
+ "decision": "hold-template",
+ "summary": {
+ "findingCount": 8,
+ "blockingFindingCount": 6,
+ "reviewFindingCount": 2
+ },
+ "findings": [
+ {
+ "type": "missing-required-rubric-dimension",
+ "severity": "block",
+ "subject": "rigor",
+ "message": "Template is missing the required rigor rubric dimension."
+ },
+ {
+ "type": "missing-required-rubric-dimension",
+ "severity": "block",
+ "subject": "reproducibility",
+ "message": "Template is missing the required reproducibility rubric dimension."
+ },
+ {
+ "type": "score-scale-out-of-bounds",
+ "severity": "block",
+ "subject": "novelty",
+ "message": "novelty uses score scale -1..12, outside the supported 0..10 increasing range."
+ },
+ {
+ "type": "evidence-anchor-missing",
+ "severity": "block",
+ "subject": "dataset",
+ "message": "No rubric dimension requires evidence anchored to dataset."
+ },
+ {
+ "type": "evidence-anchor-missing",
+ "severity": "block",
+ "subject": "code",
+ "message": "No rubric dimension requires evidence anchored to code."
+ },
+ {
+ "type": "evidence-anchor-missing",
+ "severity": "block",
+ "subject": "notebook",
+ "message": "No rubric dimension requires evidence anchored to notebook."
+ },
+ {
+ "type": "anonymous-mode-privacy-gap",
+ "severity": "review",
+ "subject": "double-blind",
+ "message": "Anonymous or blind review mode does not hide both author and reviewer identities with a safe label policy."
+ },
+ {
+ "type": "profile-publication-packet-incomplete",
+ "severity": "review",
+ "subject": "tpl-biology-fast-track",
+ "message": "Profile or citation-page publication packet must include rubric versioning and anonymous identity redaction."
+ }
+ ],
+ "curatorActions": [
+ {
+ "type": "add-rubric-dimension",
+ "target": "rigor",
+ "reason": "Add a rigor scoring dimension before this peer-review template can affect reputation."
+ },
+ {
+ "type": "add-rubric-dimension",
+ "target": "reproducibility",
+ "reason": "Add a reproducibility scoring dimension before this peer-review template can affect reputation."
+ },
+ {
+ "type": "normalize-score-scale",
+ "target": "novelty",
+ "reason": "Use an increasing numeric score scale within 0..10 so reputation deltas are comparable."
+ },
+ {
+ "type": "add-evidence-anchor",
+ "target": "dataset",
+ "reason": "Require at least one scoring dimension to cite dataset evidence before profile or timeline credit is computed."
+ },
+ {
+ "type": "add-evidence-anchor",
+ "target": "code",
+ "reason": "Require at least one scoring dimension to cite code evidence before profile or timeline credit is computed."
+ },
+ {
+ "type": "add-evidence-anchor",
+ "target": "notebook",
+ "reason": "Require at least one scoring dimension to cite notebook evidence before profile or timeline credit is computed."
+ },
+ {
+ "type": "repair-review-mode-privacy",
+ "target": "tpl-biology-fast-track",
+ "reason": "Hide author and reviewer identities and replace real-name labels before publishing review receipts."
+ },
+ {
+ "type": "complete-profile-publication-packet",
+ "target": "tpl-biology-fast-track",
+ "reason": "Include rubric version metadata and anonymous identity redaction before profile or citation publication."
+ }
+ ],
+ "templatePacket": {
+ "templateId": "tpl-biology-fast-track",
+ "discipline": "biology",
+ "decision": "hold-template",
+ "profileSafe": false,
+ "rubricScore": 0,
+ "requiredDimensions": [
+ "clarity",
+ "rigor",
+ "novelty",
+ "reproducibility"
+ ],
+ "reviewedArtifactTypes": [
+ "document",
+ "dataset",
+ "code",
+ "notebook"
+ ],
+ "publicationTargets": [
+ "project-timeline",
+ "reviewer-profile"
+ ]
+ }
+ },
+ {
+ "scenario": "Physics methods template needs reviewer declarations",
+ "generatedAt": "2026-05-22T17:10:00Z",
+ "templateId": "tpl-physics-methods",
+ "discipline": "physics",
+ "decision": "curator-review",
+ "summary": {
+ "findingCount": 2,
+ "blockingFindingCount": 0,
+ "reviewFindingCount": 2
+ },
+ "findings": [
+ {
+ "type": "reviewer-declaration-incomplete",
+ "severity": "review",
+ "subject": "conflictOfInterest",
+ "message": "Reviewer declaration conflictOfInterest must be completed before reputation deltas are trusted."
+ },
+ {
+ "type": "reviewer-declaration-incomplete",
+ "severity": "review",
+ "subject": "dataAccess",
+ "message": "Reviewer declaration dataAccess must be completed before reputation deltas are trusted."
+ }
+ ],
+ "curatorActions": [
+ {
+ "type": "complete-reviewer-declaration",
+ "target": "conflictOfInterest",
+ "reason": "Require reviewer attestations before template output affects profiles or leaderboards."
+ },
+ {
+ "type": "complete-reviewer-declaration",
+ "target": "dataAccess",
+ "reason": "Require reviewer attestations before template output affects profiles or leaderboards."
+ }
+ ],
+ "templatePacket": {
+ "templateId": "tpl-physics-methods",
+ "discipline": "physics",
+ "decision": "curator-review",
+ "profileSafe": true,
+ "rubricScore": 84,
+ "requiredDimensions": [
+ "clarity",
+ "rigor",
+ "novelty",
+ "reproducibility"
+ ],
+ "reviewedArtifactTypes": [
+ "document",
+ "code"
+ ],
+ "publicationTargets": [
+ "project-timeline"
+ ]
+ }
+ },
+ {
+ "scenario": "Social-science open review template is publication ready",
+ "generatedAt": "2026-05-22T17:20:00Z",
+ "templateId": "tpl-social-science-open-review",
+ "discipline": "social-sciences",
+ "decision": "publish-template",
+ "summary": {
+ "findingCount": 0,
+ "blockingFindingCount": 0,
+ "reviewFindingCount": 0
+ },
+ "findings": [],
+ "curatorActions": [],
+ "templatePacket": {
+ "templateId": "tpl-social-science-open-review",
+ "discipline": "social-sciences",
+ "decision": "publish-template",
+ "profileSafe": true,
+ "rubricScore": 100,
+ "requiredDimensions": [
+ "clarity",
+ "rigor",
+ "novelty",
+ "reproducibility"
+ ],
+ "reviewedArtifactTypes": [
+ "document",
+ "dataset",
+ "notebook"
+ ],
+ "publicationTargets": [
+ "project-timeline",
+ "reviewer-profile",
+ "citation-page"
+ ]
+ }
+ }
+]
diff --git a/peer-review-template-rubric-guard/reports/rubric-guard-review.md b/peer-review-template-rubric-guard/reports/rubric-guard-review.md
new file mode 100644
index 00000000..7d2fec88
--- /dev/null
+++ b/peer-review-template-rubric-guard/reports/rubric-guard-review.md
@@ -0,0 +1,59 @@
+# Peer Review Template Rubric Guard: tpl-biology-fast-track
+
+Discipline: biology
+Decision: hold-template
+Generated: 2026-05-22T17:00:00Z
+Rubric score: 0
+Profile safe: false
+
+## Findings
+- missing-required-rubric-dimension: rigor - Template is missing the required rigor rubric dimension.
+- missing-required-rubric-dimension: reproducibility - Template is missing the required reproducibility rubric dimension.
+- score-scale-out-of-bounds: novelty - novelty uses score scale -1..12, outside the supported 0..10 increasing range.
+- evidence-anchor-missing: dataset - No rubric dimension requires evidence anchored to dataset.
+- evidence-anchor-missing: code - No rubric dimension requires evidence anchored to code.
+- evidence-anchor-missing: notebook - No rubric dimension requires evidence anchored to notebook.
+- anonymous-mode-privacy-gap: double-blind - Anonymous or blind review mode does not hide both author and reviewer identities with a safe label policy.
+- profile-publication-packet-incomplete: tpl-biology-fast-track - Profile or citation-page publication packet must include rubric versioning and anonymous identity redaction.
+
+## Curator Actions
+- add-rubric-dimension: rigor - Add a rigor scoring dimension before this peer-review template can affect reputation.
+- add-rubric-dimension: reproducibility - Add a reproducibility scoring dimension before this peer-review template can affect reputation.
+- normalize-score-scale: novelty - Use an increasing numeric score scale within 0..10 so reputation deltas are comparable.
+- add-evidence-anchor: dataset - Require at least one scoring dimension to cite dataset evidence before profile or timeline credit is computed.
+- add-evidence-anchor: code - Require at least one scoring dimension to cite code evidence before profile or timeline credit is computed.
+- add-evidence-anchor: notebook - Require at least one scoring dimension to cite notebook evidence before profile or timeline credit is computed.
+- repair-review-mode-privacy: tpl-biology-fast-track - Hide author and reviewer identities and replace real-name labels before publishing review receipts.
+- complete-profile-publication-packet: tpl-biology-fast-track - Include rubric version metadata and anonymous identity redaction before profile or citation publication.
+
+---
+# Peer Review Template Rubric Guard: tpl-physics-methods
+
+Discipline: physics
+Decision: curator-review
+Generated: 2026-05-22T17:10:00Z
+Rubric score: 84
+Profile safe: true
+
+## Findings
+- reviewer-declaration-incomplete: conflictOfInterest - Reviewer declaration conflictOfInterest must be completed before reputation deltas are trusted.
+- reviewer-declaration-incomplete: dataAccess - Reviewer declaration dataAccess must be completed before reputation deltas are trusted.
+
+## Curator Actions
+- complete-reviewer-declaration: conflictOfInterest - Require reviewer attestations before template output affects profiles or leaderboards.
+- complete-reviewer-declaration: dataAccess - Require reviewer attestations before template output affects profiles or leaderboards.
+
+---
+# Peer Review Template Rubric Guard: tpl-social-science-open-review
+
+Discipline: social-sciences
+Decision: publish-template
+Generated: 2026-05-22T17:20:00Z
+Rubric score: 100
+Profile safe: true
+
+## Findings
+- None
+
+## Curator Actions
+- No curator action required
diff --git a/peer-review-template-rubric-guard/reports/summary.svg b/peer-review-template-rubric-guard/reports/summary.svg
new file mode 100644
index 00000000..3e08bf25
--- /dev/null
+++ b/peer-review-template-rubric-guard/reports/summary.svg
@@ -0,0 +1,23 @@
+
diff --git a/peer-review-template-rubric-guard/requirements-map.md b/peer-review-template-rubric-guard/requirements-map.md
new file mode 100644
index 00000000..a0c24fee
--- /dev/null
+++ b/peer-review-template-rubric-guard/requirements-map.md
@@ -0,0 +1,18 @@
+# Requirements Map
+
+Issue #15 asks for structured peer review, contribution credit, profile history, transparent reputation scoring, leaderboards, and badge systems. This slice focuses on the structured peer-review template gate that protects downstream reputation from malformed review inputs.
+
+| Issue requirement | Implementation coverage |
+| --- | --- |
+| Discipline-specific peer-review templates | `discipline` and template fixture scenarios model biology, physics, and social-science templates. |
+| Optional scoring on clarity, rigor, novelty, reproducibility | Required rubric dimensions are enforced by `evaluatePeerReviewTemplateRubric`. |
+| Inline comments on documents, datasets, code, and notebooks | `reviewedArtifactTypes` and `evidenceAnchors` require rubric dimensions to cite relevant artifacts before credit is computed. |
+| Public, semi-private, anonymous, blind, and double-blind review modes | `visibilityMode` and `reviewMode` checks detect privacy gaps before profile or timeline publication. |
+| Review history tracked on profiles and timelines | `publicationTargets` plus `profilePacket` ensure rubric versioning and anonymous identity redaction before publication. |
+| Transparent reputation metrics | Findings and curator actions explain why a template is held, routed to review, or safe to publish. |
+| Reviewer-ready demo | `demo.js` writes JSON, Markdown, SVG, and H.264 MP4 artifacts in `reports/`. |
+| Safe local verification | `test.js` covers hold, curator review, and publish decisions without external calls or secrets. |
+
+## Distinctness
+
+This module avoids duplicating existing issue #15 slices by staying narrow: it does not implement a broad community ledger, endorsement ring guard, badge or leaderboard gate, review civility checker, timeliness module, recusal/COI guard, calibration bench, edit-history integrity guard, identity-leak-only guard, profile visibility guard, appeals queue, mentorship ledger, correction-impact ledger, or contributor credit attestation module. It validates review template structure before any downstream reputation logic runs.
diff --git a/peer-review-template-rubric-guard/sample-data.js b/peer-review-template-rubric-guard/sample-data.js
new file mode 100644
index 00000000..829a8450
--- /dev/null
+++ b/peer-review-template-rubric-guard/sample-data.js
@@ -0,0 +1,68 @@
+const scenarios = [
+ {
+ name: 'Biology fast-track template misses rubric and privacy requirements',
+ generatedAt: '2026-05-22T17:00:00Z',
+ templateId: 'tpl-biology-fast-track',
+ discipline: 'biology',
+ reviewedArtifactTypes: ['document', 'dataset', 'code', 'notebook'],
+ visibilityMode: 'double-blind',
+ dimensions: [
+ {name: 'clarity', weight: 0.3, scoreScale: {min: 0, max: 10}, evidenceAnchors: ['document']},
+ {name: 'novelty', weight: 0.5, scoreScale: {min: -1, max: 12}, evidenceAnchors: ['document']},
+ ],
+ reviewMode: {
+ authorIdentityHidden: true,
+ reviewerIdentityHidden: false,
+ anonymousLabelPolicy: 'real-name',
+ },
+ reviewerDeclarations: {conflictOfInterest: true, methodExpertise: true, dataAccess: true},
+ publicationTargets: ['project-timeline', 'reviewer-profile'],
+ profilePacket: {includesRubricVersion: true, redactsAnonymousIdentities: false},
+ },
+ {
+ name: 'Physics methods template needs reviewer declarations',
+ generatedAt: '2026-05-22T17:10:00Z',
+ templateId: 'tpl-physics-methods',
+ discipline: 'physics',
+ reviewedArtifactTypes: ['document', 'code'],
+ visibilityMode: 'semi-private',
+ dimensions: [
+ {name: 'clarity', weight: 0.25, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document']},
+ {name: 'rigor', weight: 0.35, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document', 'code']},
+ {name: 'novelty', weight: 0.2, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document']},
+ {name: 'reproducibility', weight: 0.2, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['code']},
+ ],
+ reviewMode: {
+ authorIdentityHidden: false,
+ reviewerIdentityHidden: false,
+ anonymousLabelPolicy: 'visible-to-authors',
+ },
+ reviewerDeclarations: {conflictOfInterest: false, methodExpertise: true, dataAccess: false},
+ publicationTargets: ['project-timeline'],
+ profilePacket: {includesRubricVersion: true, redactsAnonymousIdentities: true},
+ },
+ {
+ name: 'Social-science open review template is publication ready',
+ generatedAt: '2026-05-22T17:20:00Z',
+ templateId: 'tpl-social-science-open-review',
+ discipline: 'social-sciences',
+ reviewedArtifactTypes: ['document', 'dataset', 'notebook'],
+ visibilityMode: 'public',
+ dimensions: [
+ {name: 'clarity', weight: 0.2, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document']},
+ {name: 'rigor', weight: 0.35, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document', 'dataset']},
+ {name: 'novelty', weight: 0.2, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document']},
+ {name: 'reproducibility', weight: 0.25, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['dataset', 'notebook']},
+ ],
+ reviewMode: {
+ authorIdentityHidden: false,
+ reviewerIdentityHidden: false,
+ anonymousLabelPolicy: 'public-name',
+ },
+ reviewerDeclarations: {conflictOfInterest: true, methodExpertise: true, dataAccess: true},
+ publicationTargets: ['project-timeline', 'reviewer-profile', 'citation-page'],
+ profilePacket: {includesRubricVersion: true, redactsAnonymousIdentities: true},
+ },
+];
+
+module.exports = {scenarios};
diff --git a/peer-review-template-rubric-guard/test.js b/peer-review-template-rubric-guard/test.js
new file mode 100644
index 00000000..03b9cbf7
--- /dev/null
+++ b/peer-review-template-rubric-guard/test.js
@@ -0,0 +1,110 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluatePeerReviewTemplateRubric,
+ buildRubricGuardPacket,
+} = require('./index');
+
+test('holds structured review templates that miss required dimensions and evidence anchors', () => {
+ const result = evaluatePeerReviewTemplateRubric({
+ generatedAt: '2026-05-22T17:00:00Z',
+ templateId: 'tpl-biology-fast-track',
+ discipline: 'biology',
+ reviewedArtifactTypes: ['document', 'dataset', 'code', 'notebook'],
+ visibilityMode: 'double-blind',
+ dimensions: [
+ {name: 'clarity', weight: 0.3, scoreScale: {min: 0, max: 10}, evidenceAnchors: ['document']},
+ {name: 'novelty', weight: 0.5, scoreScale: {min: -1, max: 12}, evidenceAnchors: ['document']},
+ ],
+ reviewMode: {
+ authorIdentityHidden: true,
+ reviewerIdentityHidden: false,
+ anonymousLabelPolicy: 'real-name',
+ },
+ reviewerDeclarations: {conflictOfInterest: true, methodExpertise: true, dataAccess: true},
+ publicationTargets: ['project-timeline', 'reviewer-profile'],
+ profilePacket: {includesRubricVersion: true, redactsAnonymousIdentities: false},
+ });
+
+ assert.equal(result.decision, 'hold-template');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ [
+ 'missing-required-rubric-dimension',
+ 'missing-required-rubric-dimension',
+ 'score-scale-out-of-bounds',
+ 'evidence-anchor-missing',
+ 'evidence-anchor-missing',
+ 'evidence-anchor-missing',
+ 'anonymous-mode-privacy-gap',
+ 'profile-publication-packet-incomplete',
+ ]
+ );
+ assert.equal(result.summary.blockingFindingCount, 6);
+ assert.match(result.curatorActions[0].reason, /rigor/i);
+});
+
+test('routes otherwise valid templates to curator review when reviewer declarations are incomplete', () => {
+ const result = evaluatePeerReviewTemplateRubric({
+ generatedAt: '2026-05-22T17:10:00Z',
+ templateId: 'tpl-physics-methods',
+ discipline: 'physics',
+ reviewedArtifactTypes: ['document', 'code'],
+ visibilityMode: 'semi-private',
+ dimensions: [
+ {name: 'clarity', weight: 0.25, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document']},
+ {name: 'rigor', weight: 0.35, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document', 'code']},
+ {name: 'novelty', weight: 0.2, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document']},
+ {name: 'reproducibility', weight: 0.2, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['code']},
+ ],
+ reviewMode: {
+ authorIdentityHidden: false,
+ reviewerIdentityHidden: false,
+ anonymousLabelPolicy: 'visible-to-authors',
+ },
+ reviewerDeclarations: {conflictOfInterest: false, methodExpertise: true, dataAccess: false},
+ publicationTargets: ['project-timeline'],
+ profilePacket: {includesRubricVersion: true, redactsAnonymousIdentities: true},
+ });
+
+ assert.equal(result.decision, 'curator-review');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ ['reviewer-declaration-incomplete', 'reviewer-declaration-incomplete']
+ );
+ assert.equal(result.summary.reviewFindingCount, 2);
+ assert.equal(result.templatePacket.profileSafe, true);
+});
+
+test('publishes complete rubric templates and builds a profile-safe review packet', () => {
+ const result = evaluatePeerReviewTemplateRubric({
+ generatedAt: '2026-05-22T17:20:00Z',
+ templateId: 'tpl-social-science-open-review',
+ discipline: 'social-sciences',
+ reviewedArtifactTypes: ['document', 'dataset', 'notebook'],
+ visibilityMode: 'public',
+ dimensions: [
+ {name: 'clarity', weight: 0.2, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document']},
+ {name: 'rigor', weight: 0.35, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document', 'dataset']},
+ {name: 'novelty', weight: 0.2, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['document']},
+ {name: 'reproducibility', weight: 0.25, scoreScale: {min: 1, max: 5}, evidenceAnchors: ['dataset', 'notebook']},
+ ],
+ reviewMode: {
+ authorIdentityHidden: false,
+ reviewerIdentityHidden: false,
+ anonymousLabelPolicy: 'public-name',
+ },
+ reviewerDeclarations: {conflictOfInterest: true, methodExpertise: true, dataAccess: true},
+ publicationTargets: ['project-timeline', 'reviewer-profile', 'citation-page'],
+ profilePacket: {includesRubricVersion: true, redactsAnonymousIdentities: true},
+ });
+ const packet = buildRubricGuardPacket(result);
+
+ assert.equal(result.decision, 'publish-template');
+ assert.equal(result.summary.findingCount, 0);
+ assert.equal(result.templatePacket.templateId, 'tpl-social-science-open-review');
+ assert.equal(result.templatePacket.rubricScore, 100);
+ assert.match(packet, /social-sciences/);
+ assert.match(packet, /No curator action required/);
+});