From 3e180ba3838e99bd0102f106dca31da62a60a379 Mon Sep 17 00:00:00 2001 From: Kyle Tree Date: Wed, 20 May 2026 04:03:01 -0700 Subject: [PATCH] Add enterprise dashboard attribution monitor --- README.md | 4 + .../README.md | 33 ++ .../dashboard-attribution-report.json | 290 ++++++++++++++++++ .../demo.js | 19 ++ .../demo.mp4 | Bin 0 -> 30909 bytes .../demo.svg | 1 + .../index.js | 290 ++++++++++++++++++ .../requirements-map.md | 21 ++ .../reviewer-packet.md | 18 ++ .../sample-data.js | 151 +++++++++ .../test.js | 58 ++++ 11 files changed, 885 insertions(+) create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/README.md create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/dashboard-attribution-report.json create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/demo.js create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/demo.mp4 create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/demo.svg create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/index.js create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/requirements-map.md create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/reviewer-packet.md create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/sample-data.js create mode 100644 enterprise-dashboard-attribution-anomaly-monitor/test.js diff --git a/README.md b/README.md index d338cf6..70155c4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Enterprise Tooling + +- `enterprise-dashboard-attribution-anomaly-monitor/` adds a self-contained #19 slice for admin dashboard attribution quality, service-account separation, visibility-boundary checks, and collaboration-inflation review. diff --git a/enterprise-dashboard-attribution-anomaly-monitor/README.md b/enterprise-dashboard-attribution-anomaly-monitor/README.md new file mode 100644 index 0000000..38193c0 --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/README.md @@ -0,0 +1,33 @@ +# Enterprise Dashboard Attribution Anomaly Monitor + +This module is a focused Enterprise Tooling slice for SCIBASE issue #19. It protects organization-wide admin dashboards from distorted productivity, collaboration, and ROI metrics before institutional leaders use those metrics for compliance or funding decisions. + +## What It Adds + +- Canonical researcher attribution across linked identities such as ORCID and GitHub accounts. +- Service-account activity separation so automation does not inflate researcher productivity. +- Private-project visibility boundary checks for institution-level admins. +- Usage-spike, duplicate-identity, service-credit, private-boundary, and cross-lab collaboration-inflation anomaly detection. +- Researcher and lab credit summaries for executive dashboards. +- Reviewer packets, JSON output, and SVG preview for admin review. + +## Why This Is Distinct + +Existing #19 submissions cover exports, webhooks, compliance evidence, identity provisioning, retention, data residency, SLA, lab inventory, secret rotation, quotas, API changes, connector certification, incident response, funder reporting, and AI model governance. This slice focuses specifically on admin dashboard attribution quality and metric integrity. + +## Run + +```bash +node enterprise-dashboard-attribution-anomaly-monitor/test.js +node enterprise-dashboard-attribution-anomaly-monitor/demo.js +``` + +The demo writes: + +- `enterprise-dashboard-attribution-anomaly-monitor/dashboard-attribution-report.json` +- `enterprise-dashboard-attribution-anomaly-monitor/reviewer-packet.md` +- `enterprise-dashboard-attribution-anomaly-monitor/demo.svg` + +## Decision Policy + +High-severity attribution anomalies hold the executive dashboard until resolved. Medium anomalies remain visible only with dashboard annotations and owner confirmation requests. diff --git a/enterprise-dashboard-attribution-anomaly-monitor/dashboard-attribution-report.json b/enterprise-dashboard-attribution-anomaly-monitor/dashboard-attribution-report.json new file mode 100644 index 0000000..d85d05c --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/dashboard-attribution-report.json @@ -0,0 +1,290 @@ +{ + "generatedAt": "2026-05-20T11:02:55.616Z", + "credits": [ + { + "eventId": "evt:001", + "projectId": "project:neuro-alpha", + "actorId": "user:amy-orcid", + "canonicalResearcherId": "researcher:amy-chen", + "type": "dataset_upload", + "timestamp": "2026-05-20T02:00:00Z", + "rawCredit": 16.8, + "dashboardCredit": 16.8, + "serviceAccount": false, + "duplicateAlias": true, + "privateBoundary": false, + "labId": "lab:neuro", + "collaborationPair": [ + "lab:neuro", + "lab:materials" + ] + }, + { + "eventId": "evt:002", + "projectId": "project:neuro-alpha", + "actorId": "user:amy-github", + "canonicalResearcherId": "researcher:amy-chen", + "type": "code_commit", + "timestamp": "2026-05-20T02:15:00Z", + "rawCredit": 13.2, + "dashboardCredit": 13.2, + "serviceAccount": false, + "duplicateAlias": true, + "privateBoundary": false, + "labId": "lab:neuro", + "collaborationPair": [ + "lab:neuro", + "lab:materials" + ] + }, + { + "eventId": "evt:003", + "projectId": "project:neuro-alpha", + "actorId": "user:sync-bot", + "canonicalResearcherId": "user:sync-bot", + "type": "ai_review_generated", + "timestamp": "2026-05-20T02:30:00Z", + "rawCredit": 30, + "dashboardCredit": 0, + "serviceAccount": true, + "duplicateAlias": false, + "privateBoundary": false, + "labId": "lab:neuro", + "collaborationPair": [ + "lab:neuro", + "lab:materials" + ] + }, + { + "eventId": "evt:004", + "projectId": "project:materials-sensor", + "actorId": "user:sync-bot", + "canonicalResearcherId": "user:sync-bot", + "type": "repository_export", + "timestamp": "2026-05-20T02:45:00Z", + "rawCredit": 36, + "dashboardCredit": 0, + "serviceAccount": true, + "duplicateAlias": false, + "privateBoundary": false, + "labId": "lab:materials", + "collaborationPair": [ + "lab:neuro", + "lab:materials" + ] + }, + { + "eventId": "evt:005", + "projectId": "project:materials-sensor", + "actorId": "user:ben", + "canonicalResearcherId": "user:ben", + "type": "peer_review_completed", + "timestamp": "2026-05-20T03:00:00Z", + "rawCredit": 40, + "dashboardCredit": 40, + "serviceAccount": false, + "duplicateAlias": false, + "privateBoundary": false, + "labId": "lab:materials", + "collaborationPair": [ + "lab:neuro", + "lab:materials" + ] + }, + { + "eventId": "evt:006", + "projectId": "project:neuro-alpha", + "actorId": "user:amy-orcid", + "canonicalResearcherId": "researcher:amy-chen", + "type": "manuscript_edit", + "timestamp": "2026-05-20T03:05:00Z", + "rawCredit": 32, + "dashboardCredit": 32, + "serviceAccount": false, + "duplicateAlias": true, + "privateBoundary": false, + "labId": "lab:neuro", + "collaborationPair": [ + "lab:neuro", + "lab:materials" + ] + }, + { + "eventId": "evt:007", + "projectId": "project:neuro-alpha", + "actorId": "user:amy-github", + "canonicalResearcherId": "researcher:amy-chen", + "type": "dataset_upload", + "timestamp": "2026-05-20T03:20:00Z", + "rawCredit": 49, + "dashboardCredit": 49, + "serviceAccount": false, + "duplicateAlias": true, + "privateBoundary": false, + "labId": "lab:neuro", + "collaborationPair": [ + "lab:neuro", + "lab:materials" + ] + }, + { + "eventId": "evt:008", + "projectId": "project:external-secret", + "actorId": "user:external", + "canonicalResearcherId": "user:external", + "type": "repository_export", + "timestamp": "2026-05-20T03:40:00Z", + "rawCredit": 18, + "dashboardCredit": 18, + "serviceAccount": false, + "duplicateAlias": false, + "privateBoundary": true, + "labId": "lab:external", + "collaborationPair": null + } + ], + "metrics": { + "researcherCredits": [ + { + "researcherId": "researcher:amy-chen", + "credit": 111 + }, + { + "researcherId": "user:ben", + "credit": 40 + }, + { + "researcherId": "user:sync-bot", + "credit": 0 + } + ], + "labCredits": [ + { + "labId": "lab:neuro", + "credit": 111 + }, + { + "labId": "lab:materials", + "credit": 40 + } + ] + }, + "anomalies": [ + { + "kind": "usage_spike", + "key": "researcher:amy-chen:2026-05-20", + "severity": "high", + "score": 111, + "reason": "dashboard credit 111 in one day exceeds institutional review threshold" + }, + { + "kind": "service_account_credit", + "severity": "high", + "score": 36, + "reason": "service account activity must not count as researcher productivity", + "eventIds": [ + "evt:004" + ], + "actorId": "user:sync-bot" + }, + { + "kind": "service_account_credit", + "severity": "high", + "score": 30, + "reason": "service account activity must not count as researcher productivity", + "eventIds": [ + "evt:003" + ], + "actorId": "user:sync-bot" + }, + { + "kind": "private_project_boundary", + "severity": "high", + "score": 18, + "reason": "admin dashboard row references a project outside visibility boundary", + "eventIds": [ + "evt:008" + ], + "projectId": "project:external-secret" + }, + { + "kind": "duplicate_identity_credit", + "canonicalResearcherId": "researcher:amy-chen", + "severity": "medium", + "score": 111, + "reason": "multiple linked identities generated dashboard credit under one researcher", + "eventIds": [ + "evt:001", + "evt:002", + "evt:006", + "evt:007" + ] + }, + { + "kind": "collaboration_inflation", + "severity": "medium", + "score": 60, + "reason": "same cross-lab collaboration pair appears repeatedly in a short window", + "pair": [ + "lab:materials", + "lab:neuro" + ], + "eventIds": [ + "evt:001", + "evt:002", + "evt:005", + "evt:006", + "evt:007" + ] + } + ], + "reviewPacket": { + "holdExecutiveDashboard": true, + "ownerActions": [ + { + "kind": "usage_spike", + "severity": "high", + "action": "remove_from_executive_dashboard_until_resolved", + "reason": "dashboard credit 111 in one day exceeds institutional review threshold" + }, + { + "kind": "service_account_credit", + "severity": "high", + "action": "remove_from_executive_dashboard_until_resolved", + "reason": "service account activity must not count as researcher productivity" + }, + { + "kind": "service_account_credit", + "severity": "high", + "action": "remove_from_executive_dashboard_until_resolved", + "reason": "service account activity must not count as researcher productivity" + }, + { + "kind": "private_project_boundary", + "severity": "high", + "action": "remove_from_executive_dashboard_until_resolved", + "reason": "admin dashboard row references a project outside visibility boundary" + }, + { + "kind": "duplicate_identity_credit", + "severity": "medium", + "action": "annotate_dashboard_metric_and_request_owner_confirmation", + "reason": "multiple linked identities generated dashboard credit under one researcher" + }, + { + "kind": "collaboration_inflation", + "severity": "medium", + "action": "annotate_dashboard_metric_and_request_owner_confirmation", + "reason": "same cross-lab collaboration pair appears repeatedly in a short window" + } + ] + }, + "stats": { + "eventCount": 8, + "creditedResearcherCount": 2, + "anomalyCount": 6, + "highSeverityCount": 4, + "serviceAccountEvents": 2, + "hiddenBoundaryEvents": 1 + } +} diff --git a/enterprise-dashboard-attribution-anomaly-monitor/demo.js b/enterprise-dashboard-attribution-anomaly-monitor/demo.js new file mode 100644 index 0000000..489942e --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/demo.js @@ -0,0 +1,19 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + buildDashboardAttributionMonitor, + buildReviewerMarkdown, + renderDashboardSvg +} = require("./index"); +const sampleData = require("./sample-data"); + +const report = buildDashboardAttributionMonitor(sampleData); +const outDir = __dirname; + +fs.writeFileSync(path.join(outDir, "dashboard-attribution-report.json"), `${JSON.stringify(report, null, 2)}\n`); +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), buildReviewerMarkdown(report)); +fs.writeFileSync(path.join(outDir, "demo.svg"), renderDashboardSvg(report)); + +console.log(JSON.stringify(report, null, 2)); diff --git a/enterprise-dashboard-attribution-anomaly-monitor/demo.mp4 b/enterprise-dashboard-attribution-anomaly-monitor/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1272bab0a9b801d5ce256a31805c5050cda5c993 GIT binary patch literal 30909 zcmb5V1z225w*K9?1`Y1+?(Q1g-QC?GxNCwF+#wL$-Q9z`1_@T3RrP-8-(C#>00_-oJRK~Z?QH=7P{6NO;2*Q0n=zBE11l2%0Dv}gGBpJN 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 z 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 E07?DT&Hw-a literal 0 HcmV?d00001 diff --git a/enterprise-dashboard-attribution-anomaly-monitor/demo.svg b/enterprise-dashboard-attribution-anomaly-monitor/demo.svg new file mode 100644 index 0000000..d8a67f3 --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/demo.svg @@ -0,0 +1 @@ +Enterprise Dashboard Attribution MonitorProtects executive metrics from duplicate identity, service-account, collaboration, and visibility-boundary inflation.usage_spikedashboard credit 111 in one day exceeds institutional review thresholdhighservice_account_creditservice account activity must not count as researcher productivityhighservice_account_creditservice account activity must not count as researcher productivityhighprivate_project_boundaryadmin dashboard row references a project outside visibility boundaryhighduplicate_identity_creditmultiple linked identities generated dashboard credit under one researchermediumcollaboration_inflationsame cross-lab collaboration pair appears repeatedly in a short windowmedium \ No newline at end of file diff --git a/enterprise-dashboard-attribution-anomaly-monitor/index.js b/enterprise-dashboard-attribution-anomaly-monitor/index.js new file mode 100644 index 0000000..542d8b7 --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/index.js @@ -0,0 +1,290 @@ +"use strict"; + +const EVENT_WEIGHTS = Object.freeze({ + manuscript_edit: 8, + dataset_upload: 14, + code_commit: 12, + ai_review_generated: 10, + peer_review_completed: 16, + repository_export: 18, + login: 4, + service_sync: 2 +}); + +function assertArray(value, name) { + if (!Array.isArray(value)) throw new TypeError(`${name} must be an array`); +} + +function round(value, digits = 2) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function hoursBetween(left, right) { + return Math.abs(new Date(right).getTime() - new Date(left).getTime()) / 36e5; +} + +function indexById(items) { + return new Map(items.map((item) => [item.id, item])); +} + +function visibleToAdmin(project, admin) { + if (project.visibility === "public") return true; + if (project.institutionId !== admin.institutionId) return false; + if (project.visibility === "institutional-only") return true; + return (admin.allowedPrivateProjectIds || []).includes(project.id); +} + +function canonicalResearcherId(actor, identityLinks) { + if (!actor) return null; + const link = identityLinks.find((item) => item.userIds.includes(actor.id)); + return link?.canonicalId || actor.id; +} + +function eventCredit(event, context) { + const actor = context.users.get(event.actorId); + const project = context.projects.get(event.projectId); + const canonicalId = canonicalResearcherId(actor, context.identityLinks); + const base = EVENT_WEIGHTS[event.type] || 5; + const serviceAccount = actor?.type === "service"; + const privateBoundary = !visibleToAdmin(project, context.admin); + const duplicateAlias = actor?.id !== canonicalId; + const score = serviceAccount ? 0 : base * Number(event.creditMultiplier ?? 1); + + return { + eventId: event.id, + projectId: event.projectId, + actorId: event.actorId, + canonicalResearcherId: canonicalId, + type: event.type, + timestamp: event.timestamp, + rawCredit: round(base * Number(event.creditMultiplier ?? 1)), + dashboardCredit: round(score), + serviceAccount, + duplicateAlias, + privateBoundary, + labId: project?.labId, + collaborationPair: event.collaborationPair || null + }; +} + +function detectUsageSpike(credits) { + const byActorDay = new Map(); + for (const credit of credits) { + const day = credit.timestamp.slice(0, 10); + const key = `${credit.canonicalResearcherId}:${day}`; + byActorDay.set(key, (byActorDay.get(key) || 0) + credit.dashboardCredit); + } + return [...byActorDay.entries()] + .filter(([, value]) => value >= 70) + .map(([key, value]) => ({ + kind: "usage_spike", + key, + severity: value >= 100 ? "high" : "medium", + score: round(value), + reason: `dashboard credit ${round(value)} in one day exceeds institutional review threshold` + })); +} + +function detectDuplicateInflation(credits) { + const aliasEvents = credits.filter((credit) => credit.duplicateAlias && credit.dashboardCredit > 0); + const byCanonical = new Map(); + for (const credit of aliasEvents) { + if (!byCanonical.has(credit.canonicalResearcherId)) byCanonical.set(credit.canonicalResearcherId, []); + byCanonical.get(credit.canonicalResearcherId).push(credit); + } + return [...byCanonical.entries()] + .filter(([, items]) => new Set(items.map((item) => item.actorId)).size > 1) + .map(([canonicalResearcherId, items]) => ({ + kind: "duplicate_identity_credit", + canonicalResearcherId, + severity: "medium", + score: round(items.reduce((sum, item) => sum + item.dashboardCredit, 0)), + reason: "multiple linked identities generated dashboard credit under one researcher", + eventIds: items.map((item) => item.eventId) + })); +} + +function detectServiceAccountCredit(credits) { + const serviceCredits = credits.filter((credit) => credit.serviceAccount && credit.rawCredit > 0); + return serviceCredits.map((credit) => ({ + kind: "service_account_credit", + severity: "high", + score: credit.rawCredit, + reason: "service account activity must not count as researcher productivity", + eventIds: [credit.eventId], + actorId: credit.actorId + })); +} + +function detectPrivateBoundaryLeak(credits) { + return credits + .filter((credit) => credit.privateBoundary) + .map((credit) => ({ + kind: "private_project_boundary", + severity: "high", + score: credit.rawCredit, + reason: "admin dashboard row references a project outside visibility boundary", + eventIds: [credit.eventId], + projectId: credit.projectId + })); +} + +function detectCollaborationInflation(credits) { + const pairs = new Map(); + for (const credit of credits) { + if (!credit.collaborationPair || credit.serviceAccount) continue; + const key = credit.collaborationPair.slice().sort().join("::"); + if (!pairs.has(key)) pairs.set(key, []); + pairs.get(key).push(credit); + } + return [...pairs.entries()] + .filter(([, items]) => items.length >= 4 && items.some((item, index) => index > 0 && hoursBetween(items[0].timestamp, item.timestamp) <= 3)) + .map(([key, items]) => ({ + kind: "collaboration_inflation", + severity: "medium", + score: items.length * 12, + reason: "same cross-lab collaboration pair appears repeatedly in a short window", + pair: key.split("::"), + eventIds: items.map((item) => item.eventId) + })); +} + +function buildMetrics(credits) { + const byResearcher = new Map(); + const byLab = new Map(); + for (const credit of credits) { + if (credit.privateBoundary) continue; + byResearcher.set( + credit.canonicalResearcherId, + round((byResearcher.get(credit.canonicalResearcherId) || 0) + credit.dashboardCredit) + ); + if (credit.labId) byLab.set(credit.labId, round((byLab.get(credit.labId) || 0) + credit.dashboardCredit)); + } + return { + researcherCredits: [...byResearcher.entries()] + .map(([researcherId, credit]) => ({ researcherId, credit })) + .sort((a, b) => b.credit - a.credit), + labCredits: [...byLab.entries()] + .map(([labId, credit]) => ({ labId, credit })) + .sort((a, b) => b.credit - a.credit) + }; +} + +function buildDashboardAttributionMonitor(input) { + const data = input || {}; + assertArray(data.users, "users"); + assertArray(data.projects, "projects"); + assertArray(data.events, "events"); + const context = { + users: indexById(data.users), + projects: indexById(data.projects), + identityLinks: data.identityLinks || [], + admin: data.admin || {} + }; + const credits = data.events.map((event) => eventCredit(event, context)); + const anomalies = [ + ...detectUsageSpike(credits), + ...detectDuplicateInflation(credits), + ...detectServiceAccountCredit(credits), + ...detectPrivateBoundaryLeak(credits), + ...detectCollaborationInflation(credits) + ].sort((a, b) => { + const rank = { high: 3, medium: 2, low: 1 }; + return (rank[b.severity] || 0) - (rank[a.severity] || 0) || b.score - a.score; + }); + const metrics = buildMetrics(credits); + const reviewPacket = { + holdExecutiveDashboard: anomalies.some((item) => item.severity === "high"), + ownerActions: anomalies.map((item) => ({ + kind: item.kind, + severity: item.severity, + action: + item.severity === "high" + ? "remove_from_executive_dashboard_until_resolved" + : "annotate_dashboard_metric_and_request_owner_confirmation", + reason: item.reason + })) + }; + + return { + generatedAt: new Date().toISOString(), + credits, + metrics, + anomalies, + reviewPacket, + stats: { + eventCount: data.events.length, + creditedResearcherCount: metrics.researcherCredits.filter((item) => item.credit > 0).length, + anomalyCount: anomalies.length, + highSeverityCount: anomalies.filter((item) => item.severity === "high").length, + serviceAccountEvents: credits.filter((item) => item.serviceAccount).length, + hiddenBoundaryEvents: credits.filter((item) => item.privateBoundary).length + } + }; +} + +function buildReviewerMarkdown(report) { + const lines = ["# Enterprise Dashboard Attribution Review", ""]; + lines.push(`Events reviewed: ${report.stats.eventCount}`); + lines.push(`Anomalies: ${report.stats.anomalyCount}; high severity: ${report.stats.highSeverityCount}`); + lines.push(`Executive dashboard hold: ${report.reviewPacket.holdExecutiveDashboard ? "yes" : "no"}`); + lines.push(""); + lines.push("## Top Researcher Credits"); + for (const item of report.metrics.researcherCredits.slice(0, 6)) { + lines.push(`- ${item.researcherId}: ${item.credit}`); + } + lines.push(""); + lines.push("## Anomalies"); + for (const item of report.anomalies) { + lines.push(`- ${item.severity.toUpperCase()} ${item.kind}: ${item.reason}`); + } + return `${lines.join("\n")}\n`; +} + +function renderDashboardSvg(report) { + const width = 940; + const rowHeight = 78; + const rows = report.anomalies.slice(0, 6); + const height = 128 + rows.length * rowHeight; + const body = rows + .map((item, index) => { + const y = 88 + index * rowHeight; + const color = item.severity === "high" ? "#be123c" : "#b45309"; + return [ + ``, + ``, + `${escapeXml(item.kind)}`, + `${escapeXml(item.reason)}`, + ``, + `${escapeXml(item.severity)}`, + `` + ].join(""); + }) + .join(""); + return [ + ``, + ``, + `Enterprise Dashboard Attribution Monitor`, + `Protects executive metrics from duplicate identity, service-account, collaboration, and visibility-boundary inflation.`, + body, + `` + ].join(""); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +module.exports = { + EVENT_WEIGHTS, + buildDashboardAttributionMonitor, + buildReviewerMarkdown, + eventCredit, + renderDashboardSvg, + visibleToAdmin +}; diff --git a/enterprise-dashboard-attribution-anomaly-monitor/requirements-map.md b/enterprise-dashboard-attribution-anomaly-monitor/requirements-map.md new file mode 100644 index 0000000..a304035 --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/requirements-map.md @@ -0,0 +1,21 @@ +# Requirements Map + +## Issue #19: Enterprise Tooling + +| Requirement | Coverage | +| --- | --- | +| Organization-wide admin dashboards | `buildDashboardAttributionMonitor` produces admin-facing metrics and review packets. | +| Contributor analytics and top researchers | `buildMetrics` rolls events into canonical researcher credits. | +| Cross-lab collaborations | `detectCollaborationInflation` flags repeated collaboration-pair inflation. | +| Usage stats and productivity metrics | Event weights cover manuscript edits, dataset uploads, code commits, AI reviews, peer reviews, exports, and logins. | +| Compliance tracking and visibility boundaries | `visibleToAdmin` and private-boundary anomalies keep restricted/private projects out of unauthorized dashboards. | +| ORCID/HRIS-style identity alignment | `canonicalResearcherId` uses identity links to de-duplicate ORCID/GitHub-style aliases. | +| Automation separation | `detectServiceAccountCredit` prevents service accounts from inflating researcher credit. | +| Tests and demo | `test.js` covers duplicate identity credit, service account credit removal, private-boundary leaks, collaboration inflation, usage spikes, reviewer Markdown, and visibility checks. `demo.js` emits JSON, Markdown, and SVG artifacts. | + +## Acceptance Notes + +- Synthetic data only; no external service calls or private data. +- No dependencies beyond the Node.js standard library. +- This is dashboard metric integrity, not another export, webhook, identity provisioning, quota, incident, or AI governance module. +- High-severity anomalies hold executive dashboards rather than silently adjusting numbers. diff --git a/enterprise-dashboard-attribution-anomaly-monitor/reviewer-packet.md b/enterprise-dashboard-attribution-anomaly-monitor/reviewer-packet.md new file mode 100644 index 0000000..370e08f --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/reviewer-packet.md @@ -0,0 +1,18 @@ +# Enterprise Dashboard Attribution Review + +Events reviewed: 8 +Anomalies: 6; high severity: 4 +Executive dashboard hold: yes + +## Top Researcher Credits +- researcher:amy-chen: 111 +- user:ben: 40 +- user:sync-bot: 0 + +## Anomalies +- HIGH usage_spike: dashboard credit 111 in one day exceeds institutional review threshold +- HIGH service_account_credit: service account activity must not count as researcher productivity +- HIGH service_account_credit: service account activity must not count as researcher productivity +- HIGH private_project_boundary: admin dashboard row references a project outside visibility boundary +- MEDIUM duplicate_identity_credit: multiple linked identities generated dashboard credit under one researcher +- MEDIUM collaboration_inflation: same cross-lab collaboration pair appears repeatedly in a short window diff --git a/enterprise-dashboard-attribution-anomaly-monitor/sample-data.js b/enterprise-dashboard-attribution-anomaly-monitor/sample-data.js new file mode 100644 index 0000000..b7415d9 --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/sample-data.js @@ -0,0 +1,151 @@ +"use strict"; + +module.exports = { + admin: { + id: "admin:research-office", + institutionId: "inst:western", + allowedPrivateProjectIds: ["project:neuro-alpha"] + }, + users: [ + { + id: "user:amy-orcid", + name: "Amy Chen", + type: "researcher", + institutionId: "inst:western", + labId: "lab:neuro", + orcid: "0000-0001-0000-0001" + }, + { + id: "user:amy-github", + name: "Amy C.", + type: "researcher", + institutionId: "inst:western", + labId: "lab:neuro", + github: "amy-research" + }, + { + id: "user:ben", + name: "Ben Patel", + type: "researcher", + institutionId: "inst:western", + labId: "lab:materials", + orcid: "0000-0002-0000-0002" + }, + { + id: "user:sync-bot", + name: "Repository Sync Bot", + type: "service", + institutionId: "inst:western", + labId: "lab:platform" + }, + { + id: "user:external", + name: "External Reviewer", + type: "researcher", + institutionId: "inst:eastern", + labId: "lab:external" + } + ], + identityLinks: [ + { + canonicalId: "researcher:amy-chen", + userIds: ["user:amy-orcid", "user:amy-github"] + } + ], + projects: [ + { + id: "project:neuro-alpha", + title: "Neuro Alpha", + institutionId: "inst:western", + labId: "lab:neuro", + visibility: "private" + }, + { + id: "project:materials-sensor", + title: "Materials Sensor", + institutionId: "inst:western", + labId: "lab:materials", + visibility: "institutional-only" + }, + { + id: "project:external-secret", + title: "External Private Study", + institutionId: "inst:eastern", + labId: "lab:external", + visibility: "private" + } + ], + events: [ + { + id: "evt:001", + type: "dataset_upload", + timestamp: "2026-05-20T02:00:00Z", + actorId: "user:amy-orcid", + projectId: "project:neuro-alpha", + creditMultiplier: 1.2, + collaborationPair: ["lab:neuro", "lab:materials"] + }, + { + id: "evt:002", + type: "code_commit", + timestamp: "2026-05-20T02:15:00Z", + actorId: "user:amy-github", + projectId: "project:neuro-alpha", + creditMultiplier: 1.1, + collaborationPair: ["lab:neuro", "lab:materials"] + }, + { + id: "evt:003", + type: "ai_review_generated", + timestamp: "2026-05-20T02:30:00Z", + actorId: "user:sync-bot", + projectId: "project:neuro-alpha", + creditMultiplier: 3, + collaborationPair: ["lab:neuro", "lab:materials"] + }, + { + id: "evt:004", + type: "repository_export", + timestamp: "2026-05-20T02:45:00Z", + actorId: "user:sync-bot", + projectId: "project:materials-sensor", + creditMultiplier: 2, + collaborationPair: ["lab:neuro", "lab:materials"] + }, + { + id: "evt:005", + type: "peer_review_completed", + timestamp: "2026-05-20T03:00:00Z", + actorId: "user:ben", + projectId: "project:materials-sensor", + creditMultiplier: 2.5, + collaborationPair: ["lab:neuro", "lab:materials"] + }, + { + id: "evt:006", + type: "manuscript_edit", + timestamp: "2026-05-20T03:05:00Z", + actorId: "user:amy-orcid", + projectId: "project:neuro-alpha", + creditMultiplier: 4, + collaborationPair: ["lab:neuro", "lab:materials"] + }, + { + id: "evt:007", + type: "dataset_upload", + timestamp: "2026-05-20T03:20:00Z", + actorId: "user:amy-github", + projectId: "project:neuro-alpha", + creditMultiplier: 3.5, + collaborationPair: ["lab:neuro", "lab:materials"] + }, + { + id: "evt:008", + type: "repository_export", + timestamp: "2026-05-20T03:40:00Z", + actorId: "user:external", + projectId: "project:external-secret", + creditMultiplier: 1 + } + ] +}; diff --git a/enterprise-dashboard-attribution-anomaly-monitor/test.js b/enterprise-dashboard-attribution-anomaly-monitor/test.js new file mode 100644 index 0000000..de10b0a --- /dev/null +++ b/enterprise-dashboard-attribution-anomaly-monitor/test.js @@ -0,0 +1,58 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + buildDashboardAttributionMonitor, + buildReviewerMarkdown, + eventCredit, + visibleToAdmin +} = require("./index"); +const sampleData = require("./sample-data"); + +const report = buildDashboardAttributionMonitor(sampleData); + +assert.equal(report.stats.eventCount, 8); +assert.ok(report.stats.anomalyCount >= 5); +assert.ok(report.stats.highSeverityCount >= 2); +assert.equal(report.stats.serviceAccountEvents, 2); +assert.equal(report.stats.hiddenBoundaryEvents, 1); +assert.equal(report.reviewPacket.holdExecutiveDashboard, true); + +const kinds = new Set(report.anomalies.map((item) => item.kind)); +assert.ok(kinds.has("service_account_credit")); +assert.ok(kinds.has("private_project_boundary")); +assert.ok(kinds.has("duplicate_identity_credit")); +assert.ok(kinds.has("collaboration_inflation")); +assert.ok(kinds.has("usage_spike")); + +const amy = report.metrics.researcherCredits.find((item) => item.researcherId === "researcher:amy-chen"); +assert.ok(amy.credit > 100); + +const serviceCredit = report.credits.find((item) => item.actorId === "user:sync-bot"); +assert.equal(serviceCredit.dashboardCredit, 0); +assert.equal(serviceCredit.serviceAccount, true); + +const hiddenCredit = report.credits.find((item) => item.eventId === "evt:008"); +assert.equal(hiddenCredit.privateBoundary, true); + +const users = new Map(sampleData.users.map((user) => [user.id, user])); +const projects = new Map(sampleData.projects.map((project) => [project.id, project])); +const admin = sampleData.admin; +assert.equal(visibleToAdmin(projects.get("project:neuro-alpha"), admin), true); +assert.equal(visibleToAdmin(projects.get("project:external-secret"), admin), false); + +const credit = eventCredit(sampleData.events[1], { + users, + projects, + identityLinks: sampleData.identityLinks, + admin +}); +assert.equal(credit.canonicalResearcherId, "researcher:amy-chen"); +assert.equal(credit.duplicateAlias, true); + +const markdown = buildReviewerMarkdown(report); +assert.ok(markdown.includes("Enterprise Dashboard Attribution Review")); +assert.ok(markdown.includes("Executive dashboard hold: yes")); +assert.ok(markdown.includes("service_account_credit")); + +console.log("enterprise-dashboard-attribution-anomaly-monitor tests passed");