diff --git a/researcher-affiliation-attestation-guard/README.md b/researcher-affiliation-attestation-guard/README.md
new file mode 100644
index 00000000..33718c85
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/README.md
@@ -0,0 +1,17 @@
+# Researcher Affiliation Attestation Guard
+
+Self-contained SCIBASE User & Project Management slice for issue #11. The guard checks whether public researcher profile affiliation and grant claims are sufficiently evidenced before they influence project trust, invitations, grants, or reputation signals.
+
+## Why this slice is distinct
+
+Existing #11 submissions cover RBAC/workspace ledgers, privacy access review, member lifecycle/offboarding, institutional recertification, anonymous-review escrow, data-room consent, researcher profile sync, archive handoff, access-audit anomaly, role delegation, invitation-domain/MFA, funding attribution, service-token governance, deletion/erasure, break-glass access, visibility transition, provisioning baseline, object-permission inheritance, session step-up, collaborator COI, data residency, authoring artifact integrity, ORCID publication disambiguation, and access denial appeals. This module focuses only on public profile affiliation and grant attestation: ORCID/SSO/admin evidence, expired appointments, institutional email-domain matching, grant consent, evidence receipts, and blocking unverified profile claims from trust/reputation decisions.
+
+## Run
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+Demo artifacts are written to `reports/`, including JSON, Markdown, SVG, GIF, and MP4 files.
diff --git a/researcher-affiliation-attestation-guard/demo.js b/researcher-affiliation-attestation-guard/demo.js
new file mode 100644
index 00000000..94bac2fe
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/demo.js
@@ -0,0 +1,61 @@
+const fs = require("fs");
+const path = require("path");
+
+const { assessAffiliationAttestation } = require("./index");
+const { trustedProfile, riskyProfile } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+function markdownReport(name, report) {
+ const findings = report.findings.length
+ ? report.findings
+ .map((item) => `- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`)
+ .join("\n")
+ : "- No affiliation or grant attestation findings.";
+ return `# Researcher Affiliation Attestation Guard
+
+Scenario: ${name}
+
+Researcher: ${report.displayName}
+Profile mode: ${report.profileMode}
+Decision: ${report.decision.toUpperCase()}
+
+Reviewed ${report.summary.affiliationsReviewed} affiliations and ${report.summary.grantsReviewed} grants.
+
+## Findings
+
+${findings}
+
+## Release Criteria
+
+${report.releaseCriteria.map((item) => `- ${item}`).join("\n")}
+`;
+}
+
+function svgReport(report) {
+ const color = report.decision === "hold" ? "#b91c1c" : report.decision === "revise" ? "#c2410c" : "#15803d";
+ return ``;
+}
+
+for (const [name, profile] of [
+ ["trusted-profile", trustedProfile],
+ ["risky-profile", riskyProfile],
+]) {
+ const report = assessAffiliationAttestation(profile, { asOf: "2026-06-01" });
+ fs.writeFileSync(path.join(reportsDir, `${name}.json`), JSON.stringify(report, null, 2));
+ fs.writeFileSync(path.join(reportsDir, `${name}.md`), markdownReport(name, report));
+ fs.writeFileSync(path.join(reportsDir, `${name}.svg`), svgReport(report));
+ console.log(`${name}: ${report.decision} (${report.summary.findings} findings)`);
+}
diff --git a/researcher-affiliation-attestation-guard/demo_video.py b/researcher-affiliation-attestation-guard/demo_video.py
new file mode 100644
index 00000000..5babd526
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/demo_video.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+import imageio.v3 as iio
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+ROOT = Path(__file__).resolve().parent
+REPORTS = ROOT / "reports"
+REPORTS.mkdir(exist_ok=True)
+
+
+def font(size):
+ for name in ("arial.ttf", "segoeui.ttf"):
+ try:
+ return ImageFont.truetype(name, size)
+ except OSError:
+ pass
+ return ImageFont.load_default()
+
+
+slides = [
+ ("Affiliation Attestation Guard", "User & Project Management #11"),
+ ("Checks", "ORCID, institutional SSO, admin attestations"),
+ ("Checks", "expired appointments, grant consent, email domains"),
+ ("Decision", "hold trust features until public claims are evidenced"),
+]
+
+frames = []
+for index, (title, subtitle) in enumerate(slides, start=1):
+ image = Image.new("RGB", (960, 544), "#111827")
+ draw = ImageDraw.Draw(image)
+ draw.rectangle((44, 52, 916, 492), outline="#60a5fa", width=3)
+ draw.text((80, 124), title, fill="#f8fafc", font=font(40))
+ draw.text((80, 206), subtitle, fill="#dbeafe", font=font(25))
+ draw.rectangle((80, 326, 818, 382), fill="#1d4ed8")
+ draw.text((104, 342), "profile claims must be verified before reputation or invitations", fill="#eff6ff", font=font(21))
+ draw.text((80, 438), f"Slide {index}/4 - synthetic reviewer artifact", fill="#cbd5e1", font=font(20))
+ frames.extend([image] * 14)
+
+gif_path = REPORTS / "demo.gif"
+mp4_path = REPORTS / "demo.mp4"
+frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0)
+iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264")
+print(f"wrote {gif_path}")
+print(f"wrote {mp4_path}")
diff --git a/researcher-affiliation-attestation-guard/index.js b/researcher-affiliation-attestation-guard/index.js
new file mode 100644
index 00000000..1b9b2eed
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/index.js
@@ -0,0 +1,264 @@
+const HIGH = "high";
+const MEDIUM = "medium";
+const LOW = "low";
+
+function requiredString(value, field) {
+ if (typeof value !== "string" || value.trim() === "") {
+ throw new TypeError(`${field} must be a non-empty string`);
+ }
+ return value.trim();
+}
+
+function array(value, field) {
+ if (!Array.isArray(value)) {
+ throw new TypeError(`${field} must be an array`);
+ }
+ return value;
+}
+
+function unique(values) {
+ return [...new Set(values.map(String))];
+}
+
+function normalizeDate(value, field) {
+ const text = requiredString(value, field);
+ const time = Date.parse(text);
+ if (Number.isNaN(time)) {
+ throw new TypeError(`${field} must be an ISO date`);
+ }
+ return text;
+}
+
+function normalizeAffiliation(raw, index) {
+ return {
+ id: requiredString(raw.id, `affiliations[${index}].id`),
+ institution: requiredString(raw.institution, `affiliations[${index}].institution`),
+ role: requiredString(raw.role, `affiliations[${index}].role`),
+ source: raw.source ? String(raw.source) : "",
+ verifiedDomain: raw.verifiedDomain ? String(raw.verifiedDomain).toLowerCase() : "",
+ validFrom: normalizeDate(raw.validFrom || "1970-01-01", `affiliations[${index}].validFrom`),
+ validUntil: raw.validUntil ? normalizeDate(raw.validUntil, `affiliations[${index}].validUntil`) : "",
+ publicClaim: Boolean(raw.publicClaim),
+ evidenceRefs: unique(raw.evidenceRefs || []),
+ };
+}
+
+function normalizeGrant(raw, index) {
+ return {
+ id: requiredString(raw.id, `grants[${index}].id`),
+ funder: requiredString(raw.funder, `grants[${index}].funder`),
+ projectId: requiredString(raw.projectId, `grants[${index}].projectId`),
+ publicClaim: Boolean(raw.publicClaim),
+ consent: raw.consent ? String(raw.consent) : "",
+ evidenceRefs: unique(raw.evidenceRefs || []),
+ };
+}
+
+function normalizeProfile(raw) {
+ return {
+ researcherId: requiredString(raw.researcherId, "researcherId"),
+ displayName: requiredString(raw.displayName, "displayName"),
+ profileMode: requiredString(raw.profileMode || "private", "profileMode"),
+ orcid: raw.orcid ? String(raw.orcid) : "",
+ verifiedEmails: unique(raw.verifiedEmails || []).map((item) => item.toLowerCase()),
+ affiliations: array(raw.affiliations || [], "affiliations").map(normalizeAffiliation),
+ grants: array(raw.grants || [], "grants").map(normalizeGrant),
+ trustInputs: {
+ useAffiliationsForInvites: Boolean(raw.trustInputs && raw.trustInputs.useAffiliationsForInvites),
+ useGrantsForReputation: Boolean(raw.trustInputs && raw.trustInputs.useGrantsForReputation),
+ publicReputationBadge: Boolean(raw.trustInputs && raw.trustInputs.publicReputationBadge),
+ },
+ };
+}
+
+function finding(code, severity, sourceId, message, remediation) {
+ return { code, severity, sourceId, message, remediation };
+}
+
+function emailDomains(emails) {
+ return unique(emails.map((email) => email.split("@")[1]).filter(Boolean));
+}
+
+function isExpired(dateText, asOf) {
+ return dateText && Date.parse(dateText) < Date.parse(asOf);
+}
+
+function assessAffiliationAttestation(rawProfile, options = {}) {
+ const profile = normalizeProfile(rawProfile);
+ const asOf = normalizeDate(options.asOf || "2026-06-01", "asOf");
+ const findings = [];
+ const domains = emailDomains(profile.verifiedEmails);
+ const publicAffiliations = profile.affiliations.filter((item) => item.publicClaim);
+ const publicGrants = profile.grants.filter((item) => item.publicClaim);
+
+ if (profile.profileMode === "public" && !profile.orcid) {
+ findings.push(
+ finding(
+ "PUBLIC_PROFILE_WITHOUT_ORCID",
+ MEDIUM,
+ profile.researcherId,
+ `${profile.displayName} has a public profile without an ORCID identifier.`,
+ "Link ORCID or keep profile credentials private until identity evidence is complete."
+ )
+ );
+ }
+
+ for (const affiliation of publicAffiliations) {
+ if (!["orcid", "institutional-sso", "admin-attestation"].includes(affiliation.source)) {
+ findings.push(
+ finding(
+ "UNTRUSTED_AFFILIATION_SOURCE",
+ HIGH,
+ affiliation.id,
+ `${affiliation.institution} affiliation uses untrusted source ${affiliation.source || "none"}.`,
+ "Require ORCID, institutional SSO, or admin attestation before publishing affiliation claims."
+ )
+ );
+ }
+
+ if (isExpired(affiliation.validUntil, asOf)) {
+ findings.push(
+ finding(
+ "EXPIRED_PUBLIC_AFFILIATION",
+ HIGH,
+ affiliation.id,
+ `${affiliation.institution} affiliation expired on ${affiliation.validUntil}.`,
+ "Hide or refresh expired appointments before they influence invitations, grants, or reputation."
+ )
+ );
+ }
+
+ if (affiliation.verifiedDomain && !domains.includes(affiliation.verifiedDomain)) {
+ findings.push(
+ finding(
+ "AFFILIATION_DOMAIN_MISMATCH",
+ HIGH,
+ affiliation.id,
+ `${affiliation.institution} requires ${affiliation.verifiedDomain}, but no verified email matches that domain.`,
+ "Verify an institutional email domain or remove the public affiliation claim."
+ )
+ );
+ }
+
+ if (affiliation.evidenceRefs.length === 0) {
+ findings.push(
+ finding(
+ "AFFILIATION_WITHOUT_EVIDENCE",
+ MEDIUM,
+ affiliation.id,
+ `${affiliation.institution} public affiliation lacks evidence references.`,
+ "Attach ORCID records, SSO assertions, appointment receipts, or admin attestations."
+ )
+ );
+ }
+ }
+
+ const activeInstitutions = publicAffiliations
+ .filter((item) => !isExpired(item.validUntil, asOf))
+ .map((item) => item.institution);
+ if (unique(activeInstitutions).length > 1) {
+ findings.push(
+ finding(
+ "MULTIPLE_ACTIVE_PUBLIC_INSTITUTIONS",
+ MEDIUM,
+ "affiliations",
+ `Profile has multiple active public institutions: ${unique(activeInstitutions).join(", ")}.`,
+ "Require primary affiliation selection and conflict disclosure before institutional trust is applied."
+ )
+ );
+ }
+
+ for (const grant of publicGrants) {
+ if (grant.consent !== "public") {
+ findings.push(
+ finding(
+ "GRANT_WITHOUT_PUBLIC_CONSENT",
+ HIGH,
+ grant.id,
+ `${grant.funder} grant is public but consent is ${grant.consent || "missing"}.`,
+ "Collect funder/PI consent before exposing grant claims or using them in reputation scoring."
+ )
+ );
+ }
+
+ if (grant.evidenceRefs.length === 0) {
+ findings.push(
+ finding(
+ "GRANT_WITHOUT_EVIDENCE",
+ MEDIUM,
+ grant.id,
+ `${grant.funder} grant lacks evidence references.`,
+ "Attach award IDs, public grant pages, or institutional award receipts."
+ )
+ );
+ }
+ }
+
+ if (profile.trustInputs.useAffiliationsForInvites && findings.some((item) => item.code.includes("AFFILIATION"))) {
+ findings.push(
+ finding(
+ "UNVERIFIED_AFFILIATION_USED_FOR_INVITES",
+ HIGH,
+ "trustInputs.useAffiliationsForInvites",
+ "Affiliation claims have blockers but are enabled for project invitation trust.",
+ "Disable affiliation-derived invitations until all public affiliation findings are resolved."
+ )
+ );
+ }
+
+ if (profile.trustInputs.useGrantsForReputation && findings.some((item) => item.code.includes("GRANT"))) {
+ findings.push(
+ finding(
+ "UNVERIFIED_GRANT_USED_FOR_REPUTATION",
+ HIGH,
+ "trustInputs.useGrantsForReputation",
+ "Grant claims have blockers but are enabled for reputation scoring.",
+ "Disable grant-derived reputation inputs until grant consent and evidence are verified."
+ )
+ );
+ }
+
+ if (profile.trustInputs.publicReputationBadge && profile.profileMode !== "public") {
+ findings.push(
+ finding(
+ "REPUTATION_BADGE_ON_PRIVATE_PROFILE",
+ LOW,
+ "trustInputs.publicReputationBadge",
+ "A public reputation badge is enabled while the profile is private.",
+ "Align badge visibility with the profile mode or provide explicit public-badge consent."
+ )
+ );
+ }
+
+ const high = findings.filter((item) => item.severity === HIGH).length;
+ const medium = findings.filter((item) => item.severity === MEDIUM).length;
+ return {
+ researcherId: profile.researcherId,
+ displayName: profile.displayName,
+ profileMode: profile.profileMode,
+ decision: high > 0 ? "hold" : medium > 0 ? "revise" : "release",
+ summary: {
+ affiliationsReviewed: profile.affiliations.length,
+ publicAffiliations: publicAffiliations.length,
+ grantsReviewed: profile.grants.length,
+ publicGrants: publicGrants.length,
+ findings: findings.length,
+ high,
+ medium,
+ low: findings.filter((item) => item.severity === LOW).length,
+ },
+ findings,
+ releaseCriteria: [
+ "Public affiliations come from ORCID, institutional SSO, or admin attestation.",
+ "Expired appointments are hidden or refreshed before trust decisions.",
+ "Verified email domains match claimed institutional affiliations.",
+ "Public grant claims have consent and evidence references.",
+ "Unverified affiliations and grants do not feed invitations, trust, or reputation scoring.",
+ ],
+ };
+}
+
+module.exports = {
+ assessAffiliationAttestation,
+ normalizeProfile,
+};
diff --git a/researcher-affiliation-attestation-guard/package.json b/researcher-affiliation-attestation-guard/package.json
new file mode 100644
index 00000000..1acd89b9
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "researcher-affiliation-attestation-guard",
+ "version": "1.0.0",
+ "description": "Researcher affiliation and grant attestation guard for SCIBASE user/project management",
+ "main": "index.js",
+ "type": "commonjs",
+ "scripts": {
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "python demo_video.py"
+ },
+ "license": "MIT"
+}
diff --git a/researcher-affiliation-attestation-guard/reports/demo.gif b/researcher-affiliation-attestation-guard/reports/demo.gif
new file mode 100644
index 00000000..9c04ed69
Binary files /dev/null and b/researcher-affiliation-attestation-guard/reports/demo.gif differ
diff --git a/researcher-affiliation-attestation-guard/reports/demo.mp4 b/researcher-affiliation-attestation-guard/reports/demo.mp4
new file mode 100644
index 00000000..277035b2
Binary files /dev/null and b/researcher-affiliation-attestation-guard/reports/demo.mp4 differ
diff --git a/researcher-affiliation-attestation-guard/reports/risky-profile.json b/researcher-affiliation-attestation-guard/reports/risky-profile.json
new file mode 100644
index 00000000..bcdfa82b
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/reports/risky-profile.json
@@ -0,0 +1,109 @@
+{
+ "researcherId": "researcher-claim-risk-002",
+ "displayName": "R. Example",
+ "profileMode": "public",
+ "decision": "hold",
+ "summary": {
+ "affiliationsReviewed": 3,
+ "publicAffiliations": 3,
+ "grantsReviewed": 1,
+ "publicGrants": 1,
+ "findings": 12,
+ "high": 8,
+ "medium": 4,
+ "low": 0
+ },
+ "findings": [
+ {
+ "code": "PUBLIC_PROFILE_WITHOUT_ORCID",
+ "severity": "medium",
+ "sourceId": "researcher-claim-risk-002",
+ "message": "R. Example has a public profile without an ORCID identifier.",
+ "remediation": "Link ORCID or keep profile credentials private until identity evidence is complete."
+ },
+ {
+ "code": "UNTRUSTED_AFFILIATION_SOURCE",
+ "severity": "high",
+ "sourceId": "aff-expired",
+ "message": "Northbridge Institute affiliation uses untrusted source manual.",
+ "remediation": "Require ORCID, institutional SSO, or admin attestation before publishing affiliation claims."
+ },
+ {
+ "code": "EXPIRED_PUBLIC_AFFILIATION",
+ "severity": "high",
+ "sourceId": "aff-expired",
+ "message": "Northbridge Institute affiliation expired on 2024-12-31.",
+ "remediation": "Hide or refresh expired appointments before they influence invitations, grants, or reputation."
+ },
+ {
+ "code": "AFFILIATION_DOMAIN_MISMATCH",
+ "severity": "high",
+ "sourceId": "aff-expired",
+ "message": "Northbridge Institute requires northbridge.edu, but no verified email matches that domain.",
+ "remediation": "Verify an institutional email domain or remove the public affiliation claim."
+ },
+ {
+ "code": "AFFILIATION_WITHOUT_EVIDENCE",
+ "severity": "medium",
+ "sourceId": "aff-expired",
+ "message": "Northbridge Institute public affiliation lacks evidence references.",
+ "remediation": "Attach ORCID records, SSO assertions, appointment receipts, or admin attestations."
+ },
+ {
+ "code": "AFFILIATION_DOMAIN_MISMATCH",
+ "severity": "high",
+ "sourceId": "aff-active-conflict",
+ "message": "West River Labs requires westriver.example, but no verified email matches that domain.",
+ "remediation": "Verify an institutional email domain or remove the public affiliation claim."
+ },
+ {
+ "code": "AFFILIATION_DOMAIN_MISMATCH",
+ "severity": "high",
+ "sourceId": "aff-second-active",
+ "message": "East Valley University requires evu.example, but no verified email matches that domain.",
+ "remediation": "Verify an institutional email domain or remove the public affiliation claim."
+ },
+ {
+ "code": "MULTIPLE_ACTIVE_PUBLIC_INSTITUTIONS",
+ "severity": "medium",
+ "sourceId": "affiliations",
+ "message": "Profile has multiple active public institutions: West River Labs, East Valley University.",
+ "remediation": "Require primary affiliation selection and conflict disclosure before institutional trust is applied."
+ },
+ {
+ "code": "GRANT_WITHOUT_PUBLIC_CONSENT",
+ "severity": "high",
+ "sourceId": "grant-private-77",
+ "message": "Private Foundation grant is public but consent is private.",
+ "remediation": "Collect funder/PI consent before exposing grant claims or using them in reputation scoring."
+ },
+ {
+ "code": "GRANT_WITHOUT_EVIDENCE",
+ "severity": "medium",
+ "sourceId": "grant-private-77",
+ "message": "Private Foundation grant lacks evidence references.",
+ "remediation": "Attach award IDs, public grant pages, or institutional award receipts."
+ },
+ {
+ "code": "UNVERIFIED_AFFILIATION_USED_FOR_INVITES",
+ "severity": "high",
+ "sourceId": "trustInputs.useAffiliationsForInvites",
+ "message": "Affiliation claims have blockers but are enabled for project invitation trust.",
+ "remediation": "Disable affiliation-derived invitations until all public affiliation findings are resolved."
+ },
+ {
+ "code": "UNVERIFIED_GRANT_USED_FOR_REPUTATION",
+ "severity": "high",
+ "sourceId": "trustInputs.useGrantsForReputation",
+ "message": "Grant claims have blockers but are enabled for reputation scoring.",
+ "remediation": "Disable grant-derived reputation inputs until grant consent and evidence are verified."
+ }
+ ],
+ "releaseCriteria": [
+ "Public affiliations come from ORCID, institutional SSO, or admin attestation.",
+ "Expired appointments are hidden or refreshed before trust decisions.",
+ "Verified email domains match claimed institutional affiliations.",
+ "Public grant claims have consent and evidence references.",
+ "Unverified affiliations and grants do not feed invitations, trust, or reputation scoring."
+ ]
+}
\ No newline at end of file
diff --git a/researcher-affiliation-attestation-guard/reports/risky-profile.md b/researcher-affiliation-attestation-guard/reports/risky-profile.md
new file mode 100644
index 00000000..66ac86e9
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/reports/risky-profile.md
@@ -0,0 +1,32 @@
+# Researcher Affiliation Attestation Guard
+
+Scenario: risky-profile
+
+Researcher: R. Example
+Profile mode: public
+Decision: HOLD
+
+Reviewed 3 affiliations and 1 grants.
+
+## Findings
+
+- MEDIUM PUBLIC_PROFILE_WITHOUT_ORCID: R. Example has a public profile without an ORCID identifier.
+- HIGH UNTRUSTED_AFFILIATION_SOURCE: Northbridge Institute affiliation uses untrusted source manual.
+- HIGH EXPIRED_PUBLIC_AFFILIATION: Northbridge Institute affiliation expired on 2024-12-31.
+- HIGH AFFILIATION_DOMAIN_MISMATCH: Northbridge Institute requires northbridge.edu, but no verified email matches that domain.
+- MEDIUM AFFILIATION_WITHOUT_EVIDENCE: Northbridge Institute public affiliation lacks evidence references.
+- HIGH AFFILIATION_DOMAIN_MISMATCH: West River Labs requires westriver.example, but no verified email matches that domain.
+- HIGH AFFILIATION_DOMAIN_MISMATCH: East Valley University requires evu.example, but no verified email matches that domain.
+- MEDIUM MULTIPLE_ACTIVE_PUBLIC_INSTITUTIONS: Profile has multiple active public institutions: West River Labs, East Valley University.
+- HIGH GRANT_WITHOUT_PUBLIC_CONSENT: Private Foundation grant is public but consent is private.
+- MEDIUM GRANT_WITHOUT_EVIDENCE: Private Foundation grant lacks evidence references.
+- HIGH UNVERIFIED_AFFILIATION_USED_FOR_INVITES: Affiliation claims have blockers but are enabled for project invitation trust.
+- HIGH UNVERIFIED_GRANT_USED_FOR_REPUTATION: Grant claims have blockers but are enabled for reputation scoring.
+
+## Release Criteria
+
+- Public affiliations come from ORCID, institutional SSO, or admin attestation.
+- Expired appointments are hidden or refreshed before trust decisions.
+- Verified email domains match claimed institutional affiliations.
+- Public grant claims have consent and evidence references.
+- Unverified affiliations and grants do not feed invitations, trust, or reputation scoring.
diff --git a/researcher-affiliation-attestation-guard/reports/risky-profile.svg b/researcher-affiliation-attestation-guard/reports/risky-profile.svg
new file mode 100644
index 00000000..eac4e97a
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/reports/risky-profile.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/researcher-affiliation-attestation-guard/reports/trusted-profile.json b/researcher-affiliation-attestation-guard/reports/trusted-profile.json
new file mode 100644
index 00000000..1985a038
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/reports/trusted-profile.json
@@ -0,0 +1,24 @@
+{
+ "researcherId": "researcher-ada-001",
+ "displayName": "Ada Rivera",
+ "profileMode": "public",
+ "decision": "release",
+ "summary": {
+ "affiliationsReviewed": 1,
+ "publicAffiliations": 1,
+ "grantsReviewed": 1,
+ "publicGrants": 1,
+ "findings": 0,
+ "high": 0,
+ "medium": 0,
+ "low": 0
+ },
+ "findings": [],
+ "releaseCriteria": [
+ "Public affiliations come from ORCID, institutional SSO, or admin attestation.",
+ "Expired appointments are hidden or refreshed before trust decisions.",
+ "Verified email domains match claimed institutional affiliations.",
+ "Public grant claims have consent and evidence references.",
+ "Unverified affiliations and grants do not feed invitations, trust, or reputation scoring."
+ ]
+}
\ No newline at end of file
diff --git a/researcher-affiliation-attestation-guard/reports/trusted-profile.md b/researcher-affiliation-attestation-guard/reports/trusted-profile.md
new file mode 100644
index 00000000..2937f6f3
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/reports/trusted-profile.md
@@ -0,0 +1,21 @@
+# Researcher Affiliation Attestation Guard
+
+Scenario: trusted-profile
+
+Researcher: Ada Rivera
+Profile mode: public
+Decision: RELEASE
+
+Reviewed 1 affiliations and 1 grants.
+
+## Findings
+
+- No affiliation or grant attestation findings.
+
+## Release Criteria
+
+- Public affiliations come from ORCID, institutional SSO, or admin attestation.
+- Expired appointments are hidden or refreshed before trust decisions.
+- Verified email domains match claimed institutional affiliations.
+- Public grant claims have consent and evidence references.
+- Unverified affiliations and grants do not feed invitations, trust, or reputation scoring.
diff --git a/researcher-affiliation-attestation-guard/reports/trusted-profile.svg b/researcher-affiliation-attestation-guard/reports/trusted-profile.svg
new file mode 100644
index 00000000..96393710
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/reports/trusted-profile.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/researcher-affiliation-attestation-guard/requirements-map.md b/researcher-affiliation-attestation-guard/requirements-map.md
new file mode 100644
index 00000000..aeec9a73
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/requirements-map.md
@@ -0,0 +1,14 @@
+# Requirements Map
+
+Issue #11 asks for user and project management: authentication and identity, researcher profiles, project spaces, permissions, activity/reputation metrics, public/private profile modes, institutional login, ORCID sync, funding sources, and collaboration governance.
+
+This slice covers a focused profile-trust gate:
+
+- Authentication and identity: validates that public affiliation claims come from ORCID, institutional SSO, or admin attestation.
+- Researcher profiles: checks public affiliation and grant claims before they appear on a public profile.
+- Institutional login: verifies institutional email domains match claimed public affiliations.
+- Funding sources: requires public grant claims to carry consent and evidence references.
+- Reputation metrics: blocks unverified affiliations or grants from feeding invitation trust or reputation scoring.
+- Public/private modes: flags visibility mismatches between private profiles and public reputation badges.
+
+Out of scope by design: broad RBAC, project visibility transitions, object-level permissions, invitation MFA, lifecycle offboarding, data residency, profile publication disambiguation, and access denial appeals, because those are already covered by separate same-issue slices.
diff --git a/researcher-affiliation-attestation-guard/sample-data.js b/researcher-affiliation-attestation-guard/sample-data.js
new file mode 100644
index 00000000..83b2941c
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/sample-data.js
@@ -0,0 +1,98 @@
+const trustedProfile = {
+ researcherId: "researcher-ada-001",
+ displayName: "Ada Rivera",
+ profileMode: "public",
+ orcid: "0000-0002-1825-0097",
+ verifiedEmails: ["ada.rivera@unam.mx"],
+ affiliations: [
+ {
+ id: "aff-unam",
+ institution: "UNAM Climate Lab",
+ role: "Principal Investigator",
+ source: "institutional-sso",
+ verifiedDomain: "unam.mx",
+ validFrom: "2024-01-01",
+ validUntil: "2027-12-31",
+ publicClaim: true,
+ evidenceRefs: ["sso:unam:ada.rivera", "orcid:employment:001"],
+ },
+ ],
+ grants: [
+ {
+ id: "grant-conacyt-42",
+ funder: "CONACYT",
+ projectId: "project-climate-yield",
+ publicClaim: true,
+ consent: "public",
+ evidenceRefs: ["award:conacyt-42"],
+ },
+ ],
+ trustInputs: {
+ useAffiliationsForInvites: true,
+ useGrantsForReputation: true,
+ publicReputationBadge: true,
+ },
+};
+
+const riskyProfile = {
+ researcherId: "researcher-claim-risk-002",
+ displayName: "R. Example",
+ profileMode: "public",
+ orcid: "",
+ verifiedEmails: ["researcher@gmail.com"],
+ affiliations: [
+ {
+ id: "aff-expired",
+ institution: "Northbridge Institute",
+ role: "Visiting Fellow",
+ source: "manual",
+ verifiedDomain: "northbridge.edu",
+ validFrom: "2021-01-01",
+ validUntil: "2024-12-31",
+ publicClaim: true,
+ evidenceRefs: [],
+ },
+ {
+ id: "aff-active-conflict",
+ institution: "West River Labs",
+ role: "Advisor",
+ source: "admin-attestation",
+ verifiedDomain: "westriver.example",
+ validFrom: "2025-01-01",
+ validUntil: "2027-12-31",
+ publicClaim: true,
+ evidenceRefs: ["attestation:wr-2026"],
+ },
+ {
+ id: "aff-second-active",
+ institution: "East Valley University",
+ role: "Faculty",
+ source: "orcid",
+ verifiedDomain: "evu.example",
+ validFrom: "2025-01-01",
+ validUntil: "2027-12-31",
+ publicClaim: true,
+ evidenceRefs: ["orcid:employment:evu"],
+ },
+ ],
+ grants: [
+ {
+ id: "grant-private-77",
+ funder: "Private Foundation",
+ projectId: "project-sensitive-review",
+ publicClaim: true,
+ consent: "private",
+ evidenceRefs: [],
+ },
+ ],
+ trustInputs: {
+ useAffiliationsForInvites: true,
+ useGrantsForReputation: true,
+ publicReputationBadge: true,
+ },
+};
+
+module.exports = {
+ trustedProfile,
+ riskyProfile,
+};
diff --git a/researcher-affiliation-attestation-guard/test.js b/researcher-affiliation-attestation-guard/test.js
new file mode 100644
index 00000000..5ccacb56
--- /dev/null
+++ b/researcher-affiliation-attestation-guard/test.js
@@ -0,0 +1,41 @@
+const assert = require("assert");
+
+const { assessAffiliationAttestation, normalizeProfile } = require("./index");
+const { trustedProfile, riskyProfile } = require("./sample-data");
+
+const clean = assessAffiliationAttestation(trustedProfile, { asOf: "2026-06-01" });
+assert.strictEqual(clean.decision, "release");
+assert.strictEqual(clean.summary.findings, 0);
+assert.strictEqual(clean.summary.publicAffiliations, 1);
+
+const risky = assessAffiliationAttestation(riskyProfile, { asOf: "2026-06-01" });
+assert.strictEqual(risky.decision, "hold");
+for (const code of [
+ "PUBLIC_PROFILE_WITHOUT_ORCID",
+ "UNTRUSTED_AFFILIATION_SOURCE",
+ "EXPIRED_PUBLIC_AFFILIATION",
+ "AFFILIATION_DOMAIN_MISMATCH",
+ "AFFILIATION_WITHOUT_EVIDENCE",
+ "MULTIPLE_ACTIVE_PUBLIC_INSTITUTIONS",
+ "GRANT_WITHOUT_PUBLIC_CONSENT",
+ "GRANT_WITHOUT_EVIDENCE",
+ "UNVERIFIED_AFFILIATION_USED_FOR_INVITES",
+ "UNVERIFIED_GRANT_USED_FOR_REPUTATION",
+]) {
+ assert(risky.findings.some((finding) => finding.code === code), `missing ${code}`);
+}
+
+const reviseOnly = assessAffiliationAttestation({
+ ...trustedProfile,
+ affiliations: trustedProfile.affiliations.map((affiliation) => ({ ...affiliation, evidenceRefs: [] })),
+ trustInputs: { ...trustedProfile.trustInputs, useAffiliationsForInvites: false },
+});
+assert.strictEqual(reviseOnly.decision, "revise");
+assert(reviseOnly.findings.some((finding) => finding.code === "AFFILIATION_WITHOUT_EVIDENCE"));
+
+assert.throws(
+ () => normalizeProfile({ ...trustedProfile, researcherId: "" }),
+ /researcherId must be a non-empty string/
+);
+
+console.log("researcher affiliation attestation guard tests passed");