diff --git a/profile-credit-consent-visibility-guard/README.md b/profile-credit-consent-visibility-guard/README.md new file mode 100644 index 00000000..5e359214 --- /dev/null +++ b/profile-credit-consent-visibility-guard/README.md @@ -0,0 +1,38 @@ +# Profile Credit Consent Visibility Guard + +This module adds a focused Community & User Reputation System guard for issue #15. +It evaluates whether CRediT contribution records, peer-review receipts, and +reputation deltas can safely appear on researcher profiles, citation pages, +leaderboards, or institutional exports. + +The guard is intentionally narrow. It does not implement another broad +reputation ledger, endorsement-ring detector, badge gate, leaderboard gate, +review civility tool, recusal module, calibration bench, identity-leak-only +checker, or correction-impact ledger. + +## What It Checks + +- contributor profile-credit consent is active and not expired or revoked +- ORCID/profile identity is verified before public reputation deltas publish +- CRediT/review roles are accepted and evidence receipts are immutable +- anonymous or double-blind review credits remain redacted until release +- embargoed artifacts stay citation-only until the embargo date passes +- private or sensitive evidence is restricted to institutional export packets +- missing evidence blocks public profile and leaderboard updates + +## Commands + +```bash +npm run check +npm test +npm run demo +``` + +The demo writes deterministic reviewer artifacts to `reports/`: + +- `summary.json` +- `reviewer-packet.md` +- `summary.svg` + +The sample data is synthetic only. No private user, account, payout, profile, or +payment information is included. diff --git a/profile-credit-consent-visibility-guard/acceptance-notes.md b/profile-credit-consent-visibility-guard/acceptance-notes.md new file mode 100644 index 00000000..423c2e8a --- /dev/null +++ b/profile-credit-consent-visibility-guard/acceptance-notes.md @@ -0,0 +1,23 @@ +# Acceptance Notes + +- Dependency-free CommonJS module. +- Synthetic sample records only. +- No live service calls, credentials, secrets, payment data, payout data, or + private profile data. +- Deterministic `sha256` audit digest for reviewer-visible output. +- Tests cover: + - publishable public profile/citation credits + - double-blind review redaction + - embargoed citation-only credits + - revoked consent holds + - institutional-only private evidence + - missing or mutable evidence holds + - deterministic digest generation + +Expected validation: + +```bash +npm run check +npm test +npm run demo +``` diff --git a/profile-credit-consent-visibility-guard/demo.js b/profile-credit-consent-visibility-guard/demo.js new file mode 100644 index 00000000..e5724eba --- /dev/null +++ b/profile-credit-consent-visibility-guard/demo.js @@ -0,0 +1,31 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + analyzeProfileCredits, + renderMarkdownReport, + renderSvgSummary, +} = require("./index"); +const { sampleProfileCreditPackets } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const result = analyzeProfileCredits(sampleProfileCreditPackets, { + asOf: "2026-05-22T12:00:00.000Z", +}); + +fs.writeFileSync( + path.join(reportsDir, "summary.json"), + `${JSON.stringify(result, null, 2)}\n` +); +fs.writeFileSync( + path.join(reportsDir, "reviewer-packet.md"), + renderMarkdownReport(result) +); +fs.writeFileSync( + path.join(reportsDir, "summary.svg"), + renderSvgSummary(result) +); + +console.log("profile credit consent visibility guard demo artifacts written"); +console.log(`audit digest: ${result.auditDigest}`); diff --git a/profile-credit-consent-visibility-guard/index.js b/profile-credit-consent-visibility-guard/index.js new file mode 100644 index 00000000..e9dfe885 --- /dev/null +++ b/profile-credit-consent-visibility-guard/index.js @@ -0,0 +1,420 @@ +const crypto = require("node:crypto"); + +const CREDIT_ROLES = new Set([ + "Conceptualization", + "Data curation", + "Formal analysis", + "Funding acquisition", + "Investigation", + "Methodology", + "Project administration", + "Resources", + "Software", + "Supervision", + "Validation", + "Visualization", + "Writing - original draft", + "Writing - review & editing", + "Reviewing", +]); + +function parseDate(value, field) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`invalid date for ${field}`); + } + return date; +} + +function requireFields(value, fields, label) { + for (const field of fields) { + if (value[field] === undefined || value[field] === null || value[field] === "") { + throw new Error(`missing required ${label} field: ${field}`); + } + } +} + +function hasEvidence(credit) { + return Array.isArray(credit.evidenceIds) && credit.evidenceIds.length > 0; +} + +function evaluateCredit(profile, credit, options) { + requireFields(profile, ["researcherId", "displayName", "profileConsent"], "profile"); + requireFields( + credit, + [ + "id", + "projectId", + "creditType", + "creditRole", + "targetSurface", + "requestedVisibility", + "artifactVisibility", + "reputationDelta", + ], + "credit" + ); + + const asOf = parseDate(options.asOf, "asOf"); + const consent = profile.profileConsent || {}; + const embargoUntil = parseDate(credit.embargoUntil, `embargoUntil for ${credit.id}`); + const anonymousUntil = parseDate( + credit.anonymousUntil, + `anonymousUntil for ${credit.id}` + ); + const consentExpiresAt = parseDate( + consent.expiresAt, + `profileConsent.expiresAt for ${profile.researcherId}` + ); + const reasons = []; + const actions = []; + const visibleFields = []; + + const base = { + id: credit.id, + researcherId: profile.researcherId, + displayName: profile.displayName, + projectId: credit.projectId, + creditType: credit.creditType, + creditRole: credit.creditRole, + requestedVisibility: credit.requestedVisibility, + targetSurface: credit.targetSurface, + artifactVisibility: credit.artifactVisibility, + reputationDelta: Number(credit.reputationDelta || 0), + publishableReputationDelta: 0, + visibleFields, + reasons, + actions, + }; + + if (!CREDIT_ROLES.has(credit.creditRole)) { + reasons.push("credit role is not mapped to an accepted CRediT/review role"); + } + + if (!hasEvidence(credit)) { + reasons.push("credit has no evidence receipt ids"); + } + + if (!credit.sourceReceiptImmutable) { + reasons.push("source receipt is not immutable"); + } + + if (consent.status !== "granted") { + return { + ...base, + status: "hold", + visibilityDecision: "do_not_publish", + reasons: [ + consent.status === "revoked" + ? "profile credit consent has been revoked" + : "profile credit consent is not granted", + ], + actions: [ + "Remove credit from public profile surfaces", + "Retain private audit receipt only", + "Ask contributor to refresh consent in account settings", + ], + }; + } + + if (consentExpiresAt && consentExpiresAt <= asOf) { + return { + ...base, + status: "hold", + visibilityDecision: "do_not_publish", + reasons: ["profile credit consent has expired"], + actions: [ + "Hold profile publication", + "Request renewed profile credit consent", + ], + }; + } + + if (reasons.length > 0) { + return { + ...base, + status: "hold", + visibilityDecision: "evidence_review", + actions: [ + "Block reputation delta from profile and leaderboard", + "Attach missing evidence before publication", + "Route credit receipt to steward review", + ], + }; + } + + if (anonymousUntil && anonymousUntil > asOf) { + visibleFields.push("anonymous_reviewer_label", "review_type", "project_id"); + return { + ...base, + status: "redacted", + visibilityDecision: "anonymous_until_release", + publishableReputationDelta: 0, + reasons: [ + "double-blind review remains inside anonymous visibility window", + ], + actions: [ + "Publish only anonymized review receipt", + "Hold profile and leaderboard reputation delta until anonymity release", + `Re-evaluate after ${credit.anonymousUntil}`, + ], + }; + } + + if (embargoUntil && embargoUntil > asOf) { + visibleFields.push("credit_role", "project_id", "embargo_label"); + return { + ...base, + status: "citation_only", + visibilityDecision: "embargoed_credit", + publishableReputationDelta: 0, + reasons: ["linked artifact is still under embargo"], + actions: [ + "Show citation-page placeholder only", + "Suppress public profile delta until embargo expires", + `Re-evaluate after ${credit.embargoUntil}`, + ], + }; + } + + if ( + credit.artifactVisibility === "private" || + credit.sensitiveEvidence || + credit.requestedVisibility === "institutional_only" || + consent.visibility === "institutional_only" + ) { + visibleFields.push("credit_role", "project_id", "institutional_receipt_id"); + return { + ...base, + status: "institutional_only", + visibilityDecision: "restricted_export_only", + publishableReputationDelta: 0, + reasons: ["credit evidence is restricted to institutional export"], + actions: [ + "Exclude from public profile and citation page", + "Include in institutional report with restricted evidence label", + "Require steward approval before public conversion", + ], + }; + } + + if (credit.requestedVisibility === "citation_only" || consent.visibility === "citation_only") { + visibleFields.push("credit_role", "project_id", "citation_receipt_id"); + return { + ...base, + status: "citation_only", + visibilityDecision: "citation_page_only", + publishableReputationDelta: 0, + reasons: ["contributor consent permits citation page but not public profile"], + actions: [ + "Show contribution on citation page only", + "Suppress profile badge and leaderboard delta", + ], + }; + } + + if (!profile.orcidVerified) { + visibleFields.push("credit_role", "project_id", "unverified_profile_notice"); + return { + ...base, + status: "citation_only", + visibilityDecision: "orcid_verification_required", + publishableReputationDelta: 0, + reasons: ["ORCID/profile identity is not verified"], + actions: [ + "Limit credit to citation page until profile verification", + "Request ORCID verification before public reputation delta", + ], + }; + } + + visibleFields.push( + "display_name", + "orcid", + "credit_role", + "project_id", + "evidence_receipt_ids", + "reputation_delta" + ); + + return { + ...base, + status: "publishable", + visibilityDecision: "public_profile_and_citation", + publishableReputationDelta: base.reputationDelta, + reasons: [ + "contributor consent is active", + "identity is verified", + "evidence receipts are immutable and public-safe", + ], + actions: [ + "Publish credit on profile and citation page", + "Apply reputation delta to transparent profile ledger", + "Attach audit digest to profile update packet", + ], + }; +} + +function analyzeProfileCredits(profiles, options = {}) { + if (!Array.isArray(profiles)) { + throw new Error("profile packets must be an array"); + } + + const normalizedOptions = { + asOf: parseDate(options.asOf || new Date().toISOString(), "asOf").toISOString(), + }; + + const credits = []; + for (const profile of profiles) { + if (!Array.isArray(profile.credits)) { + throw new Error(`profile ${profile.researcherId || "(unknown)"} must include credits`); + } + for (const credit of profile.credits) { + credits.push(evaluateCredit(profile, credit, normalizedOptions)); + } + } + + const totals = credits.reduce( + (acc, credit) => { + acc.totalCredits += 1; + acc.publishableReputationDelta += credit.publishableReputationDelta; + acc.byStatus[credit.status] = (acc.byStatus[credit.status] || 0) + 1; + return acc; + }, + { + totalCredits: 0, + publishableReputationDelta: 0, + byStatus: {}, + } + ); + + const result = { + asOf: normalizedOptions.asOf, + totals, + credits, + }; + + return { + ...result, + auditDigest: createAuditDigest(result), + }; +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + +function createAuditDigest(result) { + const payload = { + asOf: result.asOf, + totals: result.totals, + credits: result.credits.map((credit) => ({ + id: credit.id, + researcherId: credit.researcherId, + status: credit.status, + visibilityDecision: credit.visibilityDecision, + publishableReputationDelta: credit.publishableReputationDelta, + reasons: credit.reasons, + })), + }; + + return crypto.createHash("sha256").update(stableStringify(payload)).digest("hex"); +} + +function renderMarkdownReport(result) { + const lines = [ + "# Profile Credit Consent Visibility Guard", + "", + `As of: ${result.asOf}`, + `Audit digest: \`${result.auditDigest}\``, + "", + "## Totals", + "", + `- Credits evaluated: ${result.totals.totalCredits}`, + `- Publishable reputation delta: ${result.totals.publishableReputationDelta}`, + ]; + + for (const [status, count] of Object.entries(result.totals.byStatus).sort()) { + lines.push(`- ${status}: ${count}`); + } + + lines.push("", "## Credit Decisions", ""); + for (const credit of result.credits) { + lines.push( + `### ${credit.id}`, + "", + `- Researcher: ${credit.displayName} (${credit.researcherId})`, + `- Role: ${credit.creditRole}`, + `- Status: ${credit.status}`, + `- Visibility decision: ${credit.visibilityDecision}`, + `- Publishable reputation delta: ${credit.publishableReputationDelta}`, + `- Reasons: ${credit.reasons.join("; ")}`, + `- Actions: ${credit.actions.join("; ")}`, + "" + ); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderSvgSummary(result) { + const statuses = Object.entries(result.totals.byStatus).sort(); + const rows = statuses + .map(([status, count], index) => { + const y = 132 + index * 42; + const width = Math.max(32, count * 78); + return ` + ${escapeXml(status)} + + ${count}`; + }) + .join(""); + + return ` + + + + Profile Credit Consent Visibility Guard + Credits: ${result.totals.totalCredits} | Public reputation delta: ${result.totals.publishableReputationDelta} +${rows} + audit ${escapeXml(result.auditDigest.slice(0, 48))} + +`; +} + +module.exports = { + CREDIT_ROLES, + analyzeProfileCredits, + createAuditDigest, + renderMarkdownReport, + renderSvgSummary, +}; diff --git a/profile-credit-consent-visibility-guard/package.json b/profile-credit-consent-visibility-guard/package.json new file mode 100644 index 00000000..5251b9f4 --- /dev/null +++ b/profile-credit-consent-visibility-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "profile-credit-consent-visibility-guard", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js" + } +} diff --git a/profile-credit-consent-visibility-guard/reports/demo.mp4 b/profile-credit-consent-visibility-guard/reports/demo.mp4 new file mode 100644 index 00000000..428ae74a Binary files /dev/null and b/profile-credit-consent-visibility-guard/reports/demo.mp4 differ diff --git a/profile-credit-consent-visibility-guard/reports/reviewer-packet.md b/profile-credit-consent-visibility-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..9520bfdd --- /dev/null +++ b/profile-credit-consent-visibility-guard/reports/reviewer-packet.md @@ -0,0 +1,86 @@ +# Profile Credit Consent Visibility Guard + +As of: 2026-05-22T12:00:00.000Z +Audit digest: `41de587fb911104fbd7898f0df8a48b004d188bf0c48b4084c8362c67143a6b5` + +## Totals + +- Credits evaluated: 7 +- Publishable reputation delta: 17 +- citation_only: 1 +- hold: 2 +- institutional_only: 1 +- publishable: 2 +- redacted: 1 + +## Credit Decisions + +### credit-dataset-curation + +- Researcher: Ada Chen (researcher-ada) +- Role: Data curation +- Status: publishable +- Visibility decision: public_profile_and_citation +- Publishable reputation delta: 12 +- Reasons: contributor consent is active; identity is verified; evidence receipts are immutable and public-safe +- Actions: Publish credit on profile and citation page; Apply reputation delta to transparent profile ledger; Attach audit digest to profile update packet + +### credit-open-review + +- Researcher: Ada Chen (researcher-ada) +- Role: Reviewing +- Status: publishable +- Visibility decision: public_profile_and_citation +- Publishable reputation delta: 5 +- Reasons: contributor consent is active; identity is verified; evidence receipts are immutable and public-safe +- Actions: Publish credit on profile and citation page; Apply reputation delta to transparent profile ledger; Attach audit digest to profile update packet + +### credit-double-blind-review + +- Researcher: Blind Reviewer 7 (researcher-blind-reviewer) +- Role: Reviewing +- Status: redacted +- Visibility decision: anonymous_until_release +- Publishable reputation delta: 0 +- Reasons: double-blind review remains inside anonymous visibility window +- Actions: Publish only anonymized review receipt; Hold profile and leaderboard reputation delta until anonymity release; Re-evaluate after 2026-08-01T00:00:00.000Z + +### credit-embargoed-code + +- Researcher: Bea Laurent (researcher-bea) +- Role: Software +- Status: citation_only +- Visibility decision: embargoed_credit +- Publishable reputation delta: 0 +- Reasons: linked artifact is still under embargo +- Actions: Show citation-page placeholder only; Suppress public profile delta until embargo expires; Re-evaluate after 2026-07-15T00:00:00.000Z + +### credit-revoked-consent + +- Researcher: Cy Morgan (researcher-cy) +- Role: Software +- Status: hold +- Visibility decision: do_not_publish +- Publishable reputation delta: 0 +- Reasons: profile credit consent has been revoked +- Actions: Remove credit from public profile surfaces; Retain private audit receipt only; Ask contributor to refresh consent in account settings + +### credit-private-protocol + +- Researcher: Dev Imani (researcher-dev) +- Role: Methodology +- Status: institutional_only +- Visibility decision: restricted_export_only +- Publishable reputation delta: 0 +- Reasons: credit evidence is restricted to institutional export +- Actions: Exclude from public profile and citation page; Include in institutional report with restricted evidence label; Require steward approval before public conversion + +### credit-missing-evidence + +- Researcher: Eli Novak (researcher-eli) +- Role: Supervision +- Status: hold +- Visibility decision: evidence_review +- Publishable reputation delta: 0 +- Reasons: credit has no evidence receipt ids; source receipt is not immutable +- Actions: Block reputation delta from profile and leaderboard; Attach missing evidence before publication; Route credit receipt to steward review diff --git a/profile-credit-consent-visibility-guard/reports/summary.json b/profile-credit-consent-visibility-guard/reports/summary.json new file mode 100644 index 00000000..8f1046f4 --- /dev/null +++ b/profile-credit-consent-visibility-guard/reports/summary.json @@ -0,0 +1,216 @@ +{ + "asOf": "2026-05-22T12:00:00.000Z", + "totals": { + "totalCredits": 7, + "publishableReputationDelta": 17, + "byStatus": { + "publishable": 2, + "redacted": 1, + "citation_only": 1, + "hold": 2, + "institutional_only": 1 + } + }, + "credits": [ + { + "id": "credit-dataset-curation", + "researcherId": "researcher-ada", + "displayName": "Ada Chen", + "projectId": "proj-open-cell-atlas", + "creditType": "dataset_curation", + "creditRole": "Data curation", + "requestedVisibility": "public", + "targetSurface": "profile_and_citation", + "artifactVisibility": "public", + "reputationDelta": 12, + "publishableReputationDelta": 12, + "visibleFields": [ + "display_name", + "orcid", + "credit_role", + "project_id", + "evidence_receipt_ids", + "reputation_delta" + ], + "reasons": [ + "contributor consent is active", + "identity is verified", + "evidence receipts are immutable and public-safe" + ], + "actions": [ + "Publish credit on profile and citation page", + "Apply reputation delta to transparent profile ledger", + "Attach audit digest to profile update packet" + ], + "status": "publishable", + "visibilityDecision": "public_profile_and_citation" + }, + { + "id": "credit-open-review", + "researcherId": "researcher-ada", + "displayName": "Ada Chen", + "projectId": "proj-open-cell-atlas", + "creditType": "peer_review", + "creditRole": "Reviewing", + "requestedVisibility": "public", + "targetSurface": "profile", + "artifactVisibility": "public", + "reputationDelta": 5, + "publishableReputationDelta": 5, + "visibleFields": [ + "display_name", + "orcid", + "credit_role", + "project_id", + "evidence_receipt_ids", + "reputation_delta" + ], + "reasons": [ + "contributor consent is active", + "identity is verified", + "evidence receipts are immutable and public-safe" + ], + "actions": [ + "Publish credit on profile and citation page", + "Apply reputation delta to transparent profile ledger", + "Attach audit digest to profile update packet" + ], + "status": "publishable", + "visibilityDecision": "public_profile_and_citation" + }, + { + "id": "credit-double-blind-review", + "researcherId": "researcher-blind-reviewer", + "displayName": "Blind Reviewer 7", + "projectId": "proj-novel-assay", + "creditType": "peer_review", + "creditRole": "Reviewing", + "requestedVisibility": "public", + "targetSurface": "profile_and_leaderboard", + "artifactVisibility": "restricted", + "reputationDelta": 9, + "publishableReputationDelta": 0, + "visibleFields": [ + "anonymous_reviewer_label", + "review_type", + "project_id" + ], + "reasons": [ + "double-blind review remains inside anonymous visibility window" + ], + "actions": [ + "Publish only anonymized review receipt", + "Hold profile and leaderboard reputation delta until anonymity release", + "Re-evaluate after 2026-08-01T00:00:00.000Z" + ], + "status": "redacted", + "visibilityDecision": "anonymous_until_release" + }, + { + "id": "credit-embargoed-code", + "researcherId": "researcher-bea", + "displayName": "Bea Laurent", + "projectId": "proj-trial-protocol", + "creditType": "software", + "creditRole": "Software", + "requestedVisibility": "citation_only", + "targetSurface": "citation_page", + "artifactVisibility": "restricted", + "reputationDelta": 8, + "publishableReputationDelta": 0, + "visibleFields": [ + "credit_role", + "project_id", + "embargo_label" + ], + "reasons": [ + "linked artifact is still under embargo" + ], + "actions": [ + "Show citation-page placeholder only", + "Suppress public profile delta until embargo expires", + "Re-evaluate after 2026-07-15T00:00:00.000Z" + ], + "status": "citation_only", + "visibilityDecision": "embargoed_credit" + }, + { + "id": "credit-revoked-consent", + "researcherId": "researcher-cy", + "displayName": "Cy Morgan", + "projectId": "proj-open-cell-atlas", + "creditType": "software", + "creditRole": "Software", + "requestedVisibility": "public", + "targetSurface": "profile", + "artifactVisibility": "public", + "reputationDelta": 7, + "publishableReputationDelta": 0, + "visibleFields": [], + "reasons": [ + "profile credit consent has been revoked" + ], + "actions": [ + "Remove credit from public profile surfaces", + "Retain private audit receipt only", + "Ask contributor to refresh consent in account settings" + ], + "status": "hold", + "visibilityDecision": "do_not_publish" + }, + { + "id": "credit-private-protocol", + "researcherId": "researcher-dev", + "displayName": "Dev Imani", + "projectId": "proj-private-protocol", + "creditType": "methodology", + "creditRole": "Methodology", + "requestedVisibility": "institutional_only", + "targetSurface": "institutional_export", + "artifactVisibility": "private", + "reputationDelta": 6, + "publishableReputationDelta": 0, + "visibleFields": [ + "credit_role", + "project_id", + "institutional_receipt_id" + ], + "reasons": [ + "credit evidence is restricted to institutional export" + ], + "actions": [ + "Exclude from public profile and citation page", + "Include in institutional report with restricted evidence label", + "Require steward approval before public conversion" + ], + "status": "institutional_only", + "visibilityDecision": "restricted_export_only" + }, + { + "id": "credit-missing-evidence", + "researcherId": "researcher-eli", + "displayName": "Eli Novak", + "projectId": "proj-literature-map", + "creditType": "supervision", + "creditRole": "Supervision", + "requestedVisibility": "public", + "targetSurface": "profile", + "artifactVisibility": "public", + "reputationDelta": 4, + "publishableReputationDelta": 0, + "visibleFields": [], + "reasons": [ + "credit has no evidence receipt ids", + "source receipt is not immutable" + ], + "actions": [ + "Block reputation delta from profile and leaderboard", + "Attach missing evidence before publication", + "Route credit receipt to steward review" + ], + "status": "hold", + "visibilityDecision": "evidence_review" + } + ], + "auditDigest": "41de587fb911104fbd7898f0df8a48b004d188bf0c48b4084c8362c67143a6b5" +} diff --git a/profile-credit-consent-visibility-guard/reports/summary.svg b/profile-credit-consent-visibility-guard/reports/summary.svg new file mode 100644 index 00000000..4ecf4a95 --- /dev/null +++ b/profile-credit-consent-visibility-guard/reports/summary.svg @@ -0,0 +1,33 @@ + + + + + Profile Credit Consent Visibility Guard + Credits: 7 | Public reputation delta: 17 + + citation_only + + 1 + hold + + 2 + institutional_only + + 1 + publishable + + 2 + redacted + + 1 + audit 41de587fb911104fbd7898f0df8a48b004d188bf0c48b408 + diff --git a/profile-credit-consent-visibility-guard/requirements-map.md b/profile-credit-consent-visibility-guard/requirements-map.md new file mode 100644 index 00000000..baf50634 --- /dev/null +++ b/profile-credit-consent-visibility-guard/requirements-map.md @@ -0,0 +1,24 @@ +# Requirements Map + +## Issue #15: Community & User Reputation System + +| Requirement area | Coverage in this module | +| --- | --- | +| Contributor credits | Evaluates CRediT-style credit records before profile or citation publication. | +| Visible credit on researcher profiles and citation pages | Separates public profile, citation-only, redacted, institutional-only, and hold decisions. | +| Review history tracked on profiles | Allows open review receipts while redacting double-blind review identity until release. | +| Public, semi-private, and anonymous review modes | Keeps anonymous and double-blind credits redacted while preserving audit receipts. | +| Reputation scoring | Applies reputation deltas only when consent, evidence, identity, and artifact visibility are safe. | +| Institutional reporting | Emits institutional-only decisions for private/sensitive artifacts instead of public exposure. | +| Trust and transparency | Produces deterministic reasons, actions, visible fields, and an audit digest for each credit. | + +## Non-Overlap + +This is not another broad reputation ledger, endorsement-ring guard, mentorship +ladder, correction-impact ledger, credit attestation module, peer-review +calibration module, COI assignment checker, transparency receipt ledger, +leaderboard eligibility guard, badge renewal gate, civility gate, review +timeliness guard, peer-review recusal guard, or edit-history integrity guard. + +It focuses only on whether already-recorded contribution and review credit may +be shown on profile, citation, leaderboard, or institutional surfaces. diff --git a/profile-credit-consent-visibility-guard/sample-data.js b/profile-credit-consent-visibility-guard/sample-data.js new file mode 100644 index 00000000..3e599dbd --- /dev/null +++ b/profile-credit-consent-visibility-guard/sample-data.js @@ -0,0 +1,171 @@ +const sampleProfileCreditPackets = [ + { + researcherId: "researcher-ada", + displayName: "Ada Chen", + orcid: "0000-0002-1825-0097", + orcidVerified: true, + profileConsent: { + status: "granted", + grantedAt: "2026-03-01T09:00:00.000Z", + visibility: "public", + }, + credits: [ + { + id: "credit-dataset-curation", + projectId: "proj-open-cell-atlas", + creditType: "dataset_curation", + creditRole: "Data curation", + targetSurface: "profile_and_citation", + requestedVisibility: "public", + artifactVisibility: "public", + evidenceIds: ["dataset-doi-10.5555/cell-atlas-v2", "curation-commit-a1"], + sourceReceiptImmutable: true, + reputationDelta: 12, + }, + { + id: "credit-open-review", + projectId: "proj-open-cell-atlas", + creditType: "peer_review", + creditRole: "Reviewing", + targetSurface: "profile", + requestedVisibility: "public", + artifactVisibility: "public", + evidenceIds: ["review-receipt-204"], + sourceReceiptImmutable: true, + reputationDelta: 5, + }, + ], + }, + { + researcherId: "researcher-blind-reviewer", + displayName: "Blind Reviewer 7", + orcid: "0000-0003-1415-9265", + orcidVerified: true, + profileConsent: { + status: "granted", + grantedAt: "2026-02-15T12:00:00.000Z", + visibility: "public", + }, + credits: [ + { + id: "credit-double-blind-review", + projectId: "proj-novel-assay", + creditType: "peer_review", + creditRole: "Reviewing", + targetSurface: "profile_and_leaderboard", + requestedVisibility: "public", + artifactVisibility: "restricted", + reviewMode: "double_blind", + anonymousUntil: "2026-08-01T00:00:00.000Z", + evidenceIds: ["blind-review-receipt-11"], + sourceReceiptImmutable: true, + reputationDelta: 9, + }, + ], + }, + { + researcherId: "researcher-bea", + displayName: "Bea Laurent", + orcid: "0000-0001-7777-2222", + orcidVerified: true, + profileConsent: { + status: "granted", + grantedAt: "2026-01-05T09:00:00.000Z", + visibility: "citation_only", + }, + credits: [ + { + id: "credit-embargoed-code", + projectId: "proj-trial-protocol", + creditType: "software", + creditRole: "Software", + targetSurface: "citation_page", + requestedVisibility: "citation_only", + artifactVisibility: "restricted", + embargoUntil: "2026-07-15T00:00:00.000Z", + evidenceIds: ["code-commit-embargoed-44"], + sourceReceiptImmutable: true, + reputationDelta: 8, + }, + ], + }, + { + researcherId: "researcher-cy", + displayName: "Cy Morgan", + orcid: "0000-0002-8888-3333", + orcidVerified: true, + profileConsent: { + status: "revoked", + grantedAt: "2025-12-01T09:00:00.000Z", + revokedAt: "2026-04-20T09:00:00.000Z", + visibility: "public", + }, + credits: [ + { + id: "credit-revoked-consent", + projectId: "proj-open-cell-atlas", + creditType: "software", + creditRole: "Software", + targetSurface: "profile", + requestedVisibility: "public", + artifactVisibility: "public", + evidenceIds: ["commit-cy-88"], + sourceReceiptImmutable: true, + reputationDelta: 7, + }, + ], + }, + { + researcherId: "researcher-dev", + displayName: "Dev Imani", + orcid: "0000-0002-9999-4444", + orcidVerified: false, + profileConsent: { + status: "granted", + grantedAt: "2026-03-10T09:00:00.000Z", + visibility: "institutional_only", + }, + credits: [ + { + id: "credit-private-protocol", + projectId: "proj-private-protocol", + creditType: "methodology", + creditRole: "Methodology", + targetSurface: "institutional_export", + requestedVisibility: "institutional_only", + artifactVisibility: "private", + sensitiveEvidence: true, + evidenceIds: ["dua-controlled-protocol-9"], + sourceReceiptImmutable: true, + reputationDelta: 6, + }, + ], + }, + { + researcherId: "researcher-eli", + displayName: "Eli Novak", + orcid: "0000-0002-5555-6666", + orcidVerified: true, + profileConsent: { + status: "granted", + grantedAt: "2026-03-22T09:00:00.000Z", + visibility: "public", + }, + credits: [ + { + id: "credit-missing-evidence", + projectId: "proj-literature-map", + creditType: "supervision", + creditRole: "Supervision", + targetSurface: "profile", + requestedVisibility: "public", + artifactVisibility: "public", + evidenceIds: [], + sourceReceiptImmutable: false, + reputationDelta: 4, + }, + ], + }, +]; + +module.exports = { sampleProfileCreditPackets }; diff --git a/profile-credit-consent-visibility-guard/test.js b/profile-credit-consent-visibility-guard/test.js new file mode 100644 index 00000000..0eea1c24 --- /dev/null +++ b/profile-credit-consent-visibility-guard/test.js @@ -0,0 +1,87 @@ +const assert = require("node:assert/strict"); +const { + analyzeProfileCredits, + createAuditDigest, +} = require("./index"); +const { sampleProfileCreditPackets } = require("./sample-data"); + +const AS_OF = "2026-05-22T12:00:00.000Z"; + +function byId(result, id) { + const credit = result.credits.find((entry) => entry.id === id); + assert.ok(credit, `expected credit ${id} to exist`); + return credit; +} + +function run() { + const result = analyzeProfileCredits(sampleProfileCreditPackets, { + asOf: AS_OF, + }); + + assert.equal(result.asOf, AS_OF); + assert.equal(result.totals.totalCredits, 7); + assert.equal(result.totals.publishableReputationDelta, 17); + assert.equal(result.totals.byStatus.publishable, 2); + assert.equal(result.totals.byStatus.redacted, 1); + assert.equal(result.totals.byStatus.citation_only, 1); + assert.equal(result.totals.byStatus.institutional_only, 1); + assert.equal(result.totals.byStatus.hold, 2); + + const dataset = byId(result, "credit-dataset-curation"); + assert.equal(dataset.status, "publishable"); + assert.equal(dataset.visibilityDecision, "public_profile_and_citation"); + assert.equal(dataset.publishableReputationDelta, 12); + assert.ok(dataset.visibleFields.includes("orcid")); + + const openReview = byId(result, "credit-open-review"); + assert.equal(openReview.status, "publishable"); + assert.equal(openReview.publishableReputationDelta, 5); + + const blind = byId(result, "credit-double-blind-review"); + assert.equal(blind.status, "redacted"); + assert.equal(blind.publishableReputationDelta, 0); + assert.ok(blind.visibleFields.includes("anonymous_reviewer_label")); + assert.ok(blind.reasons.includes("double-blind review remains inside anonymous visibility window")); + + const embargoed = byId(result, "credit-embargoed-code"); + assert.equal(embargoed.status, "citation_only"); + assert.equal(embargoed.visibilityDecision, "embargoed_credit"); + assert.ok(embargoed.actions.some((action) => action.includes("2026-07-15"))); + + const revoked = byId(result, "credit-revoked-consent"); + assert.equal(revoked.status, "hold"); + assert.equal(revoked.visibilityDecision, "do_not_publish"); + assert.ok(revoked.reasons.includes("profile credit consent has been revoked")); + + const privateProtocol = byId(result, "credit-private-protocol"); + assert.equal(privateProtocol.status, "institutional_only"); + assert.equal(privateProtocol.visibilityDecision, "restricted_export_only"); + assert.equal(privateProtocol.publishableReputationDelta, 0); + + const missing = byId(result, "credit-missing-evidence"); + assert.equal(missing.status, "hold"); + assert.equal(missing.visibilityDecision, "evidence_review"); + assert.ok(missing.reasons.includes("credit has no evidence receipt ids")); + assert.ok(missing.reasons.includes("source receipt is not immutable")); + + const digest = createAuditDigest(result); + assert.match(digest, /^[a-f0-9]{64}$/); + assert.equal(digest, createAuditDigest(result)); + + assert.throws( + () => + analyzeProfileCredits( + [{ researcherId: "bad", displayName: "Bad", profileConsent: {} }], + { asOf: AS_OF } + ), + /must include credits/ + ); + + assert.throws( + () => analyzeProfileCredits([{ researcherId: "bad", credits: [{}] }], { asOf: AS_OF }), + /missing required profile field/ + ); +} + +run(); +console.log("profile credit consent visibility guard tests passed");