From 1b0748fae03d0a3c2a9536dd4434e1e9ffa7441e Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 08:18:39 +0200 Subject: [PATCH 01/22] Add peer review evidence recertification guard --- .../README.md | 23 ++ .../acceptance-notes.md | 15 + .../demo.js | 74 ++++ .../index.js | 334 ++++++++++++++++++ .../make-demo-video.py | 41 +++ .../package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 45287 bytes .../reports/recertification-packet.json | 225 ++++++++++++ .../reports/recertification-report.md | 28 ++ .../reports/summary.svg | 12 + .../requirements-map.md | 26 ++ .../test.js | 97 +++++ 12 files changed, 888 insertions(+) create mode 100644 peer-review-evidence-recertification-guard/README.md create mode 100644 peer-review-evidence-recertification-guard/acceptance-notes.md create mode 100644 peer-review-evidence-recertification-guard/demo.js create mode 100644 peer-review-evidence-recertification-guard/index.js create mode 100644 peer-review-evidence-recertification-guard/make-demo-video.py create mode 100644 peer-review-evidence-recertification-guard/package.json create mode 100644 peer-review-evidence-recertification-guard/reports/demo.mp4 create mode 100644 peer-review-evidence-recertification-guard/reports/recertification-packet.json create mode 100644 peer-review-evidence-recertification-guard/reports/recertification-report.md create mode 100644 peer-review-evidence-recertification-guard/reports/summary.svg create mode 100644 peer-review-evidence-recertification-guard/requirements-map.md create mode 100644 peer-review-evidence-recertification-guard/test.js diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md new file mode 100644 index 00000000..ba5e93f7 --- /dev/null +++ b/peer-review-evidence-recertification-guard/README.md @@ -0,0 +1,23 @@ +# Peer Review Evidence Recertification Guard + +This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. + +The guard freezes stale review reputation deltas, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. + +## Run + +```bash +npm test +npm run demo +npm run video +npm run check +``` + +## Outputs + +- `reports/recertification-packet.json` +- `reports/recertification-report.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +All data is synthetic. The module does not use credentials, private users, live profile systems, payment systems, or external APIs. diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md new file mode 100644 index 00000000..05a0191e --- /dev/null +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -0,0 +1,15 @@ +# Acceptance Notes + +The implemented slice is intentionally distinct from existing #15 submissions: + +- It is not a broad reputation ledger. +- It is not COI, recusal, civility, workload, accessibility, rubric validation, edit history, badge renewal, or profile visibility work. +- It focuses on stale review evidence after artifact revisions and the recertification workflow needed before reputation updates are allowed. + +Validation targets: + +- stale dataset review freezes an 18 point reputation delta +- recertified code review keeps its 14 point reputation delta +- double-blind reviewer identity is not leaked in tasks or timeline events +- stale inline comment anchors generate comment-specific recertification tasks +- timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/demo.js b/peer-review-evidence-recertification-guard/demo.js new file mode 100644 index 00000000..a977b6a9 --- /dev/null +++ b/peer-review-evidence-recertification-guard/demo.js @@ -0,0 +1,74 @@ +const fs = require('fs'); +const path = require('path'); +const { evaluateRecertification, buildSampleProject } = require('./index'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const project = buildSampleProject(); +const result = evaluateRecertification(project); + +const packetPath = path.join(reportsDir, 'recertification-packet.json'); +const reportPath = path.join(reportsDir, 'recertification-report.md'); +const svgPath = path.join(reportsDir, 'summary.svg'); + +fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); + +const staleReviewList = result.reviewDecisions + .filter((decision) => decision.status !== 'current') + .map((decision) => `- ${decision.id}: ${decision.reasons.join(', ')}`) + .join('\n'); + +const taskList = result.recertificationTasks + .map((task) => `- ${task.id} (${task.kind}, ${task.priority}): ${task.requiredAction}`) + .join('\n'); + +const markdown = `# Peer Review Evidence Recertification Guard + +Project: ${result.projectId} +Generated: ${result.generatedAt} + +## Summary + +- Total reviews evaluated: ${result.summary.totalReviews} +- Stale reviews requiring recertification: ${result.summary.staleReviews} +- Stale inline comments requiring anchor review: ${result.summary.staleComments} +- Frozen reputation delta: ${result.summary.frozenReputationDelta} +- Recommended action: ${result.summary.recommendedAction} +- Timeline audit digest: ${result.timelinePacket.auditDigest} + +## Stale Review Evidence + +${staleReviewList} + +## Recertification Tasks + +${taskList} + +## Privacy Notes + +Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. +`; + +fs.writeFileSync(reportPath, markdown); + +const svg = ` + + + Peer Review Evidence Recertification + Stale reviews: ${result.summary.staleReviews} + Stale comments: ${result.summary.staleComments} + Frozen reputation delta: ${result.summary.frozenReputationDelta} + Action: ${result.summary.recommendedAction} + Tasks generated: ${result.recertificationTasks.length} + Anonymous reviewer labels preserved without raw identity leakage. + ${result.timelinePacket.auditDigest} + +`; + +fs.writeFileSync(svgPath, svg); + +console.log(`Wrote ${path.relative(__dirname, packetPath)}`); +console.log(`Wrote ${path.relative(__dirname, reportPath)}`); +console.log(`Wrote ${path.relative(__dirname, svgPath)}`); +console.log(`Recommended action: ${result.summary.recommendedAction}`); diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js new file mode 100644 index 00000000..f5193697 --- /dev/null +++ b/peer-review-evidence-recertification-guard/index.js @@ -0,0 +1,334 @@ +const crypto = require('crypto'); + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); +} + +function digest(value) { + return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`; +} + +function isoTime(value) { + return new Date(value).getTime(); +} + +function isBlindOrAnonymous(mode) { + return ['anonymous', 'blind', 'double-blind', 'fully-anonymous'].includes(mode); +} + +function reviewerDisplay(item) { + if (isBlindOrAnonymous(item.mode)) { + return item.anonymousLabel || 'anonymous-reviewer'; + } + + return `reviewer:${item.reviewerId}`; +} + +function findArtifact(project, artifactId) { + return project.artifacts.find((artifact) => artifact.id === artifactId); +} + +function evaluateReview(project, review) { + const artifact = findArtifact(project, review.artifactId); + const reasons = []; + const reviewedAt = isoTime(review.recertifiedAt || review.submittedAt); + + if (!artifact) { + reasons.push('artifact-missing'); + } else { + if (artifact.currentDigest !== review.evidenceDigest) { + reasons.push('artifact-digest-changed'); + } + + if (isoTime(artifact.changedAt) > reviewedAt) { + reasons.push('artifact-updated-after-review'); + } + } + + const status = reasons.length > 0 ? 'recertification-required' : 'current'; + const displayReviewer = reviewerDisplay(review); + + return { + id: review.id, + artifactId: review.artifactId, + mode: review.mode, + reviewer: displayReviewer, + status, + reasons, + submittedAt: review.submittedAt, + recertifiedAt: review.recertifiedAt || null, + evidenceDigest: review.evidenceDigest, + currentArtifactDigest: artifact ? artifact.currentDigest : null + }; +} + +function reputationActionForReview(review, decision) { + const base = { + id: review.id, + appliesTo: reviewerDisplay(review), + originalDelta: review.reputationDelta, + reasonDigest: digest({ + reviewId: review.id, + reasons: decision.reasons, + evidenceDigest: review.evidenceDigest, + currentArtifactDigest: decision.currentArtifactDigest + }) + }; + + if (decision.status === 'current') { + return { + ...base, + action: 'apply-current-delta', + effectiveDelta: review.reputationDelta + }; + } + + return { + ...base, + action: 'freeze-until-recertified', + effectiveDelta: 0 + }; +} + +function taskForReview(review, decision) { + if (decision.status === 'current') { + return null; + } + + return { + id: `recertify-${review.id}`, + kind: 'peer-review', + reviewId: review.id, + artifactId: review.artifactId, + reviewer: decision.reviewer, + priority: Math.abs(review.reputationDelta) >= 15 ? 'high' : 'normal', + requiredAction: 'confirm-review-still-applies-to-current-artifact', + blockedProfileUpdates: ['reputation-score', 'leaderboards', 'badges'], + reasons: decision.reasons + }; +} + +function evaluateComment(project, comment) { + const artifact = findArtifact(project, comment.artifactId); + const reasons = []; + let anchorStatus = 'current'; + + if (!artifact) { + reasons.push('artifact-missing'); + anchorStatus = 'missing'; + } else { + if (artifact.currentDigest !== comment.anchorDigest) { + reasons.push('artifact-digest-changed'); + } + + const currentAnchor = artifact.currentAnchors[comment.anchor.selector]; + if (!currentAnchor) { + reasons.push('anchor-missing-after-comment'); + anchorStatus = 'missing'; + } else if (currentAnchor.line !== comment.anchor.line) { + reasons.push('anchor-line-shifted-after-comment'); + anchorStatus = 'stale'; + } + } + + const status = reasons.length > 0 ? 'recertification-required' : 'current'; + + return { + id: comment.id, + artifactId: comment.artifactId, + status, + anchorStatus, + reviewer: reviewerDisplay(comment), + reasons, + anchor: comment.anchor + }; +} + +function taskForComment(comment, decision) { + if (decision.status === 'current') { + return null; + } + + return { + id: `recertify-${comment.id}`, + kind: 'inline-comment', + commentId: comment.id, + artifactId: comment.artifactId, + reviewer: decision.reviewer, + priority: 'normal', + requiredAction: 'confirm-comment-anchor-still-matches-current-artifact', + reasons: decision.reasons + }; +} + +function buildTimelinePacket(project, reviewDecisions, commentDecisions) { + const reviewEvents = reviewDecisions.map((decision) => ({ + type: decision.status === 'current' ? 'review-evidence-current' : 'review-recertification-required', + reviewId: decision.id, + artifactId: decision.artifactId, + reviewer: decision.reviewer, + status: decision.status, + reasons: decision.reasons + })); + + const commentEvents = commentDecisions + .filter((decision) => decision.status !== 'current') + .map((decision) => ({ + type: 'inline-comment-recertification-required', + commentId: decision.id, + artifactId: decision.artifactId, + reviewer: decision.reviewer, + status: decision.status, + reasons: decision.reasons + })); + + const events = [...reviewEvents, ...commentEvents]; + + return { + projectId: project.projectId, + generatedAt: project.asOf, + events, + auditDigest: digest({ + projectId: project.projectId, + generatedAt: project.asOf, + events + }) + }; +} + +function evaluateRecertification(project) { + const reviewDecisions = project.reviews.map((review) => evaluateReview(project, review)); + const reputationActions = project.reviews.map((review, index) => + reputationActionForReview(review, reviewDecisions[index]) + ); + const commentDecisions = project.inlineComments.map((comment) => evaluateComment(project, comment)); + const recertificationTasks = [ + ...project.reviews.map((review, index) => taskForReview(review, reviewDecisions[index])), + ...project.inlineComments.map((comment, index) => taskForComment(comment, commentDecisions[index])) + ].filter(Boolean); + const staleReviews = reviewDecisions.filter((decision) => decision.status !== 'current').length; + const staleComments = commentDecisions.filter((decision) => decision.status !== 'current').length; + const timelinePacket = buildTimelinePacket(project, reviewDecisions, commentDecisions); + + return { + projectId: project.projectId, + generatedAt: project.asOf, + reviewDecisions, + commentDecisions, + reputationActions, + recertificationTasks, + timelinePacket, + summary: { + totalReviews: reviewDecisions.length, + staleReviews, + staleComments, + frozenReputationDelta: reputationActions + .filter((action) => action.action === 'freeze-until-recertified') + .reduce((sum, action) => sum + action.originalDelta, 0), + recommendedAction: staleReviews > 0 ? 'block-reputation-update' : 'allow-reputation-update' + } + }; +} + +function buildSampleProject() { + return { + projectId: 'project-alpha-replication', + asOf: '2026-05-28T06:00:00Z', + artifacts: [ + { + id: 'dataset-cohort-table', + type: 'dataset', + currentDigest: 'sha256:dataset-v2', + changedAt: '2026-05-24T12:00:00Z', + currentAnchors: {} + }, + { + id: 'notebook-methods', + type: 'notebook', + currentDigest: 'sha256:notebook-v1', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + }, + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-19T09:30:00Z', + currentAnchors: { + 'src/analyze.py#L41': { line: 47 } + } + } + ], + reviews: [ + { + id: 'review-dataset-methods', + reviewerId: 'orcid:0000-0002-reviewer-a', + mode: 'public', + artifactId: 'dataset-cohort-table', + evidenceDigest: 'sha256:dataset-v1', + submittedAt: '2026-05-18T09:00:00Z', + reputationDelta: 18 + }, + { + id: 'review-notebook-methods', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'semi-private', + artifactId: 'notebook-methods', + evidenceDigest: 'sha256:notebook-v1', + submittedAt: '2026-05-12T10:00:00Z', + reputationDelta: 9 + }, + { + id: 'review-code-recertified', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + recertifiedAt: '2026-05-21T13:00:00Z', + reputationDelta: 14 + }, + { + id: 'review-blind-data', + reviewerId: 'orcid:0000-0002-private', + anonymousLabel: 'anonymous-reviewer-7', + mode: 'double-blind', + artifactId: 'dataset-cohort-table', + evidenceDigest: 'sha256:dataset-v1', + submittedAt: '2026-05-17T10:30:00Z', + reputationDelta: 11 + } + ], + inlineComments: [ + { + id: 'comment-code-line-41', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v2', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + ] + }; +} + +module.exports = { + evaluateRecertification, + buildSampleProject, + digest +}; diff --git a/peer-review-evidence-recertification-guard/make-demo-video.py b/peer-review-evidence-recertification-guard/make-demo-video.py new file mode 100644 index 00000000..180fabba --- /dev/null +++ b/peer-review-evidence-recertification-guard/make-demo-video.py @@ -0,0 +1,41 @@ +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +REPORTS.mkdir(exist_ok=True) +OUTPUT = REPORTS / "demo.mp4" + +font = "C\\:/Windows/Fonts/arial.ttf" +vf = ",".join( + [ + "drawbox=x=50:y=55:w=1180:h=610:color=0x6cc7ff@0.55:t=4", + "drawbox=x=60:y=65:w=1160:h=590:color=0x172b44@0.96:t=fill", + f"drawtext=fontfile='{font}':text='Peer Review Evidence Recertification':x=95:y=125:fontsize=42:fontcolor=white", + f"drawtext=fontfile='{font}':text='Detects stale peer-review evidence after artifact changes':x=95:y=205:fontsize=30:fontcolor=0xd7edf9", + f"drawtext=fontfile='{font}':text='Freezes outdated reputation deltas until recertified':x=95:y=265:fontsize=30:fontcolor=0xd7edf9", + f"drawtext=fontfile='{font}':text='Redacts double-blind reviewer identities in task packets':x=95:y=325:fontsize=30:fontcolor=0xd7edf9", + f"drawtext=fontfile='{font}':text='Outputs JSON, Markdown, SVG, and audit digest evidence':x=95:y=385:fontsize=30:fontcolor=0xd7edf9", + f"drawtext=fontfile='{font}':text='SCIBASE issue #15 community reputation slice':x=95:y=500:fontsize=28:fontcolor=0xffdf7e", + ] +) + +cmd = [ + "ffmpeg", + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x102033:s=1280x720:d=4:r=30", + "-vf", + vf, + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + str(OUTPUT), +] + +subprocess.run(cmd, check=True) +print(f"Wrote {OUTPUT.relative_to(ROOT)}") diff --git a/peer-review-evidence-recertification-guard/package.json b/peer-review-evidence-recertification-guard/package.json new file mode 100644 index 00000000..681a4913 --- /dev/null +++ b/peer-review-evidence-recertification-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "peer-review-evidence-recertification-guard", + "version": "1.0.0", + "description": "Dependency-free peer review evidence recertification guard for SCIBASE community reputation.", + "main": "index.js", + "private": true, + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "video": "python make-demo-video.py", + "check": "npm test && npm run demo && npm run video" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/demo.mp4 b/peer-review-evidence-recertification-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..f307f087104f6715aa76b350894cb97566b29311 GIT binary patch literal 45287 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 U7xlNcT53Qi;C^%Q&+d8s1D#AjCIA2c literal 0 HcmV?d00001 diff --git a/peer-review-evidence-recertification-guard/reports/recertification-packet.json b/peer-review-evidence-recertification-guard/reports/recertification-packet.json new file mode 100644 index 00000000..61991556 --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/recertification-packet.json @@ -0,0 +1,225 @@ +{ + "projectId": "project-alpha-replication", + "generatedAt": "2026-05-28T06:00:00Z", + "reviewDecisions": [ + { + "id": "review-dataset-methods", + "artifactId": "dataset-cohort-table", + "mode": "public", + "reviewer": "reviewer:orcid:0000-0002-reviewer-a", + "status": "recertification-required", + "reasons": [ + "artifact-digest-changed", + "artifact-updated-after-review" + ], + "submittedAt": "2026-05-18T09:00:00Z", + "recertifiedAt": null, + "evidenceDigest": "sha256:dataset-v1", + "currentArtifactDigest": "sha256:dataset-v2" + }, + { + "id": "review-notebook-methods", + "artifactId": "notebook-methods", + "mode": "semi-private", + "reviewer": "reviewer:orcid:0000-0002-reviewer-b", + "status": "current", + "reasons": [], + "submittedAt": "2026-05-12T10:00:00Z", + "recertifiedAt": null, + "evidenceDigest": "sha256:notebook-v1", + "currentArtifactDigest": "sha256:notebook-v1" + }, + { + "id": "review-code-recertified", + "artifactId": "analysis-code", + "mode": "public", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "status": "current", + "reasons": [], + "submittedAt": "2026-05-16T11:00:00Z", + "recertifiedAt": "2026-05-21T13:00:00Z", + "evidenceDigest": "sha256:code-v3", + "currentArtifactDigest": "sha256:code-v3" + }, + { + "id": "review-blind-data", + "artifactId": "dataset-cohort-table", + "mode": "double-blind", + "reviewer": "anonymous-reviewer-7", + "status": "recertification-required", + "reasons": [ + "artifact-digest-changed", + "artifact-updated-after-review" + ], + "submittedAt": "2026-05-17T10:30:00Z", + "recertifiedAt": null, + "evidenceDigest": "sha256:dataset-v1", + "currentArtifactDigest": "sha256:dataset-v2" + } + ], + "commentDecisions": [ + { + "id": "comment-code-line-41", + "artifactId": "analysis-code", + "status": "recertification-required", + "anchorStatus": "stale", + "reviewer": "reviewer:orcid:0000-0002-reviewer-b", + "reasons": [ + "artifact-digest-changed", + "anchor-line-shifted-after-comment" + ], + "anchor": { + "selector": "src/analyze.py#L41", + "line": 41 + } + } + ], + "reputationActions": [ + { + "id": "review-dataset-methods", + "appliesTo": "reviewer:orcid:0000-0002-reviewer-a", + "originalDelta": 18, + "reasonDigest": "sha256:975baffcab1e852373e1ae5295c5c3dcc68f182386d1d96e938dd9ac7cb4f32c", + "action": "freeze-until-recertified", + "effectiveDelta": 0 + }, + { + "id": "review-notebook-methods", + "appliesTo": "reviewer:orcid:0000-0002-reviewer-b", + "originalDelta": 9, + "reasonDigest": "sha256:7cd90b9073f5b1c119338335d36d599717fdf5567412ddf9b702d2f2233f03a4", + "action": "apply-current-delta", + "effectiveDelta": 9 + }, + { + "id": "review-code-recertified", + "appliesTo": "reviewer:orcid:0000-0002-reviewer-c", + "originalDelta": 14, + "reasonDigest": "sha256:da4c076128fa02684b5e2ab3fa52a8628664976d7d3ec6b2b8b64066ddae70b4", + "action": "apply-current-delta", + "effectiveDelta": 14 + }, + { + "id": "review-blind-data", + "appliesTo": "anonymous-reviewer-7", + "originalDelta": 11, + "reasonDigest": "sha256:ca5d7918d21f6ed2f218a30b1933af1f11b5b80a06d49f920802b73181218907", + "action": "freeze-until-recertified", + "effectiveDelta": 0 + } + ], + "recertificationTasks": [ + { + "id": "recertify-review-dataset-methods", + "kind": "peer-review", + "reviewId": "review-dataset-methods", + "artifactId": "dataset-cohort-table", + "reviewer": "reviewer:orcid:0000-0002-reviewer-a", + "priority": "high", + "requiredAction": "confirm-review-still-applies-to-current-artifact", + "blockedProfileUpdates": [ + "reputation-score", + "leaderboards", + "badges" + ], + "reasons": [ + "artifact-digest-changed", + "artifact-updated-after-review" + ] + }, + { + "id": "recertify-review-blind-data", + "kind": "peer-review", + "reviewId": "review-blind-data", + "artifactId": "dataset-cohort-table", + "reviewer": "anonymous-reviewer-7", + "priority": "normal", + "requiredAction": "confirm-review-still-applies-to-current-artifact", + "blockedProfileUpdates": [ + "reputation-score", + "leaderboards", + "badges" + ], + "reasons": [ + "artifact-digest-changed", + "artifact-updated-after-review" + ] + }, + { + "id": "recertify-comment-code-line-41", + "kind": "inline-comment", + "commentId": "comment-code-line-41", + "artifactId": "analysis-code", + "reviewer": "reviewer:orcid:0000-0002-reviewer-b", + "priority": "normal", + "requiredAction": "confirm-comment-anchor-still-matches-current-artifact", + "reasons": [ + "artifact-digest-changed", + "anchor-line-shifted-after-comment" + ] + } + ], + "timelinePacket": { + "projectId": "project-alpha-replication", + "generatedAt": "2026-05-28T06:00:00Z", + "events": [ + { + "type": "review-recertification-required", + "reviewId": "review-dataset-methods", + "artifactId": "dataset-cohort-table", + "reviewer": "reviewer:orcid:0000-0002-reviewer-a", + "status": "recertification-required", + "reasons": [ + "artifact-digest-changed", + "artifact-updated-after-review" + ] + }, + { + "type": "review-evidence-current", + "reviewId": "review-notebook-methods", + "artifactId": "notebook-methods", + "reviewer": "reviewer:orcid:0000-0002-reviewer-b", + "status": "current", + "reasons": [] + }, + { + "type": "review-evidence-current", + "reviewId": "review-code-recertified", + "artifactId": "analysis-code", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "status": "current", + "reasons": [] + }, + { + "type": "review-recertification-required", + "reviewId": "review-blind-data", + "artifactId": "dataset-cohort-table", + "reviewer": "anonymous-reviewer-7", + "status": "recertification-required", + "reasons": [ + "artifact-digest-changed", + "artifact-updated-after-review" + ] + }, + { + "type": "inline-comment-recertification-required", + "commentId": "comment-code-line-41", + "artifactId": "analysis-code", + "reviewer": "reviewer:orcid:0000-0002-reviewer-b", + "status": "recertification-required", + "reasons": [ + "artifact-digest-changed", + "anchor-line-shifted-after-comment" + ] + } + ], + "auditDigest": "sha256:05d6bfae8c13031699429ce8eed05abc4046a732fb44f34746fca82415bb7f9f" + }, + "summary": { + "totalReviews": 4, + "staleReviews": 2, + "staleComments": 1, + "frozenReputationDelta": 29, + "recommendedAction": "block-reputation-update" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md new file mode 100644 index 00000000..eb86ffa7 --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -0,0 +1,28 @@ +# Peer Review Evidence Recertification Guard + +Project: project-alpha-replication +Generated: 2026-05-28T06:00:00Z + +## Summary + +- Total reviews evaluated: 4 +- Stale reviews requiring recertification: 2 +- Stale inline comments requiring anchor review: 1 +- Frozen reputation delta: 29 +- Recommended action: block-reputation-update +- Timeline audit digest: sha256:05d6bfae8c13031699429ce8eed05abc4046a732fb44f34746fca82415bb7f9f + +## Stale Review Evidence + +- review-dataset-methods: artifact-digest-changed, artifact-updated-after-review +- review-blind-data: artifact-digest-changed, artifact-updated-after-review + +## Recertification Tasks + +- recertify-review-dataset-methods (peer-review, high): confirm-review-still-applies-to-current-artifact +- recertify-review-blind-data (peer-review, normal): confirm-review-still-applies-to-current-artifact +- recertify-comment-code-line-41 (inline-comment, normal): confirm-comment-anchor-still-matches-current-artifact + +## Privacy Notes + +Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. diff --git a/peer-review-evidence-recertification-guard/reports/summary.svg b/peer-review-evidence-recertification-guard/reports/summary.svg new file mode 100644 index 00000000..2c41d48c --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/summary.svg @@ -0,0 +1,12 @@ + + + + Peer Review Evidence Recertification + Stale reviews: 2 + Stale comments: 1 + Frozen reputation delta: 29 + Action: block-reputation-update + Tasks generated: 3 + Anonymous reviewer labels preserved without raw identity leakage. + sha256:05d6bfae8c13031699429ce8eed05abc4046a732fb44f34746fca82415bb7f9f + diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md new file mode 100644 index 00000000..9005dbca --- /dev/null +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -0,0 +1,26 @@ +# Requirements Map + +## Peer Reviews & Comments + +- Structured peer-review evidence is tied to reviewed artifact digests. +- Inline comments track artifact anchors and require recertification when anchors shift. +- Public, semi-private, and double-blind review modes are represented. +- Review history is emitted in a project timeline packet. + +## Contributor Credits + +- Review-derived reputation deltas are preserved as original deltas. +- Stale evidence freezes effective deltas until recertification. +- Audit packets keep enough evidence for profile and citation-page credit decisions. + +## Reputation Scoring + +- Current reviews apply their transparent reputation delta. +- Stale reviews are blocked from leaderboards, badges, and score updates. +- Recertification tasks explain which evidence must be refreshed. + +## Privacy And Trust + +- Double-blind reviewer IDs are replaced by anonymous labels. +- Synthetic data only; no private profile emails, credentials, or external API calls. +- The timeline audit digest is deterministic for reviewer verification. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js new file mode 100644 index 00000000..cbf8be12 --- /dev/null +++ b/peer-review-evidence-recertification-guard/test.js @@ -0,0 +1,97 @@ +const assert = require('assert'); +const { + evaluateRecertification, + buildSampleProject +} = require('./index'); + +function byId(items, id) { + return items.find((item) => item.id === id); +} + +function testStaleReviewsFreezeReputationUntilRecertified() { + const project = buildSampleProject(); + const result = evaluateRecertification(project); + const review = byId(result.reviewDecisions, 'review-dataset-methods'); + + assert.equal(review.status, 'recertification-required'); + assert.deepEqual(review.reasons, [ + 'artifact-digest-changed', + 'artifact-updated-after-review' + ]); + + const action = byId(result.reputationActions, 'review-dataset-methods'); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.originalDelta, 18); + assert.equal(action.effectiveDelta, 0); + assert.equal(action.appliesTo, 'reviewer:orcid:0000-0002-reviewer-a'); + + assert.equal(result.summary.staleReviews, 2); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + +function testCurrentOrRecertifiedReviewsKeepReputationCredit() { + const project = buildSampleProject(); + const result = evaluateRecertification(project); + const methods = byId(result.reviewDecisions, 'review-notebook-methods'); + const code = byId(result.reviewDecisions, 'review-code-recertified'); + + assert.equal(methods.status, 'current'); + assert.equal(code.status, 'current'); + + const codeAction = byId(result.reputationActions, 'review-code-recertified'); + assert.equal(codeAction.action, 'apply-current-delta'); + assert.equal(codeAction.effectiveDelta, 14); +} + +function testAnonymousReviewerIdentityIsRedacted() { + const project = buildSampleProject(); + const result = evaluateRecertification(project); + const task = byId(result.recertificationTasks, 'recertify-review-blind-data'); + const timelineEvent = result.timelinePacket.events.find((event) => event.reviewId === 'review-blind-data'); + + assert.equal(task.reviewer, 'anonymous-reviewer-7'); + assert.ok(!JSON.stringify(task).includes('orcid:0000-0002-private')); + assert.equal(timelineEvent.reviewer, 'anonymous-reviewer-7'); + assert.ok(!JSON.stringify(timelineEvent).includes('orcid:0000-0002-private')); +} + +function testInlineCommentsUseArtifactAnchorsForRecertification() { + const project = buildSampleProject(); + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'comment-code-line-41'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'stale'); + assert.deepEqual(comment.reasons, [ + 'artifact-digest-changed', + 'anchor-line-shifted-after-comment' + ]); + + const task = byId(result.recertificationTasks, 'recertify-comment-code-line-41'); + assert.equal(task.kind, 'inline-comment'); + assert.equal(task.priority, 'normal'); +} + +function testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles() { + const project = buildSampleProject(); + const result = evaluateRecertification(project); + + assert.equal(result.timelinePacket.projectId, 'project-alpha-replication'); + assert.equal(result.timelinePacket.events.length, 5); + assert.ok(result.timelinePacket.auditDigest.startsWith('sha256:')); + assert.ok(!JSON.stringify(result.timelinePacket).includes('private@')); +} + +const tests = [ + testStaleReviewsFreezeReputationUntilRecertified, + testCurrentOrRecertifiedReviewsKeepReputationCredit, + testAnonymousReviewerIdentityIsRedacted, + testInlineCommentsUseArtifactAnchorsForRecertification, + testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles +]; + +for (const test of tests) { + test(); +} + +console.log(`${tests.length} peer review evidence recertification tests passed`); From 05d75c345d2b021516cd7cb641c245f6fc627b20 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 17:24:59 +0200 Subject: [PATCH 02/22] Harden peer review comment recertification --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 1 + .../requirements-map.md | 2 +- .../test.js | 38 +++++++++++++++++++ 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index ba5e93f7..d31e2ef2 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 05a0191e..5e6fa9f2 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -12,4 +12,5 @@ Validation targets: - recertified code review keeps its 14 point reputation delta - double-blind reviewer identity is not leaked in tasks or timeline events - stale inline comment anchors generate comment-specific recertification tasks +- artifact digest changes mark inline comment anchors stale even when the selector line is unchanged - timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index f5193697..b02bf6bf 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -130,6 +130,7 @@ function evaluateComment(project, comment) { } else { if (artifact.currentDigest !== comment.anchorDigest) { reasons.push('artifact-digest-changed'); + anchorStatus = 'stale'; } const currentAnchor = artifact.currentAnchors[comment.anchor.selector]; diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index 9005dbca..a5fa2d92 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -3,7 +3,7 @@ ## Peer Reviews & Comments - Structured peer-review evidence is tied to reviewed artifact digests. -- Inline comments track artifact anchors and require recertification when anchors shift. +- Inline comments track artifact anchors and require recertification when anchors shift or artifact digests change. - Public, semi-private, and double-blind review modes are represented. - Review history is emitted in a project timeline packet. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index cbf8be12..1464cb73 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -72,6 +72,43 @@ function testInlineCommentsUseArtifactAnchorsForRecertification() { assert.equal(task.priority, 'normal'); } +function testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v4', + changedAt: '2026-05-20T09:30:00Z', + currentAnchors: { + 'src/analyze.py#L41': { line: 41 } + } + } + ]; + project.reviews = []; + project.inlineComments = [ + { + id: 'comment-same-line-changed-digest', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + ]; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'comment-same-line-changed-digest'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'stale'); + assert.deepEqual(comment.reasons, ['artifact-digest-changed']); +} + function testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles() { const project = buildSampleProject(); const result = evaluateRecertification(project); @@ -87,6 +124,7 @@ const tests = [ testCurrentOrRecertifiedReviewsKeepReputationCredit, testAnonymousReviewerIdentityIsRedacted, testInlineCommentsUseArtifactAnchorsForRecertification, + testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles ]; From 489eb13e1b2bdbac34d8f1688c0e3237aa69bb6a Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 18:17:48 +0200 Subject: [PATCH 03/22] Harden blind review mode redaction --- .../index.js | 9 +++++- .../test.js | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index b02bf6bf..b3fe3e25 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -23,8 +23,15 @@ function isoTime(value) { return new Date(value).getTime(); } +function normalizeReviewMode(mode) { + return String(mode || '') + .trim() + .toLowerCase() + .replace(/_/g, '-'); +} + function isBlindOrAnonymous(mode) { - return ['anonymous', 'blind', 'double-blind', 'fully-anonymous'].includes(mode); + return ['anonymous', 'blind', 'double-blind', 'fully-anonymous'].includes(normalizeReviewMode(mode)); } function reviewerDisplay(item) { diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 1464cb73..4061bcd0 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -55,6 +55,33 @@ function testAnonymousReviewerIdentityIsRedacted() { assert.ok(!JSON.stringify(timelineEvent).includes('orcid:0000-0002-private')); } +function testBlindModeRedactionAcceptsCaseAndSeparatorVariants() { + const project = buildSampleProject(); + project.reviews = [ + { + id: 'review-blind-variant', + reviewerId: 'orcid:0000-0002-variant-private', + anonymousLabel: 'anonymous-reviewer-variant', + mode: 'Double_Blind', + artifactId: 'dataset-cohort-table', + evidenceDigest: 'sha256:dataset-v1', + submittedAt: '2026-05-18T09:00:00Z', + reputationDelta: 18 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const task = byId(result.recertificationTasks, 'recertify-review-blind-variant'); + const timelineEvent = result.timelinePacket.events.find( + (event) => event.reviewId === 'review-blind-variant' + ); + + assert.equal(task.reviewer, 'anonymous-reviewer-variant'); + assert.equal(timelineEvent.reviewer, 'anonymous-reviewer-variant'); + assert.ok(!JSON.stringify(result).includes('orcid:0000-0002-variant-private')); +} + function testInlineCommentsUseArtifactAnchorsForRecertification() { const project = buildSampleProject(); const result = evaluateRecertification(project); @@ -123,6 +150,7 @@ const tests = [ testStaleReviewsFreezeReputationUntilRecertified, testCurrentOrRecertifiedReviewsKeepReputationCredit, testAnonymousReviewerIdentityIsRedacted, + testBlindModeRedactionAcceptsCaseAndSeparatorVariants, testInlineCommentsUseArtifactAnchorsForRecertification, testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles From f30466728617a503f2e0933a39dcc0da48ac1f2d Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 17:24:20 +0200 Subject: [PATCH 04/22] Validate review recertification timestamps --- .../README.md | 2 +- .../index.js | 8 +++++ .../requirements-map.md | 1 + .../test.js | 36 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index d31e2ef2..3177dfb4 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, holds malformed review timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index b3fe3e25..a6d97e9f 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -23,6 +23,10 @@ function isoTime(value) { return new Date(value).getTime(); } +function hasValidTime(value) { + return Number.isFinite(isoTime(value)); +} + function normalizeReviewMode(mode) { return String(mode || '') .trim() @@ -51,6 +55,10 @@ function evaluateReview(project, review) { const reasons = []; const reviewedAt = isoTime(review.recertifiedAt || review.submittedAt); + if (!hasValidTime(review.recertifiedAt || review.submittedAt)) { + reasons.push('invalid-review-timestamp'); + } + if (!artifact) { reasons.push('artifact-missing'); } else { diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index a5fa2d92..3d9a0905 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -3,6 +3,7 @@ ## Peer Reviews & Comments - Structured peer-review evidence is tied to reviewed artifact digests. +- Malformed review submission or recertification timestamps require recertification before review credit is applied. - Inline comments track artifact anchors and require recertification when anchors shift or artifact digests change. - Public, semi-private, and double-blind review modes are represented. - Review history is emitted in a project timeline packet. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 4061bcd0..1c322d64 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -136,6 +136,41 @@ function testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift() { assert.deepEqual(comment.reasons, ['artifact-digest-changed']); } +function testInvalidReviewTimestampRequiresRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-19T09:30:00Z', + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-invalid-recertified-at', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + recertifiedAt: 'not-a-date', + reputationDelta: 14 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const decision = byId(result.reviewDecisions, 'review-invalid-recertified-at'); + const action = byId(result.reputationActions, 'review-invalid-recertified-at'); + + assert.equal(decision.status, 'recertification-required'); + assert.deepEqual(decision.reasons, ['invalid-review-timestamp']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); +} + function testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles() { const project = buildSampleProject(); const result = evaluateRecertification(project); @@ -153,6 +188,7 @@ const tests = [ testBlindModeRedactionAcceptsCaseAndSeparatorVariants, testInlineCommentsUseArtifactAnchorsForRecertification, testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift, + testInvalidReviewTimestampRequiresRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles ]; From 631d59fd028b89694241ab187357968d3c873c58 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 18:23:44 +0200 Subject: [PATCH 05/22] Validate inline comment recertification timestamps --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 5 +++ .../requirements-map.md | 2 +- .../test.js | 41 +++++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 3177dfb4..c6598f4d 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, holds malformed review timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, holds malformed review and inline-comment timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 5e6fa9f2..6d86c9a4 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -13,4 +13,5 @@ Validation targets: - double-blind reviewer identity is not leaked in tasks or timeline events - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged +- malformed inline comment timestamps require recertification before comment evidence is treated as current - timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index a6d97e9f..c4bd0f78 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -139,6 +139,11 @@ function evaluateComment(project, comment) { const reasons = []; let anchorStatus = 'current'; + if (!hasValidTime(comment.submittedAt)) { + reasons.push('invalid-comment-timestamp'); + anchorStatus = 'stale'; + } + if (!artifact) { reasons.push('artifact-missing'); anchorStatus = 'missing'; diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index 3d9a0905..2e640204 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -4,7 +4,7 @@ - Structured peer-review evidence is tied to reviewed artifact digests. - Malformed review submission or recertification timestamps require recertification before review credit is applied. -- Inline comments track artifact anchors and require recertification when anchors shift or artifact digests change. +- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, or comment timing evidence is malformed. - Public, semi-private, and double-blind review modes are represented. - Review history is emitted in a project timeline packet. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 1c322d64..69fd6e9e 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -136,6 +136,46 @@ function testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift() { assert.deepEqual(comment.reasons, ['artifact-digest-changed']); } +function testInvalidInlineCommentTimestampRequiresRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-19T09:30:00Z', + currentAnchors: { + 'src/analyze.py#L41': { line: 41 } + } + } + ]; + project.reviews = []; + project.inlineComments = [ + { + id: 'comment-invalid-submitted-at', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: 'not-a-date' + } + ]; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'comment-invalid-submitted-at'); + const task = byId(result.recertificationTasks, 'recertify-comment-invalid-submitted-at'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'stale'); + assert.deepEqual(comment.reasons, ['invalid-comment-timestamp']); + assert.equal(task.kind, 'inline-comment'); + assert.deepEqual(task.reasons, ['invalid-comment-timestamp']); +} + function testInvalidReviewTimestampRequiresRecertification() { const project = buildSampleProject(); project.artifacts = [ @@ -188,6 +228,7 @@ const tests = [ testBlindModeRedactionAcceptsCaseAndSeparatorVariants, testInlineCommentsUseArtifactAnchorsForRecertification, testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift, + testInvalidInlineCommentTimestampRequiresRecertification, testInvalidReviewTimestampRequiresRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles ]; From b38ebcc6b1ca6f7dde0a03700e88ade688dd2c5e Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 19:32:43 +0200 Subject: [PATCH 06/22] Handle missing comment anchor evidence --- .../README.md | 2 +- .../acceptance-notes.md | 2 + .../index.js | 26 +++++-- .../requirements-map.md | 2 +- .../test.js | 73 +++++++++++++++++++ 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index c6598f4d..496ff3cb 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, holds malformed review and inline-comment timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, holds malformed review and inline-comment timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 6d86c9a4..5422fe7d 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -14,4 +14,6 @@ Validation targets: - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged - malformed inline comment timestamps require recertification before comment evidence is treated as current +- missing inline comment anchor metadata requires recertification instead of crashing evidence evaluation +- missing artifact anchor maps require comment recertification instead of crashing evidence evaluation - timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index c4bd0f78..9c09be8e 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -138,12 +138,20 @@ function evaluateComment(project, comment) { const artifact = findArtifact(project, comment.artifactId); const reasons = []; let anchorStatus = 'current'; + const hasUsableAnchor = comment.anchor + && typeof comment.anchor.selector === 'string' + && typeof comment.anchor.line === 'number'; if (!hasValidTime(comment.submittedAt)) { reasons.push('invalid-comment-timestamp'); anchorStatus = 'stale'; } + if (!hasUsableAnchor) { + reasons.push('comment-anchor-metadata-missing'); + anchorStatus = 'missing'; + } + if (!artifact) { reasons.push('artifact-missing'); anchorStatus = 'missing'; @@ -153,13 +161,15 @@ function evaluateComment(project, comment) { anchorStatus = 'stale'; } - const currentAnchor = artifact.currentAnchors[comment.anchor.selector]; - if (!currentAnchor) { - reasons.push('anchor-missing-after-comment'); - anchorStatus = 'missing'; - } else if (currentAnchor.line !== comment.anchor.line) { - reasons.push('anchor-line-shifted-after-comment'); - anchorStatus = 'stale'; + if (hasUsableAnchor) { + const currentAnchor = (artifact.currentAnchors || {})[comment.anchor.selector]; + if (!currentAnchor) { + reasons.push('anchor-missing-after-comment'); + anchorStatus = 'missing'; + } else if (currentAnchor.line !== comment.anchor.line) { + reasons.push('anchor-line-shifted-after-comment'); + anchorStatus = 'stale'; + } } } @@ -172,7 +182,7 @@ function evaluateComment(project, comment) { anchorStatus, reviewer: reviewerDisplay(comment), reasons, - anchor: comment.anchor + anchor: comment.anchor || null }; } diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index 2e640204..d44943d0 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -4,7 +4,7 @@ - Structured peer-review evidence is tied to reviewed artifact digests. - Malformed review submission or recertification timestamps require recertification before review credit is applied. -- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, or comment timing evidence is malformed. +- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is malformed. - Public, semi-private, and double-blind review modes are represented. - Review history is emitted in a project timeline packet. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 69fd6e9e..79b1ff1f 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -176,6 +176,77 @@ function testInvalidInlineCommentTimestampRequiresRecertification() { assert.deepEqual(task.reasons, ['invalid-comment-timestamp']); } +function testMissingInlineCommentAnchorMetadataRequiresRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-19T09:30:00Z', + currentAnchors: { + 'src/analyze.py#L41': { line: 41 } + } + } + ]; + project.reviews = []; + project.inlineComments = [ + { + id: 'comment-missing-anchor-metadata', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v3', + submittedAt: '2026-05-18T14:30:00Z' + } + ]; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'comment-missing-anchor-metadata'); + const task = byId(result.recertificationTasks, 'recertify-comment-missing-anchor-metadata'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'missing'); + assert.deepEqual(comment.reasons, ['comment-anchor-metadata-missing']); + assert.equal(comment.anchor, null); + assert.equal(task.kind, 'inline-comment'); + assert.deepEqual(task.reasons, ['comment-anchor-metadata-missing']); +} + +function testMissingArtifactAnchorMapRequiresCommentRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-19T09:30:00Z' + } + ]; + project.reviews = []; + project.inlineComments = [ + { + id: 'comment-missing-artifact-anchor-map', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + ]; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'comment-missing-artifact-anchor-map'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'missing'); + assert.deepEqual(comment.reasons, ['anchor-missing-after-comment']); +} + function testInvalidReviewTimestampRequiresRecertification() { const project = buildSampleProject(); project.artifacts = [ @@ -229,6 +300,8 @@ const tests = [ testInlineCommentsUseArtifactAnchorsForRecertification, testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift, testInvalidInlineCommentTimestampRequiresRecertification, + testMissingInlineCommentAnchorMetadataRequiresRecertification, + testMissingArtifactAnchorMapRequiresCommentRecertification, testInvalidReviewTimestampRequiresRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles ]; From 79427c1b6384f92e22d0475c77ab71af56749ceb Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 20:23:49 +0200 Subject: [PATCH 07/22] Block stale comment reputation updates --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 4 +- .../requirements-map.md | 3 +- .../test.js | 38 +++++++++++++++++++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 496ff3cb..036086de 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, holds malformed review and inline-comment timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds malformed review and inline-comment timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 5422fe7d..a2775514 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -16,4 +16,5 @@ Validation targets: - malformed inline comment timestamps require recertification before comment evidence is treated as current - missing inline comment anchor metadata requires recertification instead of crashing evidence evaluation - missing artifact anchor maps require comment recertification instead of crashing evidence evaluation +- stale inline-comment evidence blocks reputation updates even when no stale review is present - timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 9c09be8e..29d4e761 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -267,7 +267,9 @@ function evaluateRecertification(project) { frozenReputationDelta: reputationActions .filter((action) => action.action === 'freeze-until-recertified') .reduce((sum, action) => sum + action.originalDelta, 0), - recommendedAction: staleReviews > 0 ? 'block-reputation-update' : 'allow-reputation-update' + recommendedAction: (staleReviews > 0 || staleComments > 0) + ? 'block-reputation-update' + : 'allow-reputation-update' } }; } diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index d44943d0..5b953a87 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -5,13 +5,14 @@ - Structured peer-review evidence is tied to reviewed artifact digests. - Malformed review submission or recertification timestamps require recertification before review credit is applied. - Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is malformed. +- Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented. - Review history is emitted in a project timeline packet. ## Contributor Credits - Review-derived reputation deltas are preserved as original deltas. -- Stale evidence freezes effective deltas until recertification. +- Stale review evidence freezes effective deltas until recertification, and stale inline-comment evidence blocks reputation updates. - Audit packets keep enough evidence for profile and citation-page credit decisions. ## Reputation Scoring diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 79b1ff1f..1478f1bc 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -247,6 +247,43 @@ function testMissingArtifactAnchorMapRequiresCommentRecertification() { assert.deepEqual(comment.reasons, ['anchor-missing-after-comment']); } +function testStaleInlineCommentsBlockReputationUpdateWithoutStaleReviews() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v4', + changedAt: '2026-05-20T09:30:00Z', + currentAnchors: { + 'src/analyze.py#L41': { line: 41 } + } + } + ]; + project.reviews = []; + project.inlineComments = [ + { + id: 'comment-only-stale-evidence', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + ]; + + const result = evaluateRecertification(project); + + assert.equal(result.summary.staleReviews, 0); + assert.equal(result.summary.staleComments, 1); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); + assert.equal(byId(result.recertificationTasks, 'recertify-comment-only-stale-evidence').kind, 'inline-comment'); +} + function testInvalidReviewTimestampRequiresRecertification() { const project = buildSampleProject(); project.artifacts = [ @@ -302,6 +339,7 @@ const tests = [ testInvalidInlineCommentTimestampRequiresRecertification, testMissingInlineCommentAnchorMetadataRequiresRecertification, testMissingArtifactAnchorMapRequiresCommentRecertification, + testStaleInlineCommentsBlockReputationUpdateWithoutStaleReviews, testInvalidReviewTimestampRequiresRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles ]; From eb44545bbca0a715430785feabf03600a95fc7e3 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 21:18:14 +0200 Subject: [PATCH 08/22] Require valid artifact timestamps --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 6 ++- .../requirements-map.md | 1 + .../test.js | 38 +++++++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 036086de..8c98a66f 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds malformed review and inline-comment timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds malformed review, artifact, and inline-comment timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index a2775514..defa1dbb 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -17,4 +17,5 @@ Validation targets: - missing inline comment anchor metadata requires recertification instead of crashing evidence evaluation - missing artifact anchor maps require comment recertification instead of crashing evidence evaluation - stale inline-comment evidence blocks reputation updates even when no stale review is present +- malformed artifact change timestamps require review recertification before reputation credit is applied - timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 29d4e761..203ec7bd 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -62,11 +62,15 @@ function evaluateReview(project, review) { if (!artifact) { reasons.push('artifact-missing'); } else { + if (!hasValidTime(artifact.changedAt)) { + reasons.push('invalid-artifact-timestamp'); + } + if (artifact.currentDigest !== review.evidenceDigest) { reasons.push('artifact-digest-changed'); } - if (isoTime(artifact.changedAt) > reviewedAt) { + if (hasValidTime(artifact.changedAt) && isoTime(artifact.changedAt) > reviewedAt) { reasons.push('artifact-updated-after-review'); } } diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index 5b953a87..b37605be 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -4,6 +4,7 @@ - Structured peer-review evidence is tied to reviewed artifact digests. - Malformed review submission or recertification timestamps require recertification before review credit is applied. +- Malformed artifact change timestamps require review recertification before review credit is applied. - Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is malformed. - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 1478f1bc..43a34124 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -319,6 +319,43 @@ function testInvalidReviewTimestampRequiresRecertification() { assert.equal(action.effectiveDelta, 0); } +function testInvalidArtifactTimestampRequiresReviewRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: 'not-a-date', + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-invalid-artifact-timestamp', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + reputationDelta: 14 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const decision = byId(result.reviewDecisions, 'review-invalid-artifact-timestamp'); + const task = byId(result.recertificationTasks, 'recertify-review-invalid-artifact-timestamp'); + const action = byId(result.reputationActions, 'review-invalid-artifact-timestamp'); + + assert.equal(decision.status, 'recertification-required'); + assert.deepEqual(decision.reasons, ['invalid-artifact-timestamp']); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['invalid-artifact-timestamp']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); +} + function testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles() { const project = buildSampleProject(); const result = evaluateRecertification(project); @@ -341,6 +378,7 @@ const tests = [ testMissingArtifactAnchorMapRequiresCommentRecertification, testStaleInlineCommentsBlockReputationUpdateWithoutStaleReviews, testInvalidReviewTimestampRequiresRecertification, + testInvalidArtifactTimestampRequiresReviewRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles ]; From 022b505d662d19b3faed852e478fb0efeb2736aa Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 21:57:55 +0200 Subject: [PATCH 09/22] Validate comment artifact timestamps --- .../README.md | 2 +- .../acceptance-notes.md | 2 +- .../index.js | 5 +++ .../requirements-map.md | 4 +- .../test.js | 43 +++++++++++++++++++ 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 8c98a66f..149bea17 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds malformed review, artifact, and inline-comment timestamps for recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds malformed review, artifact, and inline-comment timestamps for review and comment recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index defa1dbb..570e34a0 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -17,5 +17,5 @@ Validation targets: - missing inline comment anchor metadata requires recertification instead of crashing evidence evaluation - missing artifact anchor maps require comment recertification instead of crashing evidence evaluation - stale inline-comment evidence blocks reputation updates even when no stale review is present -- malformed artifact change timestamps require review recertification before reputation credit is applied +- malformed artifact change timestamps require review and inline-comment recertification before reputation credit or comment evidence is applied - timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 203ec7bd..ed8700aa 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -160,6 +160,11 @@ function evaluateComment(project, comment) { reasons.push('artifact-missing'); anchorStatus = 'missing'; } else { + if (!hasValidTime(artifact.changedAt)) { + reasons.push('invalid-artifact-timestamp'); + anchorStatus = 'stale'; + } + if (artifact.currentDigest !== comment.anchorDigest) { reasons.push('artifact-digest-changed'); anchorStatus = 'stale'; diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index b37605be..a4a4e80e 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -4,8 +4,8 @@ - Structured peer-review evidence is tied to reviewed artifact digests. - Malformed review submission or recertification timestamps require recertification before review credit is applied. -- Malformed artifact change timestamps require review recertification before review credit is applied. -- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is malformed. +- Malformed artifact change timestamps require review and inline-comment recertification before review credit or comment evidence is applied. +- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, artifact timing evidence is malformed, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is malformed. - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented. - Review history is emitted in a project timeline packet. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 43a34124..c8237ec4 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -356,6 +356,48 @@ function testInvalidArtifactTimestampRequiresReviewRecertification() { assert.equal(action.effectiveDelta, 0); } +function testInvalidArtifactTimestampRequiresCommentRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: 'not-a-date', + currentAnchors: { + 'src/analyze.py#L41': { line: 41 } + } + } + ]; + project.reviews = []; + project.inlineComments = [ + { + id: 'comment-invalid-artifact-timestamp', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + ]; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'comment-invalid-artifact-timestamp'); + const task = byId(result.recertificationTasks, 'recertify-comment-invalid-artifact-timestamp'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'stale'); + assert.deepEqual(comment.reasons, ['invalid-artifact-timestamp']); + assert.equal(task.kind, 'inline-comment'); + assert.deepEqual(task.reasons, ['invalid-artifact-timestamp']); + assert.equal(result.summary.staleComments, 1); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + function testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles() { const project = buildSampleProject(); const result = evaluateRecertification(project); @@ -379,6 +421,7 @@ const tests = [ testStaleInlineCommentsBlockReputationUpdateWithoutStaleReviews, testInvalidReviewTimestampRequiresRecertification, testInvalidArtifactTimestampRequiresReviewRecertification, + testInvalidArtifactTimestampRequiresCommentRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles ]; From 23a9b8dde93a1c6a83d9370f4cf2c0eea74ad387 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 23:26:39 +0200 Subject: [PATCH 10/22] Normalize blind review mode spacing --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 3 +- .../requirements-map.md | 4 +- .../test.js | 41 +++++++++++++++++++ 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 149bea17..9286de12 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds malformed review, artifact, and inline-comment timestamps for review and comment recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds malformed review, artifact, and inline-comment timestamps for review and comment recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 570e34a0..f1e0675f 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -11,6 +11,7 @@ Validation targets: - stale dataset review freezes an 18 point reputation delta - recertified code review keeps its 14 point reputation delta - double-blind reviewer identity is not leaked in tasks or timeline events +- space-separated blind and fully anonymous mode labels do not leak raw reviewer IDs - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged - malformed inline comment timestamps require recertification before comment evidence is treated as current diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index ed8700aa..159e1098 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -31,7 +31,8 @@ function normalizeReviewMode(mode) { return String(mode || '') .trim() .toLowerCase() - .replace(/_/g, '-'); + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-'); } function isBlindOrAnonymous(mode) { diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index a4a4e80e..09c16d12 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -7,7 +7,7 @@ - Malformed artifact change timestamps require review and inline-comment recertification before review credit or comment evidence is applied. - Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, artifact timing evidence is malformed, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is malformed. - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. -- Public, semi-private, and double-blind review modes are represented. +- Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. - Review history is emitted in a project timeline packet. ## Contributor Credits @@ -24,6 +24,6 @@ ## Privacy And Trust -- Double-blind reviewer IDs are replaced by anonymous labels. +- Double-blind and fully anonymous reviewer IDs are replaced by anonymous labels even when incoming mode names use spaces or underscores. - Synthetic data only; no private profile emails, credentials, or external API calls. - The timeline audit digest is deterministic for reviewer verification. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index c8237ec4..35c38bef 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -82,6 +82,46 @@ function testBlindModeRedactionAcceptsCaseAndSeparatorVariants() { assert.ok(!JSON.stringify(result).includes('orcid:0000-0002-variant-private')); } +function testBlindModeRedactionAcceptsSpaceSeparatedModes() { + const project = buildSampleProject(); + project.reviews = [ + { + id: 'review-space-blind', + reviewerId: 'orcid:0000-0002-space-private', + anonymousLabel: 'anonymous-reviewer-space', + mode: 'Double Blind', + artifactId: 'dataset-cohort-table', + evidenceDigest: 'sha256:dataset-v1', + submittedAt: '2026-05-18T09:00:00Z', + reputationDelta: 18 + } + ]; + project.inlineComments = [ + { + id: 'comment-space-anonymous', + reviewerId: 'orcid:0000-0002-comment-private', + anonymousLabel: 'anonymous-commenter-space', + mode: 'Fully Anonymous', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v2', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + ]; + + const result = evaluateRecertification(project); + const reviewTask = byId(result.recertificationTasks, 'recertify-review-space-blind'); + const commentTask = byId(result.recertificationTasks, 'recertify-comment-space-anonymous'); + + assert.equal(reviewTask.reviewer, 'anonymous-reviewer-space'); + assert.equal(commentTask.reviewer, 'anonymous-commenter-space'); + assert.ok(!JSON.stringify(result).includes('orcid:0000-0002-space-private')); + assert.ok(!JSON.stringify(result).includes('orcid:0000-0002-comment-private')); +} + function testInlineCommentsUseArtifactAnchorsForRecertification() { const project = buildSampleProject(); const result = evaluateRecertification(project); @@ -413,6 +453,7 @@ const tests = [ testCurrentOrRecertifiedReviewsKeepReputationCredit, testAnonymousReviewerIdentityIsRedacted, testBlindModeRedactionAcceptsCaseAndSeparatorVariants, + testBlindModeRedactionAcceptsSpaceSeparatedModes, testInlineCommentsUseArtifactAnchorsForRecertification, testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift, testInvalidInlineCommentTimestampRequiresRecertification, From 6768265768fb58ed3ef79d3317f305b72e345444 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 00:49:08 +0200 Subject: [PATCH 11/22] Reject missing recertification timestamps --- .../README.md | 2 +- .../acceptance-notes.md | 4 +- .../index.js | 12 +- .../requirements-map.md | 6 +- .../test.js | 118 ++++++++++++++++++ 5 files changed, 133 insertions(+), 9 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 9286de12..445a1153 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds malformed review, artifact, and inline-comment timestamps for review and comment recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index f1e0675f..d3b6b8ba 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -14,9 +14,9 @@ Validation targets: - space-separated blind and fully anonymous mode labels do not leak raw reviewer IDs - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged -- malformed inline comment timestamps require recertification before comment evidence is treated as current +- missing or malformed inline comment timestamps require recertification before comment evidence is treated as current - missing inline comment anchor metadata requires recertification instead of crashing evidence evaluation - missing artifact anchor maps require comment recertification instead of crashing evidence evaluation - stale inline-comment evidence blocks reputation updates even when no stale review is present -- malformed artifact change timestamps require review and inline-comment recertification before reputation credit or comment evidence is applied +- missing or malformed artifact change timestamps require review and inline-comment recertification before reputation credit or comment evidence is applied - timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 159e1098..a60da0c0 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -24,6 +24,10 @@ function isoTime(value) { } function hasValidTime(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return false; + } + return Number.isFinite(isoTime(value)); } @@ -54,9 +58,11 @@ function findArtifact(project, artifactId) { function evaluateReview(project, review) { const artifact = findArtifact(project, review.artifactId); const reasons = []; - const reviewedAt = isoTime(review.recertifiedAt || review.submittedAt); + const reviewTime = review.recertifiedAt || review.submittedAt; + const reviewTimeIsValid = hasValidTime(reviewTime); + const reviewedAt = reviewTimeIsValid ? isoTime(reviewTime) : null; - if (!hasValidTime(review.recertifiedAt || review.submittedAt)) { + if (!reviewTimeIsValid) { reasons.push('invalid-review-timestamp'); } @@ -71,7 +77,7 @@ function evaluateReview(project, review) { reasons.push('artifact-digest-changed'); } - if (hasValidTime(artifact.changedAt) && isoTime(artifact.changedAt) > reviewedAt) { + if (reviewTimeIsValid && hasValidTime(artifact.changedAt) && isoTime(artifact.changedAt) > reviewedAt) { reasons.push('artifact-updated-after-review'); } } diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index 09c16d12..ef47d8d1 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -3,9 +3,9 @@ ## Peer Reviews & Comments - Structured peer-review evidence is tied to reviewed artifact digests. -- Malformed review submission or recertification timestamps require recertification before review credit is applied. -- Malformed artifact change timestamps require review and inline-comment recertification before review credit or comment evidence is applied. -- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, artifact timing evidence is malformed, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is malformed. +- Missing or malformed review submission or recertification timestamps require recertification before review credit is applied. +- Missing or malformed artifact change timestamps require review and inline-comment recertification before review credit or comment evidence is applied. +- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, artifact timing evidence is missing or malformed, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is missing or malformed. - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. - Review history is emitted in a project timeline packet. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 35c38bef..a26088ba 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -216,6 +216,47 @@ function testInvalidInlineCommentTimestampRequiresRecertification() { assert.deepEqual(task.reasons, ['invalid-comment-timestamp']); } +function testMissingInlineCommentTimestampRequiresRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-19T09:30:00Z', + currentAnchors: { + 'src/analyze.py#L41': { line: 41 } + } + } + ]; + project.reviews = []; + project.inlineComments = [ + { + id: 'comment-missing-submitted-at', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: null + } + ]; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'comment-missing-submitted-at'); + const task = byId(result.recertificationTasks, 'recertify-comment-missing-submitted-at'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'stale'); + assert.deepEqual(comment.reasons, ['invalid-comment-timestamp']); + assert.equal(task.kind, 'inline-comment'); + assert.deepEqual(task.reasons, ['invalid-comment-timestamp']); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + function testMissingInlineCommentAnchorMetadataRequiresRecertification() { const project = buildSampleProject(); project.artifacts = [ @@ -359,6 +400,43 @@ function testInvalidReviewTimestampRequiresRecertification() { assert.equal(action.effectiveDelta, 0); } +function testMissingReviewTimestampRequiresRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-19T09:30:00Z', + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-missing-submitted-at', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: null, + reputationDelta: 14 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const decision = byId(result.reviewDecisions, 'review-missing-submitted-at'); + const task = byId(result.recertificationTasks, 'recertify-review-missing-submitted-at'); + const action = byId(result.reputationActions, 'review-missing-submitted-at'); + + assert.equal(decision.status, 'recertification-required'); + assert.deepEqual(decision.reasons, ['invalid-review-timestamp']); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['invalid-review-timestamp']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); +} + function testInvalidArtifactTimestampRequiresReviewRecertification() { const project = buildSampleProject(); project.artifacts = [ @@ -396,6 +474,43 @@ function testInvalidArtifactTimestampRequiresReviewRecertification() { assert.equal(action.effectiveDelta, 0); } +function testMissingArtifactTimestampRequiresReviewRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: null, + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-missing-artifact-timestamp', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + reputationDelta: 14 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const decision = byId(result.reviewDecisions, 'review-missing-artifact-timestamp'); + const task = byId(result.recertificationTasks, 'recertify-review-missing-artifact-timestamp'); + const action = byId(result.reputationActions, 'review-missing-artifact-timestamp'); + + assert.equal(decision.status, 'recertification-required'); + assert.deepEqual(decision.reasons, ['invalid-artifact-timestamp']); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['invalid-artifact-timestamp']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); +} + function testInvalidArtifactTimestampRequiresCommentRecertification() { const project = buildSampleProject(); project.artifacts = [ @@ -457,11 +572,14 @@ const tests = [ testInlineCommentsUseArtifactAnchorsForRecertification, testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift, testInvalidInlineCommentTimestampRequiresRecertification, + testMissingInlineCommentTimestampRequiresRecertification, testMissingInlineCommentAnchorMetadataRequiresRecertification, testMissingArtifactAnchorMapRequiresCommentRecertification, testStaleInlineCommentsBlockReputationUpdateWithoutStaleReviews, testInvalidReviewTimestampRequiresRecertification, + testMissingReviewTimestampRequiresRecertification, testInvalidArtifactTimestampRequiresReviewRecertification, + testMissingArtifactTimestampRequiresReviewRecertification, testInvalidArtifactTimestampRequiresCommentRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles ]; From f567181c6d2f9e50bede5c0ad6b076fb32786a17 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 07:13:17 +0200 Subject: [PATCH 12/22] Harden peer review identity recertification --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 20 ++++++- .../reports/recertification-packet.json | 57 +++++++++++++++++-- .../reports/recertification-report.md | 10 ++-- .../reports/summary.svg | 8 +-- .../requirements-map.md | 2 + .../test.js | 42 +++++++++++++- 8 files changed, 125 insertions(+), 17 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 445a1153..3ea8d029 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index d3b6b8ba..9b3b8030 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -12,6 +12,7 @@ Validation targets: - recertified code review keeps its 14 point reputation delta - double-blind reviewer identity is not leaked in tasks or timeline events - space-separated blind and fully anonymous mode labels do not leak raw reviewer IDs +- public or semi-private reviews without reviewer identity are frozen for recertification instead of applying credit to an undefined profile - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged - missing or malformed inline comment timestamps require recertification before comment evidence is treated as current diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index a60da0c0..c1ac2707 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -31,6 +31,10 @@ function hasValidTime(value) { return Number.isFinite(isoTime(value)); } +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + function normalizeReviewMode(mode) { return String(mode || '') .trim() @@ -45,10 +49,10 @@ function isBlindOrAnonymous(mode) { function reviewerDisplay(item) { if (isBlindOrAnonymous(item.mode)) { - return item.anonymousLabel || 'anonymous-reviewer'; + return hasText(item.anonymousLabel) ? item.anonymousLabel.trim() : 'anonymous-reviewer'; } - return `reviewer:${item.reviewerId}`; + return hasText(item.reviewerId) ? `reviewer:${item.reviewerId.trim()}` : 'reviewer:unverified'; } function findArtifact(project, artifactId) { @@ -66,6 +70,10 @@ function evaluateReview(project, review) { reasons.push('invalid-review-timestamp'); } + if (!isBlindOrAnonymous(review.mode) && !hasText(review.reviewerId)) { + reasons.push('reviewer-identity-missing'); + } + if (!artifact) { reasons.push('artifact-missing'); } else { @@ -338,6 +346,14 @@ function buildSampleProject() { submittedAt: '2026-05-12T10:00:00Z', reputationDelta: 9 }, + { + id: 'review-missing-public-reviewer', + mode: 'public', + artifactId: 'notebook-methods', + evidenceDigest: 'sha256:notebook-v1', + submittedAt: '2026-05-12T10:30:00Z', + reputationDelta: 12 + }, { id: 'review-code-recertified', reviewerId: 'orcid:0000-0002-reviewer-c', diff --git a/peer-review-evidence-recertification-guard/reports/recertification-packet.json b/peer-review-evidence-recertification-guard/reports/recertification-packet.json index 61991556..1b1c6701 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-packet.json +++ b/peer-review-evidence-recertification-guard/reports/recertification-packet.json @@ -29,6 +29,20 @@ "evidenceDigest": "sha256:notebook-v1", "currentArtifactDigest": "sha256:notebook-v1" }, + { + "id": "review-missing-public-reviewer", + "artifactId": "notebook-methods", + "mode": "public", + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "reviewer-identity-missing" + ], + "submittedAt": "2026-05-12T10:30:00Z", + "recertifiedAt": null, + "evidenceDigest": "sha256:notebook-v1", + "currentArtifactDigest": "sha256:notebook-v1" + }, { "id": "review-code-recertified", "artifactId": "analysis-code", @@ -91,6 +105,14 @@ "action": "apply-current-delta", "effectiveDelta": 9 }, + { + "id": "review-missing-public-reviewer", + "appliesTo": "reviewer:unverified", + "originalDelta": 12, + "reasonDigest": "sha256:13d8651c7be17689702d06eeea23841ddd36bd4ec3ff039ab97142bc515bb797", + "action": "freeze-until-recertified", + "effectiveDelta": 0 + }, { "id": "review-code-recertified", "appliesTo": "reviewer:orcid:0000-0002-reviewer-c", @@ -127,6 +149,23 @@ "artifact-updated-after-review" ] }, + { + "id": "recertify-review-missing-public-reviewer", + "kind": "peer-review", + "reviewId": "review-missing-public-reviewer", + "artifactId": "notebook-methods", + "reviewer": "reviewer:unverified", + "priority": "normal", + "requiredAction": "confirm-review-still-applies-to-current-artifact", + "blockedProfileUpdates": [ + "reputation-score", + "leaderboards", + "badges" + ], + "reasons": [ + "reviewer-identity-missing" + ] + }, { "id": "recertify-review-blind-data", "kind": "peer-review", @@ -182,6 +221,16 @@ "status": "current", "reasons": [] }, + { + "type": "review-recertification-required", + "reviewId": "review-missing-public-reviewer", + "artifactId": "notebook-methods", + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "reviewer-identity-missing" + ] + }, { "type": "review-evidence-current", "reviewId": "review-code-recertified", @@ -213,13 +262,13 @@ ] } ], - "auditDigest": "sha256:05d6bfae8c13031699429ce8eed05abc4046a732fb44f34746fca82415bb7f9f" + "auditDigest": "sha256:ca8b5b6b76b387794fe73c846c2fb4e558f0cc7dcf195fd4a09560a6a11d1d7b" }, "summary": { - "totalReviews": 4, - "staleReviews": 2, + "totalReviews": 5, + "staleReviews": 3, "staleComments": 1, - "frozenReputationDelta": 29, + "frozenReputationDelta": 41, "recommendedAction": "block-reputation-update" } } diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index eb86ffa7..41a6915f 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -5,21 +5,23 @@ Generated: 2026-05-28T06:00:00Z ## Summary -- Total reviews evaluated: 4 -- Stale reviews requiring recertification: 2 +- Total reviews evaluated: 5 +- Stale reviews requiring recertification: 3 - Stale inline comments requiring anchor review: 1 -- Frozen reputation delta: 29 +- Frozen reputation delta: 41 - Recommended action: block-reputation-update -- Timeline audit digest: sha256:05d6bfae8c13031699429ce8eed05abc4046a732fb44f34746fca82415bb7f9f +- Timeline audit digest: sha256:ca8b5b6b76b387794fe73c846c2fb4e558f0cc7dcf195fd4a09560a6a11d1d7b ## Stale Review Evidence - review-dataset-methods: artifact-digest-changed, artifact-updated-after-review +- review-missing-public-reviewer: reviewer-identity-missing - review-blind-data: artifact-digest-changed, artifact-updated-after-review ## Recertification Tasks - recertify-review-dataset-methods (peer-review, high): confirm-review-still-applies-to-current-artifact +- recertify-review-missing-public-reviewer (peer-review, normal): confirm-review-still-applies-to-current-artifact - recertify-review-blind-data (peer-review, normal): confirm-review-still-applies-to-current-artifact - recertify-comment-code-line-41 (inline-comment, normal): confirm-comment-anchor-still-matches-current-artifact diff --git a/peer-review-evidence-recertification-guard/reports/summary.svg b/peer-review-evidence-recertification-guard/reports/summary.svg index 2c41d48c..cb05c5bc 100644 --- a/peer-review-evidence-recertification-guard/reports/summary.svg +++ b/peer-review-evidence-recertification-guard/reports/summary.svg @@ -2,11 +2,11 @@ Peer Review Evidence Recertification - Stale reviews: 2 + Stale reviews: 3 Stale comments: 1 - Frozen reputation delta: 29 + Frozen reputation delta: 41 Action: block-reputation-update - Tasks generated: 3 + Tasks generated: 4 Anonymous reviewer labels preserved without raw identity leakage. - sha256:05d6bfae8c13031699429ce8eed05abc4046a732fb44f34746fca82415bb7f9f + sha256:ca8b5b6b76b387794fe73c846c2fb4e558f0cc7dcf195fd4a09560a6a11d1d7b diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index ef47d8d1..fdc5ff6d 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -8,6 +8,7 @@ - Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, artifact timing evidence is missing or malformed, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is missing or malformed. - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. +- Public and semi-private review credit requires a concrete reviewer identity before reputation deltas are applied. - Review history is emitted in a project timeline packet. ## Contributor Credits @@ -20,6 +21,7 @@ - Current reviews apply their transparent reputation delta. - Stale reviews are blocked from leaderboards, badges, and score updates. +- Reviews without non-blind reviewer identity are blocked from leaderboards, badges, and score updates until the identity is recertified. - Recertification tasks explain which evidence must be refreshed. ## Privacy And Trust diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index a26088ba..6ffbd627 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -25,7 +25,7 @@ function testStaleReviewsFreezeReputationUntilRecertified() { assert.equal(action.effectiveDelta, 0); assert.equal(action.appliesTo, 'reviewer:orcid:0000-0002-reviewer-a'); - assert.equal(result.summary.staleReviews, 2); + assert.equal(result.summary.staleReviews, 3); assert.equal(result.summary.recommendedAction, 'block-reputation-update'); } @@ -437,6 +437,43 @@ function testMissingReviewTimestampRequiresRecertification() { assert.equal(action.effectiveDelta, 0); } +function testPublicReviewWithoutReviewerIdentityFreezesReputation() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-missing-public-reviewer', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + reputationDelta: 12 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const decision = byId(result.reviewDecisions, 'review-missing-public-reviewer'); + const task = byId(result.recertificationTasks, 'recertify-review-missing-public-reviewer'); + const action = byId(result.reputationActions, 'review-missing-public-reviewer'); + + assert.equal(decision.status, 'recertification-required'); + assert.deepEqual(decision.reasons, ['reviewer-identity-missing']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); + assert.notEqual(action.appliesTo, 'reviewer:undefined'); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['reviewer-identity-missing']); +} + function testInvalidArtifactTimestampRequiresReviewRecertification() { const project = buildSampleProject(); project.artifacts = [ @@ -558,7 +595,7 @@ function testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles() { const result = evaluateRecertification(project); assert.equal(result.timelinePacket.projectId, 'project-alpha-replication'); - assert.equal(result.timelinePacket.events.length, 5); + assert.equal(result.timelinePacket.events.length, 6); assert.ok(result.timelinePacket.auditDigest.startsWith('sha256:')); assert.ok(!JSON.stringify(result.timelinePacket).includes('private@')); } @@ -578,6 +615,7 @@ const tests = [ testStaleInlineCommentsBlockReputationUpdateWithoutStaleReviews, testInvalidReviewTimestampRequiresRecertification, testMissingReviewTimestampRequiresRecertification, + testPublicReviewWithoutReviewerIdentityFreezesReputation, testInvalidArtifactTimestampRequiresReviewRecertification, testMissingArtifactTimestampRequiresReviewRecertification, testInvalidArtifactTimestampRequiresCommentRecertification, From a400945d9a568869bf86c7c1d81fa48b61790d39 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 09:06:12 +0200 Subject: [PATCH 13/22] Harden inline comment artifact recertification --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 32 ++++++++++--- .../reports/recertification-packet.json | 5 +- .../reports/recertification-report.md | 2 +- .../reports/summary.svg | 2 +- .../requirements-map.md | 2 +- .../test.js | 47 ++++++++++++++++++- 8 files changed, 80 insertions(+), 13 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 3ea8d029..f39d927a 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, marks inline comment anchors stale when artifact evidence changes even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 9b3b8030..887840d5 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -15,6 +15,7 @@ Validation targets: - public or semi-private reviews without reviewer identity are frozen for recertification instead of applying credit to an undefined profile - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged +- artifact updates after inline comments require recertification even when digest and selector evidence still match - missing or malformed inline comment timestamps require recertification before comment evidence is treated as current - missing inline comment anchor metadata requires recertification instead of crashing evidence evaluation - missing artifact anchor maps require comment recertification instead of crashing evidence evaluation diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index c1ac2707..42be1d11 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -157,13 +157,20 @@ function evaluateComment(project, comment) { const artifact = findArtifact(project, comment.artifactId); const reasons = []; let anchorStatus = 'current'; + const commentTimeIsValid = hasValidTime(comment.submittedAt); + const commentedAt = commentTimeIsValid ? isoTime(comment.submittedAt) : null; + const markStale = () => { + if (anchorStatus !== 'missing') { + anchorStatus = 'stale'; + } + }; const hasUsableAnchor = comment.anchor && typeof comment.anchor.selector === 'string' && typeof comment.anchor.line === 'number'; - if (!hasValidTime(comment.submittedAt)) { + if (!commentTimeIsValid) { reasons.push('invalid-comment-timestamp'); - anchorStatus = 'stale'; + markStale(); } if (!hasUsableAnchor) { @@ -177,12 +184,12 @@ function evaluateComment(project, comment) { } else { if (!hasValidTime(artifact.changedAt)) { reasons.push('invalid-artifact-timestamp'); - anchorStatus = 'stale'; + markStale(); } if (artifact.currentDigest !== comment.anchorDigest) { reasons.push('artifact-digest-changed'); - anchorStatus = 'stale'; + markStale(); } if (hasUsableAnchor) { @@ -190,9 +197,20 @@ function evaluateComment(project, comment) { if (!currentAnchor) { reasons.push('anchor-missing-after-comment'); anchorStatus = 'missing'; - } else if (currentAnchor.line !== comment.anchor.line) { - reasons.push('anchor-line-shifted-after-comment'); - anchorStatus = 'stale'; + } else { + if ( + commentTimeIsValid + && hasValidTime(artifact.changedAt) + && isoTime(artifact.changedAt) > commentedAt + ) { + reasons.push('artifact-updated-after-comment'); + markStale(); + } + + if (currentAnchor.line !== comment.anchor.line) { + reasons.push('anchor-line-shifted-after-comment'); + markStale(); + } } } } diff --git a/peer-review-evidence-recertification-guard/reports/recertification-packet.json b/peer-review-evidence-recertification-guard/reports/recertification-packet.json index 1b1c6701..2034e0e5 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-packet.json +++ b/peer-review-evidence-recertification-guard/reports/recertification-packet.json @@ -80,6 +80,7 @@ "reviewer": "reviewer:orcid:0000-0002-reviewer-b", "reasons": [ "artifact-digest-changed", + "artifact-updated-after-comment", "anchor-line-shifted-after-comment" ], "anchor": { @@ -194,6 +195,7 @@ "requiredAction": "confirm-comment-anchor-still-matches-current-artifact", "reasons": [ "artifact-digest-changed", + "artifact-updated-after-comment", "anchor-line-shifted-after-comment" ] } @@ -258,11 +260,12 @@ "status": "recertification-required", "reasons": [ "artifact-digest-changed", + "artifact-updated-after-comment", "anchor-line-shifted-after-comment" ] } ], - "auditDigest": "sha256:ca8b5b6b76b387794fe73c846c2fb4e558f0cc7dcf195fd4a09560a6a11d1d7b" + "auditDigest": "sha256:1b0987a020a04c8f099d4b2b06835c92e4a97862b9de96d13834e3ca4e62107f" }, "summary": { "totalReviews": 5, diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index 41a6915f..304033d1 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -10,7 +10,7 @@ Generated: 2026-05-28T06:00:00Z - Stale inline comments requiring anchor review: 1 - Frozen reputation delta: 41 - Recommended action: block-reputation-update -- Timeline audit digest: sha256:ca8b5b6b76b387794fe73c846c2fb4e558f0cc7dcf195fd4a09560a6a11d1d7b +- Timeline audit digest: sha256:1b0987a020a04c8f099d4b2b06835c92e4a97862b9de96d13834e3ca4e62107f ## Stale Review Evidence diff --git a/peer-review-evidence-recertification-guard/reports/summary.svg b/peer-review-evidence-recertification-guard/reports/summary.svg index cb05c5bc..258be9ab 100644 --- a/peer-review-evidence-recertification-guard/reports/summary.svg +++ b/peer-review-evidence-recertification-guard/reports/summary.svg @@ -8,5 +8,5 @@ Action: block-reputation-update Tasks generated: 4 Anonymous reviewer labels preserved without raw identity leakage. - sha256:ca8b5b6b76b387794fe73c846c2fb4e558f0cc7dcf195fd4a09560a6a11d1d7b + sha256:1b0987a020a04c8f099d4b2b06835c92e4a97862b9de96d13834e3ca4e62107f diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index fdc5ff6d..a4215149 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -5,7 +5,7 @@ - Structured peer-review evidence is tied to reviewed artifact digests. - Missing or malformed review submission or recertification timestamps require recertification before review credit is applied. - Missing or malformed artifact change timestamps require review and inline-comment recertification before review credit or comment evidence is applied. -- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, artifact timing evidence is missing or malformed, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is missing or malformed. +- Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, artifact timestamps postdate the comment, artifact timing evidence is missing or malformed, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is missing or malformed. - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. - Public and semi-private review credit requires a concrete reviewer identity before reputation deltas are applied. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 6ffbd627..5425e9be 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -131,6 +131,7 @@ function testInlineCommentsUseArtifactAnchorsForRecertification() { assert.equal(comment.anchorStatus, 'stale'); assert.deepEqual(comment.reasons, [ 'artifact-digest-changed', + 'artifact-updated-after-comment', 'anchor-line-shifted-after-comment' ]); @@ -173,7 +174,50 @@ function testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift() { assert.equal(comment.status, 'recertification-required'); assert.equal(comment.anchorStatus, 'stale'); - assert.deepEqual(comment.reasons, ['artifact-digest-changed']); + assert.deepEqual(comment.reasons, [ + 'artifact-digest-changed', + 'artifact-updated-after-comment' + ]); +} + +function testArtifactUpdatedAfterInlineCommentRequiresRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-20T09:30:00Z', + currentAnchors: { + 'src/analyze.py#L41': { line: 41 } + } + } + ]; + project.reviews = []; + project.inlineComments = [ + { + id: 'comment-after-artifact-update', + reviewerId: 'orcid:0000-0002-reviewer-b', + mode: 'public', + artifactId: 'analysis-code', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + ]; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'comment-after-artifact-update'); + const task = byId(result.recertificationTasks, 'recertify-comment-after-artifact-update'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'stale'); + assert.deepEqual(comment.reasons, ['artifact-updated-after-comment']); + assert.equal(task.kind, 'inline-comment'); + assert.deepEqual(task.reasons, ['artifact-updated-after-comment']); } function testInvalidInlineCommentTimestampRequiresRecertification() { @@ -608,6 +652,7 @@ const tests = [ testBlindModeRedactionAcceptsSpaceSeparatedModes, testInlineCommentsUseArtifactAnchorsForRecertification, testInlineCommentDigestChangeStalesAnchorEvenWithoutLineShift, + testArtifactUpdatedAfterInlineCommentRequiresRecertification, testInvalidInlineCommentTimestampRequiresRecertification, testMissingInlineCommentTimestampRequiresRecertification, testMissingInlineCommentAnchorMetadataRequiresRecertification, From e86f68d527eae78f1d920561ff2f2b7d717bffb1 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 11:34:43 +0200 Subject: [PATCH 14/22] Handle sparse recertification evidence payloads --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 13 ++++ .../index.js | 18 ++++-- .../make-demo-video.py | 5 +- .../reports/demo.mp4 | Bin 45287 -> 52625 bytes .../reports/empty-evidence-packet.json | 21 +++++++ .../reports/recertification-report.md | 4 ++ .../requirements-map.md | 2 + .../test.js | 58 +++++++++++++++++- 10 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 peer-review-evidence-recertification-guard/reports/empty-evidence-packet.json diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index f39d927a..6790edbb 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run @@ -16,6 +16,7 @@ npm run check ## Outputs - `reports/recertification-packet.json` +- `reports/empty-evidence-packet.json` - `reports/recertification-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 887840d5..8fd70069 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -19,6 +19,7 @@ Validation targets: - missing or malformed inline comment timestamps require recertification before comment evidence is treated as current - missing inline comment anchor metadata requires recertification instead of crashing evidence evaluation - missing artifact anchor maps require comment recertification instead of crashing evidence evaluation +- omitted review, comment, or artifact collections in sparse project snapshots do not crash recertification evaluation - stale inline-comment evidence blocks reputation updates even when no stale review is present - missing or malformed artifact change timestamps require review and inline-comment recertification before reputation credit or comment evidence is applied - timeline packets include deterministic audit digests diff --git a/peer-review-evidence-recertification-guard/demo.js b/peer-review-evidence-recertification-guard/demo.js index a977b6a9..dc436adb 100644 --- a/peer-review-evidence-recertification-guard/demo.js +++ b/peer-review-evidence-recertification-guard/demo.js @@ -7,12 +7,20 @@ fs.mkdirSync(reportsDir, { recursive: true }); const project = buildSampleProject(); const result = evaluateRecertification(project); +const emptyEvidenceProject = { + projectId: 'project-empty-reputation-evidence', + asOf: '2026-05-30T12:00:00Z', + artifacts: [] +}; +const emptyEvidenceResult = evaluateRecertification(emptyEvidenceProject); const packetPath = path.join(reportsDir, 'recertification-packet.json'); +const emptyPacketPath = path.join(reportsDir, 'empty-evidence-packet.json'); const reportPath = path.join(reportsDir, 'recertification-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(emptyPacketPath, `${JSON.stringify(emptyEvidenceResult, null, 2)}\n`); const staleReviewList = result.reviewDecisions .filter((decision) => decision.status !== 'current') @@ -45,6 +53,10 @@ ${staleReviewList} ${taskList} +## Sparse Snapshot Guard + +Sparse project payloads that omit review, comment, or artifact collections still produce deterministic audit packets instead of runtime failures. The empty evidence fixture recommends ${emptyEvidenceResult.summary.recommendedAction} and emits ${emptyEvidenceResult.timelinePacket.events.length} timeline events. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. @@ -69,6 +81,7 @@ const svg = ` artifact.id === artifactId); + return evidenceList(project.artifacts).find((artifact) => artifact.id === artifactId); } function evaluateReview(project, review) { @@ -281,14 +285,16 @@ function buildTimelinePacket(project, reviewDecisions, commentDecisions) { } function evaluateRecertification(project) { - const reviewDecisions = project.reviews.map((review) => evaluateReview(project, review)); - const reputationActions = project.reviews.map((review, index) => + const reviews = evidenceList(project.reviews); + const inlineComments = evidenceList(project.inlineComments); + const reviewDecisions = reviews.map((review) => evaluateReview(project, review)); + const reputationActions = reviews.map((review, index) => reputationActionForReview(review, reviewDecisions[index]) ); - const commentDecisions = project.inlineComments.map((comment) => evaluateComment(project, comment)); + const commentDecisions = inlineComments.map((comment) => evaluateComment(project, comment)); const recertificationTasks = [ - ...project.reviews.map((review, index) => taskForReview(review, reviewDecisions[index])), - ...project.inlineComments.map((comment, index) => taskForComment(comment, commentDecisions[index])) + ...reviews.map((review, index) => taskForReview(review, reviewDecisions[index])), + ...inlineComments.map((comment, index) => taskForComment(comment, commentDecisions[index])) ].filter(Boolean); const staleReviews = reviewDecisions.filter((decision) => decision.status !== 'current').length; const staleComments = commentDecisions.filter((decision) => decision.status !== 'current').length; diff --git a/peer-review-evidence-recertification-guard/make-demo-video.py b/peer-review-evidence-recertification-guard/make-demo-video.py index 180fabba..339e1fad 100644 --- a/peer-review-evidence-recertification-guard/make-demo-video.py +++ b/peer-review-evidence-recertification-guard/make-demo-video.py @@ -16,8 +16,9 @@ f"drawtext=fontfile='{font}':text='Detects stale peer-review evidence after artifact changes':x=95:y=205:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='Freezes outdated reputation deltas until recertified':x=95:y=265:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='Redacts double-blind reviewer identities in task packets':x=95:y=325:fontsize=30:fontcolor=0xd7edf9", - f"drawtext=fontfile='{font}':text='Outputs JSON, Markdown, SVG, and audit digest evidence':x=95:y=385:fontsize=30:fontcolor=0xd7edf9", - f"drawtext=fontfile='{font}':text='SCIBASE issue #15 community reputation slice':x=95:y=500:fontsize=28:fontcolor=0xffdf7e", + f"drawtext=fontfile='{font}':text='Handles sparse project snapshots without runtime failures':x=95:y=385:fontsize=30:fontcolor=0xd7edf9", + f"drawtext=fontfile='{font}':text='Outputs JSON, Markdown, SVG, and audit digest evidence':x=95:y=445:fontsize=30:fontcolor=0xd7edf9", + f"drawtext=fontfile='{font}':text='SCIBASE issue #15 community reputation slice':x=95:y=545:fontsize=28:fontcolor=0xffdf7e", ] ) diff --git a/peer-review-evidence-recertification-guard/reports/demo.mp4 b/peer-review-evidence-recertification-guard/reports/demo.mp4 index f307f087104f6715aa76b350894cb97566b29311..43195f3624f6d456d865a5ea83f03ec9041f7e9b 100644 GIT binary patch delta 22865 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 delta 15574 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 diff --git a/peer-review-evidence-recertification-guard/reports/empty-evidence-packet.json b/peer-review-evidence-recertification-guard/reports/empty-evidence-packet.json new file mode 100644 index 00000000..0c450ae8 --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/empty-evidence-packet.json @@ -0,0 +1,21 @@ +{ + "projectId": "project-empty-reputation-evidence", + "generatedAt": "2026-05-30T12:00:00Z", + "reviewDecisions": [], + "commentDecisions": [], + "reputationActions": [], + "recertificationTasks": [], + "timelinePacket": { + "projectId": "project-empty-reputation-evidence", + "generatedAt": "2026-05-30T12:00:00Z", + "events": [], + "auditDigest": "sha256:c07bcfcdaf03695001e50cd90924196dc27efbfe292e69e25dd870c3832cf3c6" + }, + "summary": { + "totalReviews": 0, + "staleReviews": 0, + "staleComments": 0, + "frozenReputationDelta": 0, + "recommendedAction": "allow-reputation-update" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index 304033d1..96f19dee 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -25,6 +25,10 @@ Generated: 2026-05-28T06:00:00Z - recertify-review-blind-data (peer-review, normal): confirm-review-still-applies-to-current-artifact - recertify-comment-code-line-41 (inline-comment, normal): confirm-comment-anchor-still-matches-current-artifact +## Sparse Snapshot Guard + +Sparse project payloads that omit review, comment, or artifact collections still produce deterministic audit packets instead of runtime failures. The empty evidence fixture recommends allow-reputation-update and emits 0 timeline events. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index a4215149..d0bacafc 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -10,6 +10,7 @@ - Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. - Public and semi-private review credit requires a concrete reviewer identity before reputation deltas are applied. - Review history is emitted in a project timeline packet. +- Sparse project snapshots that omit review, comment, or artifact collections are evaluated as empty or missing evidence instead of runtime failures. ## Contributor Credits @@ -23,6 +24,7 @@ - Stale reviews are blocked from leaderboards, badges, and score updates. - Reviews without non-blind reviewer identity are blocked from leaderboards, badges, and score updates until the identity is recertified. - Recertification tasks explain which evidence must be refreshed. +- Empty evidence snapshots produce an allow decision with zero frozen reputation delta and no synthetic tasks. ## Privacy And Trust diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 5425e9be..c149009e 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -644,6 +644,60 @@ function testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles() { assert.ok(!JSON.stringify(result.timelinePacket).includes('private@')); } +function testMissingReviewAndCommentListsEvaluateAsEmptyEvidence() { + const project = { + projectId: 'project-empty-reputation-evidence', + asOf: '2026-05-30T12:00:00Z', + artifacts: [] + }; + + const result = evaluateRecertification(project); + + assert.deepEqual(result.reviewDecisions, []); + assert.deepEqual(result.commentDecisions, []); + assert.deepEqual(result.reputationActions, []); + assert.deepEqual(result.recertificationTasks, []); + assert.equal(result.summary.totalReviews, 0); + assert.equal(result.summary.staleReviews, 0); + assert.equal(result.summary.staleComments, 0); + assert.equal(result.summary.frozenReputationDelta, 0); + assert.equal(result.summary.recommendedAction, 'allow-reputation-update'); + assert.equal(result.timelinePacket.events.length, 0); +} + +function testMissingArtifactListRequiresRecertificationInsteadOfCrashing() { + const project = { + projectId: 'project-missing-artifact-list', + asOf: '2026-05-30T12:10:00Z', + reviews: [ + { + id: 'review-without-artifact-list', + artifactId: 'supplementary-methods', + mode: 'public', + reviewerId: 'orcid:0000-0002-9999-8888', + submittedAt: '2026-05-29T09:00:00Z', + evidenceDigest: 'sha256:methods-v1', + reputationDelta: 9 + } + ], + inlineComments: [] + }; + + const result = evaluateRecertification(project); + const review = byId(result.reviewDecisions, 'review-without-artifact-list'); + const action = byId(result.reputationActions, 'review-without-artifact-list'); + const task = byId(result.recertificationTasks, 'recertify-review-without-artifact-list'); + + assert.equal(review.status, 'recertification-required'); + assert.deepEqual(review.reasons, ['artifact-missing']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['artifact-missing']); + assert.equal(result.summary.staleReviews, 1); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + const tests = [ testStaleReviewsFreezeReputationUntilRecertified, testCurrentOrRecertifiedReviewsKeepReputationCredit, @@ -664,7 +718,9 @@ const tests = [ testInvalidArtifactTimestampRequiresReviewRecertification, testMissingArtifactTimestampRequiresReviewRecertification, testInvalidArtifactTimestampRequiresCommentRecertification, - testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles + testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles, + testMissingReviewAndCommentListsEvaluateAsEmptyEvidence, + testMissingArtifactListRequiresRecertificationInsteadOfCrashing ]; for (const test of tests) { From 476d8c6fa046888a861fc23ce3d6a1aa6faabf3d Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 14:06:48 +0200 Subject: [PATCH 15/22] Harden peer review reputation delta evidence --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 37 +++++++++ .../index.js | 18 ++++- .../make-demo-video.py | 2 +- .../reports/demo.mp4 | Bin 52625 -> 52616 bytes .../invalid-reputation-delta-packet.json | 74 ++++++++++++++++++ .../reports/recertification-report.md | 4 + .../requirements-map.md | 2 + .../test.js | 41 ++++++++++ 10 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 peer-review-evidence-recertification-guard/reports/invalid-reputation-delta-packet.json diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 6790edbb..547185e4 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run @@ -17,6 +17,7 @@ npm run check - `reports/recertification-packet.json` - `reports/empty-evidence-packet.json` +- `reports/invalid-reputation-delta-packet.json` - `reports/recertification-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 8fd70069..96675c0c 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -13,6 +13,7 @@ Validation targets: - double-blind reviewer identity is not leaked in tasks or timeline events - space-separated blind and fully anonymous mode labels do not leak raw reviewer IDs - public or semi-private reviews without reviewer identity are frozen for recertification instead of applying credit to an undefined profile +- malformed reputation-delta evidence is frozen for recertification instead of applying non-numeric profile credit - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged - artifact updates after inline comments require recertification even when digest and selector evidence still match diff --git a/peer-review-evidence-recertification-guard/demo.js b/peer-review-evidence-recertification-guard/demo.js index dc436adb..55cdbb34 100644 --- a/peer-review-evidence-recertification-guard/demo.js +++ b/peer-review-evidence-recertification-guard/demo.js @@ -13,14 +13,18 @@ const emptyEvidenceProject = { artifacts: [] }; const emptyEvidenceResult = evaluateRecertification(emptyEvidenceProject); +const invalidReputationDeltaProject = buildInvalidReputationDeltaProject(); +const invalidReputationDeltaResult = evaluateRecertification(invalidReputationDeltaProject); const packetPath = path.join(reportsDir, 'recertification-packet.json'); const emptyPacketPath = path.join(reportsDir, 'empty-evidence-packet.json'); +const invalidDeltaPacketPath = path.join(reportsDir, 'invalid-reputation-delta-packet.json'); const reportPath = path.join(reportsDir, 'recertification-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(emptyPacketPath, `${JSON.stringify(emptyEvidenceResult, null, 2)}\n`); +fs.writeFileSync(invalidDeltaPacketPath, `${JSON.stringify(invalidReputationDeltaResult, null, 2)}\n`); const staleReviewList = result.reviewDecisions .filter((decision) => decision.status !== 'current') @@ -57,6 +61,10 @@ ${taskList} Sparse project payloads that omit review, comment, or artifact collections still produce deterministic audit packets instead of runtime failures. The empty evidence fixture recommends ${emptyEvidenceResult.summary.recommendedAction} and emits ${emptyEvidenceResult.timelinePacket.events.length} timeline events. +## Invalid Reputation Delta Packet + +Malformed review reputation deltas require recertification before profile credit is applied. The invalid-delta fixture recommends ${invalidReputationDeltaResult.summary.recommendedAction}, emits ${invalidReputationDeltaResult.summary.staleReviews} stale review, and normalizes the frozen reputation delta to ${invalidReputationDeltaResult.summary.frozenReputationDelta}. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. @@ -82,6 +90,35 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, emptyPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, invalidDeltaPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Recommended action: ${result.summary.recommendedAction}`); + +function buildInvalidReputationDeltaProject() { + return { + projectId: 'project-invalid-reputation-delta', + asOf: '2026-05-30T12:30:00Z', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ], + reviews: [ + { + id: 'review-invalid-reputation-delta', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + reputationDelta: '18' + } + ], + inlineComments: [] + }; +} diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index bf9ff413..c95e506f 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -35,6 +35,14 @@ function hasText(value) { return typeof value === 'string' && value.trim().length > 0; } +function hasValidReputationDelta(value) { + return typeof value === 'number' && Number.isFinite(value); +} + +function reputationDeltaFor(review) { + return hasValidReputationDelta(review.reputationDelta) ? review.reputationDelta : 0; +} + function normalizeReviewMode(mode) { return String(mode || '') .trim() @@ -78,6 +86,10 @@ function evaluateReview(project, review) { reasons.push('reviewer-identity-missing'); } + if (!hasValidReputationDelta(review.reputationDelta)) { + reasons.push('invalid-reputation-delta'); + } + if (!artifact) { reasons.push('artifact-missing'); } else { @@ -115,7 +127,7 @@ function reputationActionForReview(review, decision) { const base = { id: review.id, appliesTo: reviewerDisplay(review), - originalDelta: review.reputationDelta, + originalDelta: reputationDeltaFor(review), reasonDigest: digest({ reviewId: review.id, reasons: decision.reasons, @@ -128,7 +140,7 @@ function reputationActionForReview(review, decision) { return { ...base, action: 'apply-current-delta', - effectiveDelta: review.reputationDelta + effectiveDelta: reputationDeltaFor(review) }; } @@ -150,7 +162,7 @@ function taskForReview(review, decision) { reviewId: review.id, artifactId: review.artifactId, reviewer: decision.reviewer, - priority: Math.abs(review.reputationDelta) >= 15 ? 'high' : 'normal', + priority: Math.abs(reputationDeltaFor(review)) >= 15 ? 'high' : 'normal', requiredAction: 'confirm-review-still-applies-to-current-artifact', blockedProfileUpdates: ['reputation-score', 'leaderboards', 'badges'], reasons: decision.reasons diff --git a/peer-review-evidence-recertification-guard/make-demo-video.py b/peer-review-evidence-recertification-guard/make-demo-video.py index 339e1fad..9a82ee02 100644 --- a/peer-review-evidence-recertification-guard/make-demo-video.py +++ b/peer-review-evidence-recertification-guard/make-demo-video.py @@ -16,7 +16,7 @@ f"drawtext=fontfile='{font}':text='Detects stale peer-review evidence after artifact changes':x=95:y=205:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='Freezes outdated reputation deltas until recertified':x=95:y=265:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='Redacts double-blind reviewer identities in task packets':x=95:y=325:fontsize=30:fontcolor=0xd7edf9", - f"drawtext=fontfile='{font}':text='Handles sparse project snapshots without runtime failures':x=95:y=385:fontsize=30:fontcolor=0xd7edf9", + f"drawtext=fontfile='{font}':text='Blocks malformed reputation deltas before profile credit':x=95:y=385:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='Outputs JSON, Markdown, SVG, and audit digest evidence':x=95:y=445:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='SCIBASE issue #15 community reputation slice':x=95:y=545:fontsize=28:fontcolor=0xffdf7e", ] diff --git a/peer-review-evidence-recertification-guard/reports/demo.mp4 b/peer-review-evidence-recertification-guard/reports/demo.mp4 index 43195f3624f6d456d865a5ea83f03ec9041f7e9b..f07079fc23ba83475a4e38fa00ec81f14948ed03 100644 GIT binary patch delta 22738 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 c|8Ilh;h?#vJUKWhKs{~t>KjdK4H`Va4a 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%0DQ50pc|MEAHZ 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 Kl72apQvL%`%?Q;1 diff --git a/peer-review-evidence-recertification-guard/reports/invalid-reputation-delta-packet.json b/peer-review-evidence-recertification-guard/reports/invalid-reputation-delta-packet.json new file mode 100644 index 00000000..d060b097 --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/invalid-reputation-delta-packet.json @@ -0,0 +1,74 @@ +{ + "projectId": "project-invalid-reputation-delta", + "generatedAt": "2026-05-30T12:30:00Z", + "reviewDecisions": [ + { + "id": "review-invalid-reputation-delta", + "artifactId": "analysis-code", + "mode": "public", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "status": "recertification-required", + "reasons": [ + "invalid-reputation-delta" + ], + "submittedAt": "2026-05-16T11:00:00Z", + "recertifiedAt": null, + "evidenceDigest": "sha256:code-v3", + "currentArtifactDigest": "sha256:code-v3" + } + ], + "commentDecisions": [], + "reputationActions": [ + { + "id": "review-invalid-reputation-delta", + "appliesTo": "reviewer:orcid:0000-0002-reviewer-c", + "originalDelta": 0, + "reasonDigest": "sha256:11295498de075b5d18c63d5855cdc6b15c30f825ba0f68edad125c3f4bbfe605", + "action": "freeze-until-recertified", + "effectiveDelta": 0 + } + ], + "recertificationTasks": [ + { + "id": "recertify-review-invalid-reputation-delta", + "kind": "peer-review", + "reviewId": "review-invalid-reputation-delta", + "artifactId": "analysis-code", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "priority": "normal", + "requiredAction": "confirm-review-still-applies-to-current-artifact", + "blockedProfileUpdates": [ + "reputation-score", + "leaderboards", + "badges" + ], + "reasons": [ + "invalid-reputation-delta" + ] + } + ], + "timelinePacket": { + "projectId": "project-invalid-reputation-delta", + "generatedAt": "2026-05-30T12:30:00Z", + "events": [ + { + "type": "review-recertification-required", + "reviewId": "review-invalid-reputation-delta", + "artifactId": "analysis-code", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "status": "recertification-required", + "reasons": [ + "invalid-reputation-delta" + ] + } + ], + "auditDigest": "sha256:a643983186a0dc6feb3f8e4ca41621c20ce5cb9f9db6c9cfc4a1d52025bae1a2" + }, + "summary": { + "totalReviews": 1, + "staleReviews": 1, + "staleComments": 0, + "frozenReputationDelta": 0, + "recommendedAction": "block-reputation-update" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index 96f19dee..596d3d8e 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -29,6 +29,10 @@ Generated: 2026-05-28T06:00:00Z Sparse project payloads that omit review, comment, or artifact collections still produce deterministic audit packets instead of runtime failures. The empty evidence fixture recommends allow-reputation-update and emits 0 timeline events. +## Invalid Reputation Delta Packet + +Malformed review reputation deltas require recertification before profile credit is applied. The invalid-delta fixture recommends block-reputation-update, emits 1 stale review, and normalizes the frozen reputation delta to 0. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index d0bacafc..e426a909 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -15,6 +15,7 @@ ## Contributor Credits - Review-derived reputation deltas are preserved as original deltas. +- Missing or malformed reputation-delta evidence blocks profile credit and is normalized out of frozen-delta summary math until recertified. - Stale review evidence freezes effective deltas until recertification, and stale inline-comment evidence blocks reputation updates. - Audit packets keep enough evidence for profile and citation-page credit decisions. @@ -22,6 +23,7 @@ - Current reviews apply their transparent reputation delta. - Stale reviews are blocked from leaderboards, badges, and score updates. +- Reviews with malformed reputation deltas are blocked from leaderboards, badges, and score updates until the delta is recertified. - Reviews without non-blind reviewer identity are blocked from leaderboards, badges, and score updates until the identity is recertified. - Recertification tasks explain which evidence must be refreshed. - Empty evidence snapshots produce an allow decision with zero frozen reputation delta and no synthetic tasks. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index c149009e..632e42f0 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -518,6 +518,46 @@ function testPublicReviewWithoutReviewerIdentityFreezesReputation() { assert.deepEqual(task.reasons, ['reviewer-identity-missing']); } +function testInvalidReputationDeltaRequiresRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-invalid-reputation-delta', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + reputationDelta: '18' + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const decision = byId(result.reviewDecisions, 'review-invalid-reputation-delta'); + const task = byId(result.recertificationTasks, 'recertify-review-invalid-reputation-delta'); + const action = byId(result.reputationActions, 'review-invalid-reputation-delta'); + + assert.equal(decision.status, 'recertification-required'); + assert.deepEqual(decision.reasons, ['invalid-reputation-delta']); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['invalid-reputation-delta']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.originalDelta, 0); + assert.equal(action.effectiveDelta, 0); + assert.equal(result.summary.frozenReputationDelta, 0); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + function testInvalidArtifactTimestampRequiresReviewRecertification() { const project = buildSampleProject(); project.artifacts = [ @@ -715,6 +755,7 @@ const tests = [ testInvalidReviewTimestampRequiresRecertification, testMissingReviewTimestampRequiresRecertification, testPublicReviewWithoutReviewerIdentityFreezesReputation, + testInvalidReputationDeltaRequiresRecertification, testInvalidArtifactTimestampRequiresReviewRecertification, testMissingArtifactTimestampRequiresReviewRecertification, testInvalidArtifactTimestampRequiresCommentRecertification, From 737d8364f1c2977d1a211db5335b89a2c6c308bb Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 16:01:45 +0200 Subject: [PATCH 16/22] Hold malformed peer review evidence entries --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 27 +++++ .../index.js | 72 +++++++++++- .../make-demo-video.py | 2 +- .../reports/demo.mp4 | Bin 52616 -> 52113 bytes .../reports/malformed-evidence-packet.json | 108 ++++++++++++++++++ .../reports/recertification-report.md | 4 + .../requirements-map.md | 1 + .../test.js | 66 ++++++++++- 10 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 peer-review-evidence-recertification-guard/reports/malformed-evidence-packet.json diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 547185e4..1729780c 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed review and inline-comment evidence entries into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run @@ -18,6 +18,7 @@ npm run check - `reports/recertification-packet.json` - `reports/empty-evidence-packet.json` - `reports/invalid-reputation-delta-packet.json` +- `reports/malformed-evidence-packet.json` - `reports/recertification-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 96675c0c..f5dd81c5 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -14,6 +14,7 @@ Validation targets: - space-separated blind and fully anonymous mode labels do not leak raw reviewer IDs - public or semi-private reviews without reviewer identity are frozen for recertification instead of applying credit to an undefined profile - malformed reputation-delta evidence is frozen for recertification instead of applying non-numeric profile credit +- malformed review and inline-comment entries inside otherwise valid evidence arrays create recertification holds instead of crashing or being silently ignored - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged - artifact updates after inline comments require recertification even when digest and selector evidence still match diff --git a/peer-review-evidence-recertification-guard/demo.js b/peer-review-evidence-recertification-guard/demo.js index 55cdbb34..e6d17a17 100644 --- a/peer-review-evidence-recertification-guard/demo.js +++ b/peer-review-evidence-recertification-guard/demo.js @@ -15,16 +15,20 @@ const emptyEvidenceProject = { const emptyEvidenceResult = evaluateRecertification(emptyEvidenceProject); const invalidReputationDeltaProject = buildInvalidReputationDeltaProject(); const invalidReputationDeltaResult = evaluateRecertification(invalidReputationDeltaProject); +const malformedEvidenceProject = buildMalformedEvidenceProject(); +const malformedEvidenceResult = evaluateRecertification(malformedEvidenceProject); const packetPath = path.join(reportsDir, 'recertification-packet.json'); const emptyPacketPath = path.join(reportsDir, 'empty-evidence-packet.json'); const invalidDeltaPacketPath = path.join(reportsDir, 'invalid-reputation-delta-packet.json'); +const malformedEvidencePacketPath = path.join(reportsDir, 'malformed-evidence-packet.json'); const reportPath = path.join(reportsDir, 'recertification-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(emptyPacketPath, `${JSON.stringify(emptyEvidenceResult, null, 2)}\n`); fs.writeFileSync(invalidDeltaPacketPath, `${JSON.stringify(invalidReputationDeltaResult, null, 2)}\n`); +fs.writeFileSync(malformedEvidencePacketPath, `${JSON.stringify(malformedEvidenceResult, null, 2)}\n`); const staleReviewList = result.reviewDecisions .filter((decision) => decision.status !== 'current') @@ -65,6 +69,10 @@ Sparse project payloads that omit review, comment, or artifact collections still Malformed review reputation deltas require recertification before profile credit is applied. The invalid-delta fixture recommends ${invalidReputationDeltaResult.summary.recommendedAction}, emits ${invalidReputationDeltaResult.summary.staleReviews} stale review, and normalizes the frozen reputation delta to ${invalidReputationDeltaResult.summary.frozenReputationDelta}. +## Malformed Evidence Entry Packet + +Malformed review and inline-comment entries inside otherwise valid evidence arrays are converted into recertification holds instead of crashing or being silently ignored. The malformed-entry fixture recommends ${malformedEvidenceResult.summary.recommendedAction}, emits ${malformedEvidenceResult.summary.staleReviews} stale review and ${malformedEvidenceResult.summary.staleComments} stale inline comment, and creates ${malformedEvidenceResult.recertificationTasks.length} recertification tasks. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. @@ -91,6 +99,7 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, emptyPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidDeltaPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedEvidencePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Recommended action: ${result.summary.recommendedAction}`); @@ -122,3 +131,21 @@ function buildInvalidReputationDeltaProject() { inlineComments: [] }; } + +function buildMalformedEvidenceProject() { + return { + projectId: 'project-malformed-review-comment-evidence', + asOf: '2026-05-30T12:35:00Z', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ], + reviews: [null], + inlineComments: [null] + }; +} diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index c95e506f..3c287b2b 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -39,6 +39,10 @@ function hasValidReputationDelta(value) { return typeof value === 'number' && Number.isFinite(value); } +function isRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + function reputationDeltaFor(review) { return hasValidReputationDelta(review.reputationDelta) ? review.reputationDelta : 0; } @@ -67,11 +71,61 @@ function evidenceList(value) { return Array.isArray(value) ? value : []; } +function evidenceRecords(value) { + return evidenceList(value).filter(isRecord); +} + +function normalizeReviewEntries(value) { + return evidenceList(value).map((review, index) => { + if (isRecord(review)) { + return review; + } + + return { + id: `malformed-review-entry-${index + 1}`, + artifactId: null, + mode: null, + reputationDelta: null, + __malformedReviewEntry: true + }; + }); +} + +function normalizeInlineCommentEntries(value) { + return evidenceList(value).map((comment, index) => { + if (isRecord(comment)) { + return comment; + } + + return { + id: `malformed-inline-comment-entry-${index + 1}`, + artifactId: null, + mode: null, + __malformedInlineCommentEntry: true + }; + }); +} + function findArtifact(project, artifactId) { - return evidenceList(project.artifacts).find((artifact) => artifact.id === artifactId); + return evidenceRecords(project.artifacts).find((artifact) => artifact.id === artifactId); } function evaluateReview(project, review) { + if (review.__malformedReviewEntry) { + return { + id: review.id, + artifactId: null, + mode: null, + reviewer: reviewerDisplay(review), + status: 'recertification-required', + reasons: ['malformed-review-entry'], + submittedAt: null, + recertifiedAt: null, + evidenceDigest: null, + currentArtifactDigest: null + }; + } + const artifact = findArtifact(project, review.artifactId); const reasons = []; const reviewTime = review.recertifiedAt || review.submittedAt; @@ -170,6 +224,18 @@ function taskForReview(review, decision) { } function evaluateComment(project, comment) { + if (comment.__malformedInlineCommentEntry) { + return { + id: comment.id, + artifactId: null, + status: 'recertification-required', + anchorStatus: 'missing', + reviewer: reviewerDisplay(comment), + reasons: ['malformed-inline-comment-entry'], + anchor: null + }; + } + const artifact = findArtifact(project, comment.artifactId); const reasons = []; let anchorStatus = 'current'; @@ -297,8 +363,8 @@ function buildTimelinePacket(project, reviewDecisions, commentDecisions) { } function evaluateRecertification(project) { - const reviews = evidenceList(project.reviews); - const inlineComments = evidenceList(project.inlineComments); + const reviews = normalizeReviewEntries(project.reviews); + const inlineComments = normalizeInlineCommentEntries(project.inlineComments); const reviewDecisions = reviews.map((review) => evaluateReview(project, review)); const reputationActions = reviews.map((review, index) => reputationActionForReview(review, reviewDecisions[index]) diff --git a/peer-review-evidence-recertification-guard/make-demo-video.py b/peer-review-evidence-recertification-guard/make-demo-video.py index 9a82ee02..b3dee9de 100644 --- a/peer-review-evidence-recertification-guard/make-demo-video.py +++ b/peer-review-evidence-recertification-guard/make-demo-video.py @@ -17,7 +17,7 @@ f"drawtext=fontfile='{font}':text='Freezes outdated reputation deltas until recertified':x=95:y=265:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='Redacts double-blind reviewer identities in task packets':x=95:y=325:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='Blocks malformed reputation deltas before profile credit':x=95:y=385:fontsize=30:fontcolor=0xd7edf9", - f"drawtext=fontfile='{font}':text='Outputs JSON, Markdown, SVG, and audit digest evidence':x=95:y=445:fontsize=30:fontcolor=0xd7edf9", + f"drawtext=fontfile='{font}':text='Holds malformed review/comment entries for recertification':x=95:y=445:fontsize=30:fontcolor=0xd7edf9", f"drawtext=fontfile='{font}':text='SCIBASE issue #15 community reputation slice':x=95:y=545:fontsize=28:fontcolor=0xffdf7e", ] ) diff --git a/peer-review-evidence-recertification-guard/reports/demo.mp4 b/peer-review-evidence-recertification-guard/reports/demo.mp4 index f07079fc23ba83475a4e38fa00ec81f14948ed03..3e7977e5835fb805f7bb74cd614a5a6c79a69192 100644 GIT binary patch delta 16790 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 diff --git a/peer-review-evidence-recertification-guard/reports/malformed-evidence-packet.json b/peer-review-evidence-recertification-guard/reports/malformed-evidence-packet.json new file mode 100644 index 00000000..76467c38 --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/malformed-evidence-packet.json @@ -0,0 +1,108 @@ +{ + "projectId": "project-malformed-review-comment-evidence", + "generatedAt": "2026-05-30T12:35:00Z", + "reviewDecisions": [ + { + "id": "malformed-review-entry-1", + "artifactId": null, + "mode": null, + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "malformed-review-entry" + ], + "submittedAt": null, + "recertifiedAt": null, + "evidenceDigest": null, + "currentArtifactDigest": null + } + ], + "commentDecisions": [ + { + "id": "malformed-inline-comment-entry-1", + "artifactId": null, + "status": "recertification-required", + "anchorStatus": "missing", + "reviewer": "reviewer:unverified", + "reasons": [ + "malformed-inline-comment-entry" + ], + "anchor": null + } + ], + "reputationActions": [ + { + "id": "malformed-review-entry-1", + "appliesTo": "reviewer:unverified", + "originalDelta": 0, + "reasonDigest": "sha256:895ecb33a444c76c0ca3ba5b51b154a7be574efaeb841e4ed8ab9e1c569ef4d4", + "action": "freeze-until-recertified", + "effectiveDelta": 0 + } + ], + "recertificationTasks": [ + { + "id": "recertify-malformed-review-entry-1", + "kind": "peer-review", + "reviewId": "malformed-review-entry-1", + "artifactId": null, + "reviewer": "reviewer:unverified", + "priority": "normal", + "requiredAction": "confirm-review-still-applies-to-current-artifact", + "blockedProfileUpdates": [ + "reputation-score", + "leaderboards", + "badges" + ], + "reasons": [ + "malformed-review-entry" + ] + }, + { + "id": "recertify-malformed-inline-comment-entry-1", + "kind": "inline-comment", + "commentId": "malformed-inline-comment-entry-1", + "artifactId": null, + "reviewer": "reviewer:unverified", + "priority": "normal", + "requiredAction": "confirm-comment-anchor-still-matches-current-artifact", + "reasons": [ + "malformed-inline-comment-entry" + ] + } + ], + "timelinePacket": { + "projectId": "project-malformed-review-comment-evidence", + "generatedAt": "2026-05-30T12:35:00Z", + "events": [ + { + "type": "review-recertification-required", + "reviewId": "malformed-review-entry-1", + "artifactId": null, + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "malformed-review-entry" + ] + }, + { + "type": "inline-comment-recertification-required", + "commentId": "malformed-inline-comment-entry-1", + "artifactId": null, + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "malformed-inline-comment-entry" + ] + } + ], + "auditDigest": "sha256:9836d23d5b1f9b0018a55ab03ac40c54a17263f806281d13b68957a2d7b60593" + }, + "summary": { + "totalReviews": 1, + "staleReviews": 1, + "staleComments": 1, + "frozenReputationDelta": 0, + "recommendedAction": "block-reputation-update" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index 596d3d8e..3dee621b 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -33,6 +33,10 @@ Sparse project payloads that omit review, comment, or artifact collections still Malformed review reputation deltas require recertification before profile credit is applied. The invalid-delta fixture recommends block-reputation-update, emits 1 stale review, and normalizes the frozen reputation delta to 0. +## Malformed Evidence Entry Packet + +Malformed review and inline-comment entries inside otherwise valid evidence arrays are converted into recertification holds instead of crashing or being silently ignored. The malformed-entry fixture recommends block-reputation-update, emits 1 stale review and 1 stale inline comment, and creates 2 recertification tasks. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index e426a909..bd33bc04 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -9,6 +9,7 @@ - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. - Public and semi-private review credit requires a concrete reviewer identity before reputation deltas are applied. +- Malformed review or inline-comment entries inside evidence arrays create recertification tasks instead of crashing evaluation or being silently ignored. - Review history is emitted in a project timeline packet. - Sparse project snapshots that omit review, comment, or artifact collections are evaluated as empty or missing evidence instead of runtime failures. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 632e42f0..7a99f4e1 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -738,6 +738,68 @@ function testMissingArtifactListRequiresRecertificationInsteadOfCrashing() { assert.equal(result.summary.recommendedAction, 'block-reputation-update'); } +function testMalformedReviewEntriesRequireRecertificationInsteadOfCrashing() { + const project = { + projectId: 'project-malformed-review-entry', + asOf: '2026-05-30T12:20:00Z', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ], + reviews: [null], + inlineComments: [] + }; + + const result = evaluateRecertification(project); + const review = byId(result.reviewDecisions, 'malformed-review-entry-1'); + const action = byId(result.reputationActions, 'malformed-review-entry-1'); + const task = byId(result.recertificationTasks, 'recertify-malformed-review-entry-1'); + + assert.equal(review.status, 'recertification-required'); + assert.deepEqual(review.reasons, ['malformed-review-entry']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['malformed-review-entry']); + assert.equal(result.summary.staleReviews, 1); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + +function testMalformedInlineCommentEntriesRequireRecertificationInsteadOfCrashing() { + const project = { + projectId: 'project-malformed-inline-comment-entry', + asOf: '2026-05-30T12:25:00Z', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ], + reviews: [], + inlineComments: [null] + }; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'malformed-inline-comment-entry-1'); + const task = byId(result.recertificationTasks, 'recertify-malformed-inline-comment-entry-1'); + + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'missing'); + assert.deepEqual(comment.reasons, ['malformed-inline-comment-entry']); + assert.equal(task.kind, 'inline-comment'); + assert.deepEqual(task.reasons, ['malformed-inline-comment-entry']); + assert.equal(result.summary.staleComments, 1); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + const tests = [ testStaleReviewsFreezeReputationUntilRecertified, testCurrentOrRecertifiedReviewsKeepReputationCredit, @@ -761,7 +823,9 @@ const tests = [ testInvalidArtifactTimestampRequiresCommentRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles, testMissingReviewAndCommentListsEvaluateAsEmptyEvidence, - testMissingArtifactListRequiresRecertificationInsteadOfCrashing + testMissingArtifactListRequiresRecertificationInsteadOfCrashing, + testMalformedReviewEntriesRequireRecertificationInsteadOfCrashing, + testMalformedInlineCommentEntriesRequireRecertificationInsteadOfCrashing ]; for (const test of tests) { From e6a9df426c225d8ad49d6b3ce356f27eaccee1a8 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sun, 31 May 2026 05:21:08 +0200 Subject: [PATCH 17/22] Harden malformed evidence collections --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 44 +++++++ .../index.js | 39 ++++++- .../reports/malformed-collection-packet.json | 108 ++++++++++++++++++ .../reports/recertification-report.md | 4 + .../requirements-map.md | 2 +- .../test.js | 85 ++++++++++++++ 8 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 peer-review-evidence-recertification-guard/reports/malformed-collection-packet.json diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 1729780c..701a590e 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed review and inline-comment evidence entries into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed review and inline-comment evidence entries or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run @@ -19,6 +19,7 @@ npm run check - `reports/empty-evidence-packet.json` - `reports/invalid-reputation-delta-packet.json` - `reports/malformed-evidence-packet.json` +- `reports/malformed-collection-packet.json` - `reports/recertification-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index f5dd81c5..92db399b 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -15,6 +15,7 @@ Validation targets: - public or semi-private reviews without reviewer identity are frozen for recertification instead of applying credit to an undefined profile - malformed reputation-delta evidence is frozen for recertification instead of applying non-numeric profile credit - malformed review and inline-comment entries inside otherwise valid evidence arrays create recertification holds instead of crashing or being silently ignored +- malformed non-array review and inline-comment collections create recertification holds instead of being treated like omitted evidence - stale inline comment anchors generate comment-specific recertification tasks - artifact digest changes mark inline comment anchors stale even when the selector line is unchanged - artifact updates after inline comments require recertification even when digest and selector evidence still match diff --git a/peer-review-evidence-recertification-guard/demo.js b/peer-review-evidence-recertification-guard/demo.js index e6d17a17..37669e36 100644 --- a/peer-review-evidence-recertification-guard/demo.js +++ b/peer-review-evidence-recertification-guard/demo.js @@ -17,11 +17,14 @@ const invalidReputationDeltaProject = buildInvalidReputationDeltaProject(); const invalidReputationDeltaResult = evaluateRecertification(invalidReputationDeltaProject); const malformedEvidenceProject = buildMalformedEvidenceProject(); const malformedEvidenceResult = evaluateRecertification(malformedEvidenceProject); +const malformedCollectionProject = buildMalformedCollectionProject(); +const malformedCollectionResult = evaluateRecertification(malformedCollectionProject); const packetPath = path.join(reportsDir, 'recertification-packet.json'); const emptyPacketPath = path.join(reportsDir, 'empty-evidence-packet.json'); const invalidDeltaPacketPath = path.join(reportsDir, 'invalid-reputation-delta-packet.json'); const malformedEvidencePacketPath = path.join(reportsDir, 'malformed-evidence-packet.json'); +const malformedCollectionPacketPath = path.join(reportsDir, 'malformed-collection-packet.json'); const reportPath = path.join(reportsDir, 'recertification-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -29,6 +32,7 @@ fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(emptyPacketPath, `${JSON.stringify(emptyEvidenceResult, null, 2)}\n`); fs.writeFileSync(invalidDeltaPacketPath, `${JSON.stringify(invalidReputationDeltaResult, null, 2)}\n`); fs.writeFileSync(malformedEvidencePacketPath, `${JSON.stringify(malformedEvidenceResult, null, 2)}\n`); +fs.writeFileSync(malformedCollectionPacketPath, `${JSON.stringify(malformedCollectionResult, null, 2)}\n`); const staleReviewList = result.reviewDecisions .filter((decision) => decision.status !== 'current') @@ -73,6 +77,10 @@ Malformed review reputation deltas require recertification before profile credit Malformed review and inline-comment entries inside otherwise valid evidence arrays are converted into recertification holds instead of crashing or being silently ignored. The malformed-entry fixture recommends ${malformedEvidenceResult.summary.recommendedAction}, emits ${malformedEvidenceResult.summary.staleReviews} stale review and ${malformedEvidenceResult.summary.staleComments} stale inline comment, and creates ${malformedEvidenceResult.recertificationTasks.length} recertification tasks. +## Malformed Evidence Collection Packet + +Malformed non-array review and inline-comment collections are converted into recertification holds instead of being treated like omitted evidence. The malformed-collection fixture recommends ${malformedCollectionResult.summary.recommendedAction}, emits ${malformedCollectionResult.summary.staleReviews} stale review and ${malformedCollectionResult.summary.staleComments} stale inline comment, and creates ${malformedCollectionResult.recertificationTasks.length} recertification tasks. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. @@ -100,6 +108,7 @@ console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, emptyPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidDeltaPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedEvidencePacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedCollectionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Recommended action: ${result.summary.recommendedAction}`); @@ -149,3 +158,38 @@ function buildMalformedEvidenceProject() { inlineComments: [null] }; } + +function buildMalformedCollectionProject() { + return { + projectId: 'project-malformed-review-comment-collections', + asOf: '2026-05-30T12:40:00Z', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ], + reviews: { + id: 'review-object-instead-of-array', + artifactId: 'analysis-code', + reviewerId: 'orcid:0000-0002-reviewer-c', + submittedAt: '2026-05-16T11:00:00Z', + evidenceDigest: 'sha256:code-v3', + reputationDelta: 14 + }, + inlineComments: { + id: 'comment-object-instead-of-array', + artifactId: 'analysis-code', + reviewerId: 'orcid:0000-0002-reviewer-b', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + }; +} diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 3c287b2b..10cd36e0 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -76,7 +76,22 @@ function evidenceRecords(value) { } function normalizeReviewEntries(value) { - return evidenceList(value).map((review, index) => { + const reviews = Array.isArray(value) + ? value + : value == null + ? [] + : [ + { + id: 'malformed-review-list', + artifactId: null, + mode: null, + reputationDelta: null, + malformedReason: 'malformed-review-list', + __malformedReviewEntry: true + } + ]; + + return reviews.map((review, index) => { if (isRecord(review)) { return review; } @@ -86,13 +101,28 @@ function normalizeReviewEntries(value) { artifactId: null, mode: null, reputationDelta: null, + malformedReason: 'malformed-review-entry', __malformedReviewEntry: true }; }); } function normalizeInlineCommentEntries(value) { - return evidenceList(value).map((comment, index) => { + const comments = Array.isArray(value) + ? value + : value == null + ? [] + : [ + { + id: 'malformed-inline-comment-list', + artifactId: null, + mode: null, + malformedReason: 'malformed-inline-comment-list', + __malformedInlineCommentEntry: true + } + ]; + + return comments.map((comment, index) => { if (isRecord(comment)) { return comment; } @@ -101,6 +131,7 @@ function normalizeInlineCommentEntries(value) { id: `malformed-inline-comment-entry-${index + 1}`, artifactId: null, mode: null, + malformedReason: 'malformed-inline-comment-entry', __malformedInlineCommentEntry: true }; }); @@ -118,7 +149,7 @@ function evaluateReview(project, review) { mode: null, reviewer: reviewerDisplay(review), status: 'recertification-required', - reasons: ['malformed-review-entry'], + reasons: [review.malformedReason || 'malformed-review-entry'], submittedAt: null, recertifiedAt: null, evidenceDigest: null, @@ -231,7 +262,7 @@ function evaluateComment(project, comment) { status: 'recertification-required', anchorStatus: 'missing', reviewer: reviewerDisplay(comment), - reasons: ['malformed-inline-comment-entry'], + reasons: [comment.malformedReason || 'malformed-inline-comment-entry'], anchor: null }; } diff --git a/peer-review-evidence-recertification-guard/reports/malformed-collection-packet.json b/peer-review-evidence-recertification-guard/reports/malformed-collection-packet.json new file mode 100644 index 00000000..901b9c94 --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/malformed-collection-packet.json @@ -0,0 +1,108 @@ +{ + "projectId": "project-malformed-review-comment-collections", + "generatedAt": "2026-05-30T12:40:00Z", + "reviewDecisions": [ + { + "id": "malformed-review-list", + "artifactId": null, + "mode": null, + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "malformed-review-list" + ], + "submittedAt": null, + "recertifiedAt": null, + "evidenceDigest": null, + "currentArtifactDigest": null + } + ], + "commentDecisions": [ + { + "id": "malformed-inline-comment-list", + "artifactId": null, + "status": "recertification-required", + "anchorStatus": "missing", + "reviewer": "reviewer:unverified", + "reasons": [ + "malformed-inline-comment-list" + ], + "anchor": null + } + ], + "reputationActions": [ + { + "id": "malformed-review-list", + "appliesTo": "reviewer:unverified", + "originalDelta": 0, + "reasonDigest": "sha256:8098ca1c3e969283b6c57e58b3ac75f38c35eb5685fe84ec3a2e1e2352a989e7", + "action": "freeze-until-recertified", + "effectiveDelta": 0 + } + ], + "recertificationTasks": [ + { + "id": "recertify-malformed-review-list", + "kind": "peer-review", + "reviewId": "malformed-review-list", + "artifactId": null, + "reviewer": "reviewer:unverified", + "priority": "normal", + "requiredAction": "confirm-review-still-applies-to-current-artifact", + "blockedProfileUpdates": [ + "reputation-score", + "leaderboards", + "badges" + ], + "reasons": [ + "malformed-review-list" + ] + }, + { + "id": "recertify-malformed-inline-comment-list", + "kind": "inline-comment", + "commentId": "malformed-inline-comment-list", + "artifactId": null, + "reviewer": "reviewer:unverified", + "priority": "normal", + "requiredAction": "confirm-comment-anchor-still-matches-current-artifact", + "reasons": [ + "malformed-inline-comment-list" + ] + } + ], + "timelinePacket": { + "projectId": "project-malformed-review-comment-collections", + "generatedAt": "2026-05-30T12:40:00Z", + "events": [ + { + "type": "review-recertification-required", + "reviewId": "malformed-review-list", + "artifactId": null, + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "malformed-review-list" + ] + }, + { + "type": "inline-comment-recertification-required", + "commentId": "malformed-inline-comment-list", + "artifactId": null, + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "malformed-inline-comment-list" + ] + } + ], + "auditDigest": "sha256:f28929f692213316212053173ed8b0afc2892ae5d7dca1d1062b9905587f4d5a" + }, + "summary": { + "totalReviews": 1, + "staleReviews": 1, + "staleComments": 1, + "frozenReputationDelta": 0, + "recommendedAction": "block-reputation-update" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index 3dee621b..dfbfb4df 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -37,6 +37,10 @@ Malformed review reputation deltas require recertification before profile credit Malformed review and inline-comment entries inside otherwise valid evidence arrays are converted into recertification holds instead of crashing or being silently ignored. The malformed-entry fixture recommends block-reputation-update, emits 1 stale review and 1 stale inline comment, and creates 2 recertification tasks. +## Malformed Evidence Collection Packet + +Malformed non-array review and inline-comment collections are converted into recertification holds instead of being treated like omitted evidence. The malformed-collection fixture recommends block-reputation-update, emits 1 stale review and 1 stale inline comment, and creates 2 recertification tasks. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index bd33bc04..e8c4b34b 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -9,7 +9,7 @@ - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. - Public and semi-private review credit requires a concrete reviewer identity before reputation deltas are applied. -- Malformed review or inline-comment entries inside evidence arrays create recertification tasks instead of crashing evaluation or being silently ignored. +- Malformed review or inline-comment entries inside evidence arrays, and malformed non-array evidence collections, create recertification tasks instead of crashing evaluation or being silently ignored. - Review history is emitted in a project timeline packet. - Sparse project snapshots that omit review, comment, or artifact collections are evaluated as empty or missing evidence instead of runtime failures. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 7a99f4e1..ec9042f7 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -705,6 +705,89 @@ function testMissingReviewAndCommentListsEvaluateAsEmptyEvidence() { assert.equal(result.timelinePacket.events.length, 0); } +function testMalformedReviewListRequiresRecertificationInsteadOfAllowingUpdate() { + const project = { + projectId: 'project-malformed-review-list', + asOf: '2026-05-30T12:05:00Z', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ], + reviews: { + id: 'review-object-instead-of-array', + artifactId: 'analysis-code', + reviewerId: 'orcid:0000-0002-reviewer-c', + submittedAt: '2026-05-16T11:00:00Z', + evidenceDigest: 'sha256:code-v3', + reputationDelta: 14 + }, + inlineComments: [] + }; + + const result = evaluateRecertification(project); + const review = byId(result.reviewDecisions, 'malformed-review-list'); + const task = byId(result.recertificationTasks, 'recertify-malformed-review-list'); + const action = byId(result.reputationActions, 'malformed-review-list'); + + assert.ok(review, 'expected malformed review collection to create a review decision'); + assert.ok(task, 'expected malformed review collection to create a recertification task'); + assert.ok(action, 'expected malformed review collection to freeze reputation updates'); + assert.equal(review.status, 'recertification-required'); + assert.deepEqual(review.reasons, ['malformed-review-list']); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['malformed-review-list']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(result.summary.staleReviews, 1); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + +function testMalformedInlineCommentListRequiresRecertificationInsteadOfAllowingUpdate() { + const project = { + projectId: 'project-malformed-inline-comment-list', + asOf: '2026-05-30T12:08:00Z', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ], + reviews: [], + inlineComments: { + id: 'comment-object-instead-of-array', + artifactId: 'analysis-code', + reviewerId: 'orcid:0000-0002-reviewer-b', + anchorDigest: 'sha256:code-v3', + anchor: { + selector: 'src/analyze.py#L41', + line: 41 + }, + submittedAt: '2026-05-18T14:30:00Z' + } + }; + + const result = evaluateRecertification(project); + const comment = byId(result.commentDecisions, 'malformed-inline-comment-list'); + const task = byId(result.recertificationTasks, 'recertify-malformed-inline-comment-list'); + + assert.ok(comment, 'expected malformed inline comment collection to create a comment decision'); + assert.ok(task, 'expected malformed inline comment collection to create a recertification task'); + assert.equal(comment.status, 'recertification-required'); + assert.equal(comment.anchorStatus, 'missing'); + assert.deepEqual(comment.reasons, ['malformed-inline-comment-list']); + assert.equal(task.kind, 'inline-comment'); + assert.deepEqual(task.reasons, ['malformed-inline-comment-list']); + assert.equal(result.summary.staleComments, 1); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + function testMissingArtifactListRequiresRecertificationInsteadOfCrashing() { const project = { projectId: 'project-missing-artifact-list', @@ -823,6 +906,8 @@ const tests = [ testInvalidArtifactTimestampRequiresCommentRecertification, testTimelinePacketPreservesAuditEvidenceWithoutRawPrivateProfiles, testMissingReviewAndCommentListsEvaluateAsEmptyEvidence, + testMalformedReviewListRequiresRecertificationInsteadOfAllowingUpdate, + testMalformedInlineCommentListRequiresRecertificationInsteadOfAllowingUpdate, testMissingArtifactListRequiresRecertificationInsteadOfCrashing, testMalformedReviewEntriesRequireRecertificationInsteadOfCrashing, testMalformedInlineCommentEntriesRequireRecertificationInsteadOfCrashing From c4d29d3a0385269b3ec898c788a3f47dcb9f3abd Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Mon, 1 Jun 2026 08:52:47 +0200 Subject: [PATCH 18/22] Harden backdated peer review recertification --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 38 ++++++++++ .../index.js | 6 ++ .../backdated-recertification-packet.json | 74 +++++++++++++++++++ .../reports/recertification-report.md | 4 + .../requirements-map.md | 2 +- .../test.js | 39 ++++++++++ 8 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 peer-review-evidence-recertification-guard/reports/backdated-recertification-packet.json diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 701a590e..cb6f7695 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed review and inline-comment evidence entries or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, blocks recertification timestamps that predate the original review submission, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed review and inline-comment evidence entries or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run @@ -20,6 +20,7 @@ npm run check - `reports/invalid-reputation-delta-packet.json` - `reports/malformed-evidence-packet.json` - `reports/malformed-collection-packet.json` +- `reports/backdated-recertification-packet.json` - `reports/recertification-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 92db399b..1a2ecfac 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -14,6 +14,7 @@ Validation targets: - space-separated blind and fully anonymous mode labels do not leak raw reviewer IDs - public or semi-private reviews without reviewer identity are frozen for recertification instead of applying credit to an undefined profile - malformed reputation-delta evidence is frozen for recertification instead of applying non-numeric profile credit +- recertification timestamps that predate the original review submission are frozen before profile credit is applied - malformed review and inline-comment entries inside otherwise valid evidence arrays create recertification holds instead of crashing or being silently ignored - malformed non-array review and inline-comment collections create recertification holds instead of being treated like omitted evidence - stale inline comment anchors generate comment-specific recertification tasks diff --git a/peer-review-evidence-recertification-guard/demo.js b/peer-review-evidence-recertification-guard/demo.js index 37669e36..0d08a466 100644 --- a/peer-review-evidence-recertification-guard/demo.js +++ b/peer-review-evidence-recertification-guard/demo.js @@ -19,12 +19,15 @@ const malformedEvidenceProject = buildMalformedEvidenceProject(); const malformedEvidenceResult = evaluateRecertification(malformedEvidenceProject); const malformedCollectionProject = buildMalformedCollectionProject(); const malformedCollectionResult = evaluateRecertification(malformedCollectionProject); +const backdatedRecertificationProject = buildBackdatedRecertificationProject(); +const backdatedRecertificationResult = evaluateRecertification(backdatedRecertificationProject); const packetPath = path.join(reportsDir, 'recertification-packet.json'); const emptyPacketPath = path.join(reportsDir, 'empty-evidence-packet.json'); const invalidDeltaPacketPath = path.join(reportsDir, 'invalid-reputation-delta-packet.json'); const malformedEvidencePacketPath = path.join(reportsDir, 'malformed-evidence-packet.json'); const malformedCollectionPacketPath = path.join(reportsDir, 'malformed-collection-packet.json'); +const backdatedRecertificationPacketPath = path.join(reportsDir, 'backdated-recertification-packet.json'); const reportPath = path.join(reportsDir, 'recertification-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -33,6 +36,7 @@ fs.writeFileSync(emptyPacketPath, `${JSON.stringify(emptyEvidenceResult, null, 2 fs.writeFileSync(invalidDeltaPacketPath, `${JSON.stringify(invalidReputationDeltaResult, null, 2)}\n`); fs.writeFileSync(malformedEvidencePacketPath, `${JSON.stringify(malformedEvidenceResult, null, 2)}\n`); fs.writeFileSync(malformedCollectionPacketPath, `${JSON.stringify(malformedCollectionResult, null, 2)}\n`); +fs.writeFileSync(backdatedRecertificationPacketPath, `${JSON.stringify(backdatedRecertificationResult, null, 2)}\n`); const staleReviewList = result.reviewDecisions .filter((decision) => decision.status !== 'current') @@ -81,6 +85,10 @@ Malformed review and inline-comment entries inside otherwise valid evidence arra Malformed non-array review and inline-comment collections are converted into recertification holds instead of being treated like omitted evidence. The malformed-collection fixture recommends ${malformedCollectionResult.summary.recommendedAction}, emits ${malformedCollectionResult.summary.staleReviews} stale review and ${malformedCollectionResult.summary.staleComments} stale inline comment, and creates ${malformedCollectionResult.recertificationTasks.length} recertification tasks. +## Backdated Recertification Packet + +Recertification timestamps that predate the original review submission are blocked as impossible audit chronology. The backdated-recertification fixture recommends ${backdatedRecertificationResult.summary.recommendedAction}, emits ${backdatedRecertificationResult.summary.staleReviews} stale review, and records ${backdatedRecertificationResult.reviewDecisions[0].reasons.join(', ')} before profile credit is applied. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. @@ -109,6 +117,7 @@ console.log(`Wrote ${path.relative(__dirname, emptyPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidDeltaPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedEvidencePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedCollectionPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, backdatedRecertificationPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Recommended action: ${result.summary.recommendedAction}`); @@ -193,3 +202,32 @@ function buildMalformedCollectionProject() { } }; } + +function buildBackdatedRecertificationProject() { + return { + projectId: 'project-backdated-recertification', + asOf: '2026-05-30T12:45:00Z', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-14T09:30:00Z', + currentAnchors: {} + } + ], + reviews: [ + { + id: 'review-backdated-recertification', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + recertifiedAt: '2026-05-15T11:00:00Z', + reputationDelta: 14 + } + ], + inlineComments: [] + }; +} diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 10cd36e0..9325ab74 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -161,12 +161,18 @@ function evaluateReview(project, review) { const reasons = []; const reviewTime = review.recertifiedAt || review.submittedAt; const reviewTimeIsValid = hasValidTime(reviewTime); + const submittedAtIsValid = hasValidTime(review.submittedAt); + const recertifiedAtIsValid = hasValidTime(review.recertifiedAt); const reviewedAt = reviewTimeIsValid ? isoTime(reviewTime) : null; if (!reviewTimeIsValid) { reasons.push('invalid-review-timestamp'); } + if (recertifiedAtIsValid && submittedAtIsValid && isoTime(review.recertifiedAt) < isoTime(review.submittedAt)) { + reasons.push('recertification-before-submission'); + } + if (!isBlindOrAnonymous(review.mode) && !hasText(review.reviewerId)) { reasons.push('reviewer-identity-missing'); } diff --git a/peer-review-evidence-recertification-guard/reports/backdated-recertification-packet.json b/peer-review-evidence-recertification-guard/reports/backdated-recertification-packet.json new file mode 100644 index 00000000..f62b0cf4 --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/backdated-recertification-packet.json @@ -0,0 +1,74 @@ +{ + "projectId": "project-backdated-recertification", + "generatedAt": "2026-05-30T12:45:00Z", + "reviewDecisions": [ + { + "id": "review-backdated-recertification", + "artifactId": "analysis-code", + "mode": "public", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "status": "recertification-required", + "reasons": [ + "recertification-before-submission" + ], + "submittedAt": "2026-05-16T11:00:00Z", + "recertifiedAt": "2026-05-15T11:00:00Z", + "evidenceDigest": "sha256:code-v3", + "currentArtifactDigest": "sha256:code-v3" + } + ], + "commentDecisions": [], + "reputationActions": [ + { + "id": "review-backdated-recertification", + "appliesTo": "reviewer:orcid:0000-0002-reviewer-c", + "originalDelta": 14, + "reasonDigest": "sha256:c1a5ff09268111ff8e15c87ba929737f1596a1f0d91f03fb306113fbdbb6db2b", + "action": "freeze-until-recertified", + "effectiveDelta": 0 + } + ], + "recertificationTasks": [ + { + "id": "recertify-review-backdated-recertification", + "kind": "peer-review", + "reviewId": "review-backdated-recertification", + "artifactId": "analysis-code", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "priority": "normal", + "requiredAction": "confirm-review-still-applies-to-current-artifact", + "blockedProfileUpdates": [ + "reputation-score", + "leaderboards", + "badges" + ], + "reasons": [ + "recertification-before-submission" + ] + } + ], + "timelinePacket": { + "projectId": "project-backdated-recertification", + "generatedAt": "2026-05-30T12:45:00Z", + "events": [ + { + "type": "review-recertification-required", + "reviewId": "review-backdated-recertification", + "artifactId": "analysis-code", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "status": "recertification-required", + "reasons": [ + "recertification-before-submission" + ] + } + ], + "auditDigest": "sha256:de72a608485f8c7a808f5088e21fae32ba50a9cffb49bc66b399b27e49b3fbde" + }, + "summary": { + "totalReviews": 1, + "staleReviews": 1, + "staleComments": 0, + "frozenReputationDelta": 14, + "recommendedAction": "block-reputation-update" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index dfbfb4df..af123c91 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -41,6 +41,10 @@ Malformed review and inline-comment entries inside otherwise valid evidence arra Malformed non-array review and inline-comment collections are converted into recertification holds instead of being treated like omitted evidence. The malformed-collection fixture recommends block-reputation-update, emits 1 stale review and 1 stale inline comment, and creates 2 recertification tasks. +## Backdated Recertification Packet + +Recertification timestamps that predate the original review submission are blocked as impossible audit chronology. The backdated-recertification fixture recommends block-reputation-update, emits 1 stale review, and records recertification-before-submission before profile credit is applied. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index e8c4b34b..9742533f 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -3,7 +3,7 @@ ## Peer Reviews & Comments - Structured peer-review evidence is tied to reviewed artifact digests. -- Missing or malformed review submission or recertification timestamps require recertification before review credit is applied. +- Missing, malformed, or backdated review submission and recertification timestamps require recertification before review credit is applied. - Missing or malformed artifact change timestamps require review and inline-comment recertification before review credit or comment evidence is applied. - Inline comments track artifact anchors and require recertification when anchors shift, artifact digests change, artifact timestamps postdate the comment, artifact timing evidence is missing or malformed, anchor metadata is missing, artifact anchor maps are missing, or comment timing evidence is missing or malformed. - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index ec9042f7..652407a5 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -481,6 +481,44 @@ function testMissingReviewTimestampRequiresRecertification() { assert.equal(action.effectiveDelta, 0); } +function testReviewRecertificationBeforeSubmissionRequiresRecertification() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-14T09:30:00Z', + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-backdated-recertification', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + recertifiedAt: '2026-05-15T11:00:00Z', + reputationDelta: 14 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const decision = byId(result.reviewDecisions, 'review-backdated-recertification'); + const task = byId(result.recertificationTasks, 'recertify-review-backdated-recertification'); + const action = byId(result.reputationActions, 'review-backdated-recertification'); + + assert.equal(decision.status, 'recertification-required'); + assert.deepEqual(decision.reasons, ['recertification-before-submission']); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['recertification-before-submission']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); +} + function testPublicReviewWithoutReviewerIdentityFreezesReputation() { const project = buildSampleProject(); project.artifacts = [ @@ -899,6 +937,7 @@ const tests = [ testStaleInlineCommentsBlockReputationUpdateWithoutStaleReviews, testInvalidReviewTimestampRequiresRecertification, testMissingReviewTimestampRequiresRecertification, + testReviewRecertificationBeforeSubmissionRequiresRecertification, testPublicReviewWithoutReviewerIdentityFreezesReputation, testInvalidReputationDeltaRequiresRecertification, testInvalidArtifactTimestampRequiresReviewRecertification, From 5b5c2cdb592fbe38a2ea3c4fbaf61fa68850d04d Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Tue, 2 Jun 2026 09:07:06 +0200 Subject: [PATCH 19/22] Harden malformed recertification packets --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 8 ++ .../index.js | 38 ++++++++-- .../reports/malformed-project-packet.json | 74 +++++++++++++++++++ .../reports/recertification-report.md | 4 + .../requirements-map.md | 1 + .../test.js | 21 +++++- 8 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 peer-review-evidence-recertification-guard/reports/malformed-project-packet.json diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index cb6f7695..4c4e9389 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, blocks recertification timestamps that predate the original review submission, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed review and inline-comment evidence entries or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, blocks recertification timestamps that predate the original review submission, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed top-level recertification packets, review and inline-comment evidence entries, or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run @@ -21,6 +21,7 @@ npm run check - `reports/malformed-evidence-packet.json` - `reports/malformed-collection-packet.json` - `reports/backdated-recertification-packet.json` +- `reports/malformed-project-packet.json` - `reports/recertification-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 1a2ecfac..dda534c0 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -15,6 +15,7 @@ Validation targets: - public or semi-private reviews without reviewer identity are frozen for recertification instead of applying credit to an undefined profile - malformed reputation-delta evidence is frozen for recertification instead of applying non-numeric profile credit - recertification timestamps that predate the original review submission are frozen before profile credit is applied +- malformed top-level recertification packets create reviewer-visible holds instead of crashing before timeline evidence is generated - malformed review and inline-comment entries inside otherwise valid evidence arrays create recertification holds instead of crashing or being silently ignored - malformed non-array review and inline-comment collections create recertification holds instead of being treated like omitted evidence - stale inline comment anchors generate comment-specific recertification tasks diff --git a/peer-review-evidence-recertification-guard/demo.js b/peer-review-evidence-recertification-guard/demo.js index 0d08a466..ddcc65f6 100644 --- a/peer-review-evidence-recertification-guard/demo.js +++ b/peer-review-evidence-recertification-guard/demo.js @@ -21,6 +21,7 @@ const malformedCollectionProject = buildMalformedCollectionProject(); const malformedCollectionResult = evaluateRecertification(malformedCollectionProject); const backdatedRecertificationProject = buildBackdatedRecertificationProject(); const backdatedRecertificationResult = evaluateRecertification(backdatedRecertificationProject); +const malformedProjectResult = evaluateRecertification(null); const packetPath = path.join(reportsDir, 'recertification-packet.json'); const emptyPacketPath = path.join(reportsDir, 'empty-evidence-packet.json'); @@ -28,6 +29,7 @@ const invalidDeltaPacketPath = path.join(reportsDir, 'invalid-reputation-delta-p const malformedEvidencePacketPath = path.join(reportsDir, 'malformed-evidence-packet.json'); const malformedCollectionPacketPath = path.join(reportsDir, 'malformed-collection-packet.json'); const backdatedRecertificationPacketPath = path.join(reportsDir, 'backdated-recertification-packet.json'); +const malformedProjectPacketPath = path.join(reportsDir, 'malformed-project-packet.json'); const reportPath = path.join(reportsDir, 'recertification-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -37,6 +39,7 @@ fs.writeFileSync(invalidDeltaPacketPath, `${JSON.stringify(invalidReputationDelt fs.writeFileSync(malformedEvidencePacketPath, `${JSON.stringify(malformedEvidenceResult, null, 2)}\n`); fs.writeFileSync(malformedCollectionPacketPath, `${JSON.stringify(malformedCollectionResult, null, 2)}\n`); fs.writeFileSync(backdatedRecertificationPacketPath, `${JSON.stringify(backdatedRecertificationResult, null, 2)}\n`); +fs.writeFileSync(malformedProjectPacketPath, `${JSON.stringify(malformedProjectResult, null, 2)}\n`); const staleReviewList = result.reviewDecisions .filter((decision) => decision.status !== 'current') @@ -89,6 +92,10 @@ Malformed non-array review and inline-comment collections are converted into rec Recertification timestamps that predate the original review submission are blocked as impossible audit chronology. The backdated-recertification fixture recommends ${backdatedRecertificationResult.summary.recommendedAction}, emits ${backdatedRecertificationResult.summary.staleReviews} stale review, and records ${backdatedRecertificationResult.reviewDecisions[0].reasons.join(', ')} before profile credit is applied. +## Malformed Project Packet + +Malformed top-level recertification packets are converted into reviewer-visible recertification holds instead of crashing before timeline evidence is generated. The malformed-project fixture recommends ${malformedProjectResult.summary.recommendedAction}, emits ${malformedProjectResult.summary.staleReviews} stale review hold, and records ${malformedProjectResult.reviewDecisions[0].reasons.join(', ')} for unidentified-project. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. @@ -118,6 +125,7 @@ console.log(`Wrote ${path.relative(__dirname, invalidDeltaPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedEvidencePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedCollectionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, backdatedRecertificationPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedProjectPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Recommended action: ${result.summary.recommendedAction}`); diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 9325ab74..54d3dbcf 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -137,6 +137,29 @@ function normalizeInlineCommentEntries(value) { }); } +function normalizeProject(project) { + if (isRecord(project)) { + return project; + } + + return { + projectId: 'unidentified-project', + asOf: null, + artifacts: [], + reviews: [ + { + id: 'malformed-project-evidence', + artifactId: null, + mode: null, + reputationDelta: null, + malformedReason: 'malformed-project-evidence', + __malformedReviewEntry: true + } + ], + inlineComments: [] + }; +} + function findArtifact(project, artifactId) { return evidenceRecords(project.artifacts).find((artifact) => artifact.id === artifactId); } @@ -400,24 +423,25 @@ function buildTimelinePacket(project, reviewDecisions, commentDecisions) { } function evaluateRecertification(project) { - const reviews = normalizeReviewEntries(project.reviews); - const inlineComments = normalizeInlineCommentEntries(project.inlineComments); - const reviewDecisions = reviews.map((review) => evaluateReview(project, review)); + const normalizedProject = normalizeProject(project); + const reviews = normalizeReviewEntries(normalizedProject.reviews); + const inlineComments = normalizeInlineCommentEntries(normalizedProject.inlineComments); + const reviewDecisions = reviews.map((review) => evaluateReview(normalizedProject, review)); const reputationActions = reviews.map((review, index) => reputationActionForReview(review, reviewDecisions[index]) ); - const commentDecisions = inlineComments.map((comment) => evaluateComment(project, comment)); + const commentDecisions = inlineComments.map((comment) => evaluateComment(normalizedProject, comment)); const recertificationTasks = [ ...reviews.map((review, index) => taskForReview(review, reviewDecisions[index])), ...inlineComments.map((comment, index) => taskForComment(comment, commentDecisions[index])) ].filter(Boolean); const staleReviews = reviewDecisions.filter((decision) => decision.status !== 'current').length; const staleComments = commentDecisions.filter((decision) => decision.status !== 'current').length; - const timelinePacket = buildTimelinePacket(project, reviewDecisions, commentDecisions); + const timelinePacket = buildTimelinePacket(normalizedProject, reviewDecisions, commentDecisions); return { - projectId: project.projectId, - generatedAt: project.asOf, + projectId: normalizedProject.projectId, + generatedAt: normalizedProject.asOf, reviewDecisions, commentDecisions, reputationActions, diff --git a/peer-review-evidence-recertification-guard/reports/malformed-project-packet.json b/peer-review-evidence-recertification-guard/reports/malformed-project-packet.json new file mode 100644 index 00000000..7b1f6b60 --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/malformed-project-packet.json @@ -0,0 +1,74 @@ +{ + "projectId": "unidentified-project", + "generatedAt": null, + "reviewDecisions": [ + { + "id": "malformed-project-evidence", + "artifactId": null, + "mode": null, + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "malformed-project-evidence" + ], + "submittedAt": null, + "recertifiedAt": null, + "evidenceDigest": null, + "currentArtifactDigest": null + } + ], + "commentDecisions": [], + "reputationActions": [ + { + "id": "malformed-project-evidence", + "appliesTo": "reviewer:unverified", + "originalDelta": 0, + "reasonDigest": "sha256:d9400edbca854122715c4c474a9bcf681f68fd16de7071ccbff7ef9f721b353a", + "action": "freeze-until-recertified", + "effectiveDelta": 0 + } + ], + "recertificationTasks": [ + { + "id": "recertify-malformed-project-evidence", + "kind": "peer-review", + "reviewId": "malformed-project-evidence", + "artifactId": null, + "reviewer": "reviewer:unverified", + "priority": "normal", + "requiredAction": "confirm-review-still-applies-to-current-artifact", + "blockedProfileUpdates": [ + "reputation-score", + "leaderboards", + "badges" + ], + "reasons": [ + "malformed-project-evidence" + ] + } + ], + "timelinePacket": { + "projectId": "unidentified-project", + "generatedAt": null, + "events": [ + { + "type": "review-recertification-required", + "reviewId": "malformed-project-evidence", + "artifactId": null, + "reviewer": "reviewer:unverified", + "status": "recertification-required", + "reasons": [ + "malformed-project-evidence" + ] + } + ], + "auditDigest": "sha256:0178d93df04b1943332209616d325b4b994d5a1240aa53304ff46f0b9ce7ad6d" + }, + "summary": { + "totalReviews": 1, + "staleReviews": 1, + "staleComments": 0, + "frozenReputationDelta": 0, + "recommendedAction": "block-reputation-update" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index af123c91..5f389834 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -45,6 +45,10 @@ Malformed non-array review and inline-comment collections are converted into rec Recertification timestamps that predate the original review submission are blocked as impossible audit chronology. The backdated-recertification fixture recommends block-reputation-update, emits 1 stale review, and records recertification-before-submission before profile credit is applied. +## Malformed Project Packet + +Malformed top-level recertification packets are converted into reviewer-visible recertification holds instead of crashing before timeline evidence is generated. The malformed-project fixture recommends block-reputation-update, emits 1 stale review hold, and records malformed-project-evidence for unidentified-project. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index 9742533f..68467411 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -9,6 +9,7 @@ - Stale review or inline-comment evidence blocks reputation updates until recertification is complete. - Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. - Public and semi-private review credit requires a concrete reviewer identity before reputation deltas are applied. +- Malformed top-level recertification packets create blocked reviewer evidence for an unidentified project instead of crashing before timeline packets are generated. - Malformed review or inline-comment entries inside evidence arrays, and malformed non-array evidence collections, create recertification tasks instead of crashing evaluation or being silently ignored. - Review history is emitted in a project timeline packet. - Sparse project snapshots that omit review, comment, or artifact collections are evaluated as empty or missing evidence instead of runtime failures. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 652407a5..51d5f357 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -921,6 +921,24 @@ function testMalformedInlineCommentEntriesRequireRecertificationInsteadOfCrashin assert.equal(result.summary.recommendedAction, 'block-reputation-update'); } +function testMalformedProjectPacketRequiresRecertificationInsteadOfCrashing() { + const result = evaluateRecertification(null); + const review = byId(result.reviewDecisions, 'malformed-project-evidence'); + const task = byId(result.recertificationTasks, 'recertify-malformed-project-evidence'); + const action = byId(result.reputationActions, 'malformed-project-evidence'); + + assert.equal(result.projectId, 'unidentified-project'); + assert.equal(result.generatedAt, null); + assert.equal(review.status, 'recertification-required'); + assert.deepEqual(review.reasons, ['malformed-project-evidence']); + assert.equal(task.kind, 'peer-review'); + assert.deepEqual(task.reasons, ['malformed-project-evidence']); + assert.equal(action.action, 'freeze-until-recertified'); + assert.equal(action.effectiveDelta, 0); + assert.equal(result.summary.staleReviews, 1); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + const tests = [ testStaleReviewsFreezeReputationUntilRecertified, testCurrentOrRecertifiedReviewsKeepReputationCredit, @@ -949,7 +967,8 @@ const tests = [ testMalformedInlineCommentListRequiresRecertificationInsteadOfAllowingUpdate, testMissingArtifactListRequiresRecertificationInsteadOfCrashing, testMalformedReviewEntriesRequireRecertificationInsteadOfCrashing, - testMalformedInlineCommentEntriesRequireRecertificationInsteadOfCrashing + testMalformedInlineCommentEntriesRequireRecertificationInsteadOfCrashing, + testMalformedProjectPacketRequiresRecertificationInsteadOfCrashing ]; for (const test of tests) { From bb47f4733ac56b8947d91b90bb7357ebb2e2041b Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Wed, 3 Jun 2026 17:36:32 +0200 Subject: [PATCH 20/22] Harden recertification project timestamps --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 37 ++++++++++ .../index.js | 45 ++++++++++-- .../backdated-recertification-packet.json | 1 + .../reports/empty-evidence-packet.json | 1 + .../invalid-project-timestamp-packet.json | 72 +++++++++++++++++++ .../invalid-reputation-delta-packet.json | 1 + .../reports/malformed-collection-packet.json | 1 + .../reports/malformed-evidence-packet.json | 1 + .../reports/malformed-project-packet.json | 1 + .../reports/recertification-packet.json | 1 + .../reports/recertification-report.md | 4 ++ .../requirements-map.md | 2 + .../test.js | 38 +++++++++- 15 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 peer-review-evidence-recertification-guard/reports/invalid-project-timestamp-packet.json diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 4c4e9389..7c6c9ca1 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, and inline-comment timestamps for review and comment recertification, blocks recertification timestamps that predate the original review submission, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed top-level recertification packets, review and inline-comment evidence entries, or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, inline-comment, and project snapshot timestamps for recertification, blocks recertification timestamps that predate the original review submission, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed top-level recertification packets, review and inline-comment evidence entries, or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. ## Run @@ -22,6 +22,7 @@ npm run check - `reports/malformed-collection-packet.json` - `reports/backdated-recertification-packet.json` - `reports/malformed-project-packet.json` +- `reports/invalid-project-timestamp-packet.json` - `reports/recertification-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index dda534c0..76b1e1b8 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -16,6 +16,7 @@ Validation targets: - malformed reputation-delta evidence is frozen for recertification instead of applying non-numeric profile credit - recertification timestamps that predate the original review submission are frozen before profile credit is applied - malformed top-level recertification packets create reviewer-visible holds instead of crashing before timeline evidence is generated +- malformed project snapshot timestamps create project-evidence recertification tasks and null audit generatedAt evidence before reputation updates are allowed - malformed review and inline-comment entries inside otherwise valid evidence arrays create recertification holds instead of crashing or being silently ignored - malformed non-array review and inline-comment collections create recertification holds instead of being treated like omitted evidence - stale inline comment anchors generate comment-specific recertification tasks diff --git a/peer-review-evidence-recertification-guard/demo.js b/peer-review-evidence-recertification-guard/demo.js index ddcc65f6..71c92425 100644 --- a/peer-review-evidence-recertification-guard/demo.js +++ b/peer-review-evidence-recertification-guard/demo.js @@ -22,6 +22,8 @@ const malformedCollectionResult = evaluateRecertification(malformedCollectionPro const backdatedRecertificationProject = buildBackdatedRecertificationProject(); const backdatedRecertificationResult = evaluateRecertification(backdatedRecertificationProject); const malformedProjectResult = evaluateRecertification(null); +const invalidProjectTimestampProject = buildInvalidProjectTimestampProject(); +const invalidProjectTimestampResult = evaluateRecertification(invalidProjectTimestampProject); const packetPath = path.join(reportsDir, 'recertification-packet.json'); const emptyPacketPath = path.join(reportsDir, 'empty-evidence-packet.json'); @@ -30,6 +32,7 @@ const malformedEvidencePacketPath = path.join(reportsDir, 'malformed-evidence-pa const malformedCollectionPacketPath = path.join(reportsDir, 'malformed-collection-packet.json'); const backdatedRecertificationPacketPath = path.join(reportsDir, 'backdated-recertification-packet.json'); const malformedProjectPacketPath = path.join(reportsDir, 'malformed-project-packet.json'); +const invalidProjectTimestampPacketPath = path.join(reportsDir, 'invalid-project-timestamp-packet.json'); const reportPath = path.join(reportsDir, 'recertification-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -40,6 +43,7 @@ fs.writeFileSync(malformedEvidencePacketPath, `${JSON.stringify(malformedEvidenc fs.writeFileSync(malformedCollectionPacketPath, `${JSON.stringify(malformedCollectionResult, null, 2)}\n`); fs.writeFileSync(backdatedRecertificationPacketPath, `${JSON.stringify(backdatedRecertificationResult, null, 2)}\n`); fs.writeFileSync(malformedProjectPacketPath, `${JSON.stringify(malformedProjectResult, null, 2)}\n`); +fs.writeFileSync(invalidProjectTimestampPacketPath, `${JSON.stringify(invalidProjectTimestampResult, null, 2)}\n`); const staleReviewList = result.reviewDecisions .filter((decision) => decision.status !== 'current') @@ -96,6 +100,10 @@ Recertification timestamps that predate the original review submission are block Malformed top-level recertification packets are converted into reviewer-visible recertification holds instead of crashing before timeline evidence is generated. The malformed-project fixture recommends ${malformedProjectResult.summary.recommendedAction}, emits ${malformedProjectResult.summary.staleReviews} stale review hold, and records ${malformedProjectResult.reviewDecisions[0].reasons.join(', ')} for unidentified-project. +## Invalid Project Timestamp Packet + +Project snapshot timestamps must be valid before reputation updates can be applied. The invalid-project-timestamp fixture recommends ${invalidProjectTimestampResult.summary.recommendedAction}, emits ${invalidProjectTimestampResult.summary.staleProjectEvidence} project evidence hold, creates ${invalidProjectTimestampResult.recertificationTasks.length} recertification task, and records ${invalidProjectTimestampResult.timelinePacket.generatedAt === null ? 'null generatedAt' : invalidProjectTimestampResult.timelinePacket.generatedAt} in the audit timeline packet. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. @@ -126,6 +134,7 @@ console.log(`Wrote ${path.relative(__dirname, malformedEvidencePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedCollectionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, backdatedRecertificationPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedProjectPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, invalidProjectTimestampPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Recommended action: ${result.summary.recommendedAction}`); @@ -239,3 +248,31 @@ function buildBackdatedRecertificationProject() { inlineComments: [] }; } + +function buildInvalidProjectTimestampProject() { + return { + projectId: 'project-invalid-project-timestamp', + asOf: 'not-a-date', + artifacts: [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ], + reviews: [ + { + id: 'review-current-with-invalid-project-time', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + reputationDelta: 14 + } + ], + inlineComments: [] + }; +} diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 54d3dbcf..46117fac 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -387,7 +387,30 @@ function taskForComment(comment, decision) { }; } -function buildTimelinePacket(project, reviewDecisions, commentDecisions) { +function taskForProjectTimestamp(project, projectIssues) { + if (projectIssues.length === 0) { + return null; + } + + return { + id: 'recertify-project-timestamp', + kind: 'project-evidence', + projectId: project.projectId, + priority: 'high', + requiredAction: 'repair-project-timestamp-before-reputation-update', + reasons: projectIssues + }; +} + +function buildTimelinePacket(project, reviewDecisions, commentDecisions, projectIssues) { + const generatedAt = hasValidTime(project.asOf) ? project.asOf : null; + const projectEvents = projectIssues.map((reason) => ({ + type: 'project-recertification-required', + projectId: project.projectId, + status: 'recertification-required', + reasons: [reason] + })); + const reviewEvents = reviewDecisions.map((decision) => ({ type: decision.status === 'current' ? 'review-evidence-current' : 'review-recertification-required', reviewId: decision.id, @@ -408,15 +431,15 @@ function buildTimelinePacket(project, reviewDecisions, commentDecisions) { reasons: decision.reasons })); - const events = [...reviewEvents, ...commentEvents]; + const events = [...projectEvents, ...reviewEvents, ...commentEvents]; return { projectId: project.projectId, - generatedAt: project.asOf, + generatedAt, events, auditDigest: digest({ projectId: project.projectId, - generatedAt: project.asOf, + generatedAt, events }) }; @@ -424,6 +447,9 @@ function buildTimelinePacket(project, reviewDecisions, commentDecisions) { function evaluateRecertification(project) { const normalizedProject = normalizeProject(project); + const projectIssues = isRecord(project) && !hasValidTime(normalizedProject.asOf) + ? ['invalid-project-timestamp'] + : []; const reviews = normalizeReviewEntries(normalizedProject.reviews); const inlineComments = normalizeInlineCommentEntries(normalizedProject.inlineComments); const reviewDecisions = reviews.map((review) => evaluateReview(normalizedProject, review)); @@ -432,12 +458,18 @@ function evaluateRecertification(project) { ); const commentDecisions = inlineComments.map((comment) => evaluateComment(normalizedProject, comment)); const recertificationTasks = [ + taskForProjectTimestamp(normalizedProject, projectIssues), ...reviews.map((review, index) => taskForReview(review, reviewDecisions[index])), ...inlineComments.map((comment, index) => taskForComment(comment, commentDecisions[index])) ].filter(Boolean); const staleReviews = reviewDecisions.filter((decision) => decision.status !== 'current').length; const staleComments = commentDecisions.filter((decision) => decision.status !== 'current').length; - const timelinePacket = buildTimelinePacket(normalizedProject, reviewDecisions, commentDecisions); + const timelinePacket = buildTimelinePacket( + normalizedProject, + reviewDecisions, + commentDecisions, + projectIssues + ); return { projectId: normalizedProject.projectId, @@ -451,10 +483,11 @@ function evaluateRecertification(project) { totalReviews: reviewDecisions.length, staleReviews, staleComments, + staleProjectEvidence: projectIssues.length, frozenReputationDelta: reputationActions .filter((action) => action.action === 'freeze-until-recertified') .reduce((sum, action) => sum + action.originalDelta, 0), - recommendedAction: (staleReviews > 0 || staleComments > 0) + recommendedAction: (projectIssues.length > 0 || staleReviews > 0 || staleComments > 0) ? 'block-reputation-update' : 'allow-reputation-update' } diff --git a/peer-review-evidence-recertification-guard/reports/backdated-recertification-packet.json b/peer-review-evidence-recertification-guard/reports/backdated-recertification-packet.json index f62b0cf4..71db067b 100644 --- a/peer-review-evidence-recertification-guard/reports/backdated-recertification-packet.json +++ b/peer-review-evidence-recertification-guard/reports/backdated-recertification-packet.json @@ -68,6 +68,7 @@ "totalReviews": 1, "staleReviews": 1, "staleComments": 0, + "staleProjectEvidence": 0, "frozenReputationDelta": 14, "recommendedAction": "block-reputation-update" } diff --git a/peer-review-evidence-recertification-guard/reports/empty-evidence-packet.json b/peer-review-evidence-recertification-guard/reports/empty-evidence-packet.json index 0c450ae8..62213574 100644 --- a/peer-review-evidence-recertification-guard/reports/empty-evidence-packet.json +++ b/peer-review-evidence-recertification-guard/reports/empty-evidence-packet.json @@ -15,6 +15,7 @@ "totalReviews": 0, "staleReviews": 0, "staleComments": 0, + "staleProjectEvidence": 0, "frozenReputationDelta": 0, "recommendedAction": "allow-reputation-update" } diff --git a/peer-review-evidence-recertification-guard/reports/invalid-project-timestamp-packet.json b/peer-review-evidence-recertification-guard/reports/invalid-project-timestamp-packet.json new file mode 100644 index 00000000..394da4aa --- /dev/null +++ b/peer-review-evidence-recertification-guard/reports/invalid-project-timestamp-packet.json @@ -0,0 +1,72 @@ +{ + "projectId": "project-invalid-project-timestamp", + "generatedAt": "not-a-date", + "reviewDecisions": [ + { + "id": "review-current-with-invalid-project-time", + "artifactId": "analysis-code", + "mode": "public", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "status": "current", + "reasons": [], + "submittedAt": "2026-05-16T11:00:00Z", + "recertifiedAt": null, + "evidenceDigest": "sha256:code-v3", + "currentArtifactDigest": "sha256:code-v3" + } + ], + "commentDecisions": [], + "reputationActions": [ + { + "id": "review-current-with-invalid-project-time", + "appliesTo": "reviewer:orcid:0000-0002-reviewer-c", + "originalDelta": 14, + "reasonDigest": "sha256:c7ed235e8e937666bff4591de0e593412b4869b6c410102db6a03933f9e63139", + "action": "apply-current-delta", + "effectiveDelta": 14 + } + ], + "recertificationTasks": [ + { + "id": "recertify-project-timestamp", + "kind": "project-evidence", + "projectId": "project-invalid-project-timestamp", + "priority": "high", + "requiredAction": "repair-project-timestamp-before-reputation-update", + "reasons": [ + "invalid-project-timestamp" + ] + } + ], + "timelinePacket": { + "projectId": "project-invalid-project-timestamp", + "generatedAt": null, + "events": [ + { + "type": "project-recertification-required", + "projectId": "project-invalid-project-timestamp", + "status": "recertification-required", + "reasons": [ + "invalid-project-timestamp" + ] + }, + { + "type": "review-evidence-current", + "reviewId": "review-current-with-invalid-project-time", + "artifactId": "analysis-code", + "reviewer": "reviewer:orcid:0000-0002-reviewer-c", + "status": "current", + "reasons": [] + } + ], + "auditDigest": "sha256:521854ec6b808c822229f876bbdf09127709d5d3606763560e9602a18145de1e" + }, + "summary": { + "totalReviews": 1, + "staleReviews": 0, + "staleComments": 0, + "staleProjectEvidence": 1, + "frozenReputationDelta": 0, + "recommendedAction": "block-reputation-update" + } +} diff --git a/peer-review-evidence-recertification-guard/reports/invalid-reputation-delta-packet.json b/peer-review-evidence-recertification-guard/reports/invalid-reputation-delta-packet.json index d060b097..21a89456 100644 --- a/peer-review-evidence-recertification-guard/reports/invalid-reputation-delta-packet.json +++ b/peer-review-evidence-recertification-guard/reports/invalid-reputation-delta-packet.json @@ -68,6 +68,7 @@ "totalReviews": 1, "staleReviews": 1, "staleComments": 0, + "staleProjectEvidence": 0, "frozenReputationDelta": 0, "recommendedAction": "block-reputation-update" } diff --git a/peer-review-evidence-recertification-guard/reports/malformed-collection-packet.json b/peer-review-evidence-recertification-guard/reports/malformed-collection-packet.json index 901b9c94..6bffeafa 100644 --- a/peer-review-evidence-recertification-guard/reports/malformed-collection-packet.json +++ b/peer-review-evidence-recertification-guard/reports/malformed-collection-packet.json @@ -102,6 +102,7 @@ "totalReviews": 1, "staleReviews": 1, "staleComments": 1, + "staleProjectEvidence": 0, "frozenReputationDelta": 0, "recommendedAction": "block-reputation-update" } diff --git a/peer-review-evidence-recertification-guard/reports/malformed-evidence-packet.json b/peer-review-evidence-recertification-guard/reports/malformed-evidence-packet.json index 76467c38..07b40955 100644 --- a/peer-review-evidence-recertification-guard/reports/malformed-evidence-packet.json +++ b/peer-review-evidence-recertification-guard/reports/malformed-evidence-packet.json @@ -102,6 +102,7 @@ "totalReviews": 1, "staleReviews": 1, "staleComments": 1, + "staleProjectEvidence": 0, "frozenReputationDelta": 0, "recommendedAction": "block-reputation-update" } diff --git a/peer-review-evidence-recertification-guard/reports/malformed-project-packet.json b/peer-review-evidence-recertification-guard/reports/malformed-project-packet.json index 7b1f6b60..2718030e 100644 --- a/peer-review-evidence-recertification-guard/reports/malformed-project-packet.json +++ b/peer-review-evidence-recertification-guard/reports/malformed-project-packet.json @@ -68,6 +68,7 @@ "totalReviews": 1, "staleReviews": 1, "staleComments": 0, + "staleProjectEvidence": 0, "frozenReputationDelta": 0, "recommendedAction": "block-reputation-update" } diff --git a/peer-review-evidence-recertification-guard/reports/recertification-packet.json b/peer-review-evidence-recertification-guard/reports/recertification-packet.json index 2034e0e5..aef1181d 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-packet.json +++ b/peer-review-evidence-recertification-guard/reports/recertification-packet.json @@ -271,6 +271,7 @@ "totalReviews": 5, "staleReviews": 3, "staleComments": 1, + "staleProjectEvidence": 0, "frozenReputationDelta": 41, "recommendedAction": "block-reputation-update" } diff --git a/peer-review-evidence-recertification-guard/reports/recertification-report.md b/peer-review-evidence-recertification-guard/reports/recertification-report.md index 5f389834..0d308cea 100644 --- a/peer-review-evidence-recertification-guard/reports/recertification-report.md +++ b/peer-review-evidence-recertification-guard/reports/recertification-report.md @@ -49,6 +49,10 @@ Recertification timestamps that predate the original review submission are block Malformed top-level recertification packets are converted into reviewer-visible recertification holds instead of crashing before timeline evidence is generated. The malformed-project fixture recommends block-reputation-update, emits 1 stale review hold, and records malformed-project-evidence for unidentified-project. +## Invalid Project Timestamp Packet + +Project snapshot timestamps must be valid before reputation updates can be applied. The invalid-project-timestamp fixture recommends block-reputation-update, emits 1 project evidence hold, creates 1 recertification task, and records null generatedAt in the audit timeline packet. + ## Privacy Notes Double-blind reviewer identifiers are replaced with reviewer-safe anonymous labels in tasks and timeline events. The audit packet uses synthetic data only and does not contain private profile emails, live profile IDs, credentials, or external API output. diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index 68467411..190e73e9 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -10,6 +10,7 @@ - Public, semi-private, and double-blind review modes are represented, with blind and fully anonymous labels normalized across hyphenated, underscored, and space-separated variants. - Public and semi-private review credit requires a concrete reviewer identity before reputation deltas are applied. - Malformed top-level recertification packets create blocked reviewer evidence for an unidentified project instead of crashing before timeline packets are generated. +- Missing or malformed project snapshot timestamps create project-evidence recertification tasks instead of releasing timeline packets with invalid generated-at evidence. - Malformed review or inline-comment entries inside evidence arrays, and malformed non-array evidence collections, create recertification tasks instead of crashing evaluation or being silently ignored. - Review history is emitted in a project timeline packet. - Sparse project snapshots that omit review, comment, or artifact collections are evaluated as empty or missing evidence instead of runtime failures. @@ -28,6 +29,7 @@ - Reviews with malformed reputation deltas are blocked from leaderboards, badges, and score updates until the delta is recertified. - Reviews without non-blind reviewer identity are blocked from leaderboards, badges, and score updates until the identity is recertified. - Recertification tasks explain which evidence must be refreshed. +- Project timestamp recertification blocks leaderboards, badges, and score updates until the audit snapshot time is repaired. - Empty evidence snapshots produce an allow decision with zero frozen reputation delta and no synthetic tasks. ## Privacy And Trust diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 51d5f357..1bd6f6ca 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -939,6 +939,41 @@ function testMalformedProjectPacketRequiresRecertificationInsteadOfCrashing() { assert.equal(result.summary.recommendedAction, 'block-reputation-update'); } +function testInvalidProjectTimestampBlocksReputationUpdate() { + const project = buildSampleProject(); + project.asOf = 'not-a-date'; + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-current-with-invalid-project-time', + reviewerId: 'orcid:0000-0002-reviewer-c', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + reputationDelta: 14 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const task = byId(result.recertificationTasks, 'recertify-project-timestamp'); + + assert.equal(result.timelinePacket.generatedAt, null); + assert.ok(task, 'expected invalid project timestamp to create a recertification task'); + assert.equal(task.kind, 'project-evidence'); + assert.deepEqual(task.reasons, ['invalid-project-timestamp']); + assert.equal(result.summary.recommendedAction, 'block-reputation-update'); +} + const tests = [ testStaleReviewsFreezeReputationUntilRecertified, testCurrentOrRecertifiedReviewsKeepReputationCredit, @@ -968,7 +1003,8 @@ const tests = [ testMissingArtifactListRequiresRecertificationInsteadOfCrashing, testMalformedReviewEntriesRequireRecertificationInsteadOfCrashing, testMalformedInlineCommentEntriesRequireRecertificationInsteadOfCrashing, - testMalformedProjectPacketRequiresRecertificationInsteadOfCrashing + testMalformedProjectPacketRequiresRecertificationInsteadOfCrashing, + testInvalidProjectTimestampBlocksReputationUpdate ]; for (const test of tests) { From 192df86b2911b17f5304550fe42af2d4a07cc320 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 13 Jun 2026 20:35:44 +0200 Subject: [PATCH 21/22] Harden anonymous reviewer label privacy --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 12 +++++++- .../requirements-map.md | 2 +- .../test.js | 29 +++++++++++++++++++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/peer-review-evidence-recertification-guard/README.md b/peer-review-evidence-recertification-guard/README.md index 7c6c9ca1..1b5ab773 100644 --- a/peer-review-evidence-recertification-guard/README.md +++ b/peer-review-evidence-recertification-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Community & User Reputation slice for SCIBASE issue #15. It checks whether peer reviews and inline comments still apply after reviewed documents, datasets, code, or notebooks change. -The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, inline-comment, and project snapshot timestamps for recertification, blocks recertification timestamps that predate the original review submission, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed top-level recertification packets, review and inline-comment evidence entries, or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, and emits a deterministic project timeline audit packet. +The guard freezes stale review reputation deltas, blocks reputation updates when review or inline-comment evidence is stale, holds missing or malformed review, artifact, inline-comment, and project snapshot timestamps for recertification, blocks recertification timestamps that predate the original review submission, freezes public or semi-private review credit when the reviewer identity is missing, holds malformed reputation-delta evidence before profile credit is applied, turns malformed top-level recertification packets, review and inline-comment evidence entries, or non-array evidence collections into explicit recertification holds, marks inline comment anchors stale when artifact evidence changes or the artifact was updated after the comment even if a selector line did not move, holds missing inline-comment anchor metadata or missing artifact anchor maps for recertification, tolerates omitted review, comment, and artifact collections in sparse project snapshots, generates recertification tasks, preserves anonymous and double-blind reviewer safety across hyphenated, underscored, and space-separated review mode labels, screens anonymous labels for direct identifiers before timeline output, and emits a deterministic project timeline audit packet. ## Run diff --git a/peer-review-evidence-recertification-guard/acceptance-notes.md b/peer-review-evidence-recertification-guard/acceptance-notes.md index 76b1e1b8..ead7507b 100644 --- a/peer-review-evidence-recertification-guard/acceptance-notes.md +++ b/peer-review-evidence-recertification-guard/acceptance-notes.md @@ -11,6 +11,7 @@ Validation targets: - stale dataset review freezes an 18 point reputation delta - recertified code review keeps its 14 point reputation delta - double-blind reviewer identity is not leaked in tasks or timeline events +- anonymous labels that contain direct identifiers are not echoed in tasks or timeline events - space-separated blind and fully anonymous mode labels do not leak raw reviewer IDs - public or semi-private reviews without reviewer identity are frozen for recertification instead of applying credit to an undefined profile - malformed reputation-delta evidence is frozen for recertification instead of applying non-numeric profile credit diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 46117fac..070eb629 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -61,12 +61,22 @@ function isBlindOrAnonymous(mode) { function reviewerDisplay(item) { if (isBlindOrAnonymous(item.mode)) { - return hasText(item.anonymousLabel) ? item.anonymousLabel.trim() : 'anonymous-reviewer'; + return safeAnonymousLabel(item.anonymousLabel); } return hasText(item.reviewerId) ? `reviewer:${item.reviewerId.trim()}` : 'reviewer:unverified'; } +function safeAnonymousLabel(label) { + if (!hasText(label)) return 'anonymous-reviewer'; + const trimmed = label.trim(); + return containsDirectIdentifier(trimmed) ? 'anonymous-reviewer' : trimmed; +} + +function containsDirectIdentifier(value = '') { + return /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|orcid:\d{4}-\d{4}-\d{4}-\d{3}[\dx]|private/i.test(value); +} + function evidenceList(value) { return Array.isArray(value) ? value : []; } diff --git a/peer-review-evidence-recertification-guard/requirements-map.md b/peer-review-evidence-recertification-guard/requirements-map.md index 190e73e9..e8b8278f 100644 --- a/peer-review-evidence-recertification-guard/requirements-map.md +++ b/peer-review-evidence-recertification-guard/requirements-map.md @@ -34,6 +34,6 @@ ## Privacy And Trust -- Double-blind and fully anonymous reviewer IDs are replaced by anonymous labels even when incoming mode names use spaces or underscores. +- Double-blind and fully anonymous reviewer IDs are replaced by anonymous labels even when incoming mode names use spaces or underscores, and labels that contain direct identifiers fall back to a generic anonymous reviewer. - Synthetic data only; no private profile emails, credentials, or external API calls. - The timeline audit digest is deterministic for reviewer verification. diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 1bd6f6ca..42d4d5bf 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -55,6 +55,34 @@ function testAnonymousReviewerIdentityIsRedacted() { assert.ok(!JSON.stringify(timelineEvent).includes('orcid:0000-0002-private')); } +function testAnonymousLabelWithDirectIdentifierIsNotEchoed() { + const project = buildSampleProject(); + project.reviews = [ + { + id: 'review-blind-direct-label', + reviewerId: 'orcid:0000-0002-private-label', + anonymousLabel: 'alice.private@example.edu', + mode: 'double-blind', + artifactId: 'dataset-cohort-table', + evidenceDigest: 'sha256:dataset-v1', + submittedAt: '2026-05-18T09:00:00Z', + reputationDelta: 18 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const task = byId(result.recertificationTasks, 'recertify-review-blind-direct-label'); + const timelineEvent = result.timelinePacket.events.find( + (event) => event.reviewId === 'review-blind-direct-label' + ); + + assert.equal(task.reviewer, 'anonymous-reviewer'); + assert.equal(timelineEvent.reviewer, 'anonymous-reviewer'); + assert.ok(!JSON.stringify(result).includes('alice.private@example.edu')); + assert.ok(!JSON.stringify(result).includes('orcid:0000-0002-private-label')); +} + function testBlindModeRedactionAcceptsCaseAndSeparatorVariants() { const project = buildSampleProject(); project.reviews = [ @@ -978,6 +1006,7 @@ const tests = [ testStaleReviewsFreezeReputationUntilRecertified, testCurrentOrRecertifiedReviewsKeepReputationCredit, testAnonymousReviewerIdentityIsRedacted, + testAnonymousLabelWithDirectIdentifierIsNotEchoed, testBlindModeRedactionAcceptsCaseAndSeparatorVariants, testBlindModeRedactionAcceptsSpaceSeparatedModes, testInlineCommentsUseArtifactAnchorsForRecertification, From edca3cf9469f59467d56053b88c0d766433f310f Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 13 Jun 2026 21:28:09 +0200 Subject: [PATCH 22/22] Harden peer reviewer identity handling --- .../index.js | 12 ++++++ .../test.js | 40 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/peer-review-evidence-recertification-guard/index.js b/peer-review-evidence-recertification-guard/index.js index 070eb629..969a4529 100644 --- a/peer-review-evidence-recertification-guard/index.js +++ b/peer-review-evidence-recertification-guard/index.js @@ -64,6 +64,10 @@ function reviewerDisplay(item) { return safeAnonymousLabel(item.anonymousLabel); } + if (containsUnsafePublicReviewerIdentifier(item.reviewerId)) { + return 'reviewer:unverified'; + } + return hasText(item.reviewerId) ? `reviewer:${item.reviewerId.trim()}` : 'reviewer:unverified'; } @@ -77,6 +81,10 @@ function containsDirectIdentifier(value = '') { return /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|orcid:\d{4}-\d{4}-\d{4}-\d{3}[\dx]|private/i.test(value); } +function containsUnsafePublicReviewerIdentifier(value = '') { + return /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|(?:file:\/\/|[A-Z]:[\\/]Users[\\/][^ \s"')]+|\/Users\/[^ \s"')]+|\/home\/[^ \s"')]+|private-lab|patient-export)/i.test(value); +} + function evidenceList(value) { return Array.isArray(value) ? value : []; } @@ -210,6 +218,10 @@ function evaluateReview(project, review) { reasons.push('reviewer-identity-missing'); } + if (!isBlindOrAnonymous(review.mode) && containsUnsafePublicReviewerIdentifier(review.reviewerId)) { + reasons.push('reviewer-identity-unsafe'); + } + if (!hasValidReputationDelta(review.reputationDelta)) { reasons.push('invalid-reputation-delta'); } diff --git a/peer-review-evidence-recertification-guard/test.js b/peer-review-evidence-recertification-guard/test.js index 42d4d5bf..9688b865 100644 --- a/peer-review-evidence-recertification-guard/test.js +++ b/peer-review-evidence-recertification-guard/test.js @@ -1002,6 +1002,43 @@ function testInvalidProjectTimestampBlocksReputationUpdate() { assert.equal(result.summary.recommendedAction, 'block-reputation-update'); } +function testPublicReviewerDirectIdentifierRequiresRecertificationWithoutEchoingId() { + const project = buildSampleProject(); + project.artifacts = [ + { + id: 'analysis-code', + type: 'code', + currentDigest: 'sha256:code-v3', + changedAt: '2026-05-10T10:00:00Z', + currentAnchors: {} + } + ]; + project.reviews = [ + { + id: 'review-public-email-identity', + reviewerId: 'alice.private@example.edu', + mode: 'public', + artifactId: 'analysis-code', + evidenceDigest: 'sha256:code-v3', + submittedAt: '2026-05-16T11:00:00Z', + reputationDelta: 14 + } + ]; + project.inlineComments = []; + + const result = evaluateRecertification(project); + const decision = byId(result.reviewDecisions, 'review-public-email-identity'); + const task = byId(result.recertificationTasks, 'recertify-review-public-email-identity'); + const action = byId(result.reputationActions, 'review-public-email-identity'); + const packetJson = JSON.stringify(result); + + assert.equal(decision.status, 'recertification-required'); + assert.deepEqual(decision.reasons, ['reviewer-identity-unsafe']); + assert.equal(task.reviewer, 'reviewer:unverified'); + assert.equal(action.appliesTo, 'reviewer:unverified'); + assert.equal(packetJson.includes('alice.private@example.edu'), false); +} + const tests = [ testStaleReviewsFreezeReputationUntilRecertified, testCurrentOrRecertifiedReviewsKeepReputationCredit, @@ -1033,7 +1070,8 @@ const tests = [ testMalformedReviewEntriesRequireRecertificationInsteadOfCrashing, testMalformedInlineCommentEntriesRequireRecertificationInsteadOfCrashing, testMalformedProjectPacketRequiresRecertificationInsteadOfCrashing, - testInvalidProjectTimestampBlocksReputationUpdate + testInvalidProjectTimestampBlocksReputationUpdate, + testPublicReviewerDirectIdentifierRequiresRecertificationWithoutEchoingId ]; for (const test of tests) {