diff --git a/README.md b/README.md
index d338cf6..0a98157 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,6 @@
# deepevents.ai
deepevents.ai main codebase
+
+## Bounty modules
+
+- [FAIR Artifact Access Gate](fair-artifact-access-gate/README.md) - validates scientific data and code hosting readiness for SCIBASE issue #14.
diff --git a/fair-artifact-access-gate/README.md b/fair-artifact-access-gate/README.md
new file mode 100644
index 0000000..c405532
--- /dev/null
+++ b/fair-artifact-access-gate/README.md
@@ -0,0 +1,28 @@
+# FAIR Artifact Access Gate
+
+This module is a focused implementation slice for SCIBASE issue #14, "Scientific/Engineering Data & Code Hosting." It models the pre-publication gate a hosted scientific project should pass before SCIBASE exposes datasets, notebooks, source code, figures, raw instrument outputs, and model files to collaborators, reviewers, or the public.
+
+The gate answers four practical hosting questions:
+
+- Does the project expose machine-readable metadata through JSON-LD, DataCite, and schema.org?
+- Are scientific artifacts cataloged by family, access policy, preview capability, versioning, and deterministic hashes?
+- Do the hosted materials satisfy FAIR signals for findability, accessibility, interoperability, and reuse?
+- Can SCIBASE produce a reviewer export packet that includes metadata, artifact links, access-policy evidence, and checksums?
+
+## Files
+
+- `src/fair-artifact-access-gate.js` contains the deterministic assessment logic.
+- `data/sample-artifacts.json` provides a realistic mixed scientific artifact set.
+- `test/fair-artifact-access-gate.test.js` covers success and failure paths.
+- `scripts/demo.js` prints a concise readiness report for the sample project.
+- `docs/demo.svg` and `docs/demo.mp4` show the intended product surface.
+
+## Run
+
+```sh
+npm test
+npm run demo
+npm run demo:video
+```
+
+The implementation is intentionally dependency-free so it can be reviewed without installing a framework or service runtime.
diff --git a/fair-artifact-access-gate/data/sample-artifacts.json b/fair-artifact-access-gate/data/sample-artifacts.json
new file mode 100644
index 0000000..01d3d32
--- /dev/null
+++ b/fair-artifact-access-gate/data/sample-artifacts.json
@@ -0,0 +1,107 @@
+{
+ "projectId": "lab-astrocyte-calcium-atlas",
+ "title": "Astrocyte Calcium Imaging Atlas",
+ "doi": "10.5555/scibase.astrocyte-calcium.2026.v2",
+ "version": "2.1.0",
+ "license": "CC-BY-4.0",
+ "indexed": true,
+ "publisher": "SCIBASE Demo Repository",
+ "persistentBaseUrl": "https://scibase.ai/artifacts/lab-astrocyte-calcium-atlas",
+ "metadata": {
+ "jsonLd": {
+ "@context": "https://schema.org",
+ "@type": "Dataset",
+ "name": "Astrocyte Calcium Imaging Atlas",
+ "identifier": "10.5555/scibase.astrocyte-calcium.2026.v2"
+ },
+ "dataCite": {
+ "identifier": "10.5555/scibase.astrocyte-calcium.2026.v2",
+ "creators": ["SCIBASE Demo Lab"],
+ "publisher": "SCIBASE Demo Repository",
+ "publicationYear": 2026,
+ "resourceType": "Dataset"
+ },
+ "schemaOrg": {
+ "name": "Astrocyte Calcium Imaging Atlas",
+ "description": "Processed calcium imaging events, notebooks, source code, model weights, and raw instrument outputs.",
+ "license": "https://creativecommons.org/licenses/by/4.0/",
+ "distribution": "https://scibase.ai/artifacts/lab-astrocyte-calcium-atlas"
+ }
+ },
+ "tags": {
+ "keywords": ["calcium imaging", "astrocyte", "time series", "neuroscience"],
+ "instruments": ["two-photon microscope", "scientific CMOS camera"],
+ "organisms": ["Mus musculus"],
+ "variables": ["event amplitude", "event duration", "field of view", "stimulus epoch"]
+ },
+ "artifacts": [
+ {
+ "path": "data/processed/calcium-events.parquet",
+ "family": "dataset",
+ "format": "parquet",
+ "access": "public",
+ "preview": "table",
+ "versioned": true,
+ "diffable": true,
+ "machineReadable": true,
+ "sizeBytes": 9420800
+ },
+ {
+ "path": "code/notebooks/event-detection.ipynb",
+ "family": "notebook",
+ "format": "ipynb",
+ "access": "public",
+ "preview": "notebook",
+ "versioned": true,
+ "diffable": true,
+ "machineReadable": true,
+ "sizeBytes": 184220
+ },
+ {
+ "path": "code/src/extract_events.py",
+ "family": "code",
+ "format": "python",
+ "access": "public",
+ "preview": "syntax-highlight",
+ "versioned": true,
+ "diffable": true,
+ "machineReadable": true,
+ "sizeBytes": 38291
+ },
+ {
+ "path": "supplementary/figures/field-of-view.png",
+ "family": "figure",
+ "format": "png",
+ "access": "public",
+ "preview": "image",
+ "versioned": true,
+ "diffable": false,
+ "machineReadable": false,
+ "sizeBytes": 308204
+ },
+ {
+ "path": "raw/instrument/session-0423.tiff",
+ "family": "raw-instrument-output",
+ "format": "tiff",
+ "access": "restricted",
+ "restrictionReason": "contains pre-release animal cohort metadata",
+ "reviewerAccess": true,
+ "preview": "thumbnail",
+ "versioned": true,
+ "diffable": false,
+ "machineReadable": false,
+ "sizeBytes": 182002284
+ },
+ {
+ "path": "models/event-classifier.onnx",
+ "family": "model",
+ "format": "onnx",
+ "access": "public",
+ "preview": "model-card",
+ "versioned": true,
+ "diffable": false,
+ "machineReadable": true,
+ "sizeBytes": 14082112
+ }
+ ]
+}
diff --git a/fair-artifact-access-gate/docs/demo.mp4 b/fair-artifact-access-gate/docs/demo.mp4
new file mode 100644
index 0000000..bd8b5d6
Binary files /dev/null and b/fair-artifact-access-gate/docs/demo.mp4 differ
diff --git a/fair-artifact-access-gate/docs/demo.svg b/fair-artifact-access-gate/docs/demo.svg
new file mode 100644
index 0000000..05bf43b
--- /dev/null
+++ b/fair-artifact-access-gate/docs/demo.svg
@@ -0,0 +1,33 @@
+
diff --git a/fair-artifact-access-gate/docs/requirement-map.md b/fair-artifact-access-gate/docs/requirement-map.md
new file mode 100644
index 0000000..7026d8f
--- /dev/null
+++ b/fair-artifact-access-gate/docs/requirement-map.md
@@ -0,0 +1,13 @@
+# Requirement Map
+
+Issue #14 asks for a platform to host scientific and engineering data plus code. This implementation focuses on the acceptance gate that decides whether a hosted project is ready to expose those artifacts.
+
+| Issue need | Implementation surface |
+| --- | --- |
+| Store datasets, code, figures, notebooks, raw outputs, and model files | `buildArtifactCatalog` normalizes each artifact family with persistent links, access policy, preview state, versioning, and hashes. |
+| Support scientific discovery and citation | `buildMetadataStandardsIndex` checks JSON-LD, DataCite, and schema.org metadata. |
+| Keep restricted materials reviewable | `evaluateFairSignals` blocks restricted artifacts without reviewer access and a restriction reason. |
+| Produce evidence for maintainers, reviewers, and collaborators | `planReviewerExportPacket` creates a deterministic export manifest with metadata, policy, FAIR report, artifact hashes, and audit digest. |
+| Make regressions obvious | `test/fair-artifact-access-gate.test.js` covers the ready path plus metadata, access, and reuse failures. |
+
+The slice is intentionally narrow: it does not build object storage, upload UI, billing, or auth. It defines the deterministic hosting contract those surfaces can call before publication.
diff --git a/fair-artifact-access-gate/package.json b/fair-artifact-access-gate/package.json
new file mode 100644
index 0000000..994ece1
--- /dev/null
+++ b/fair-artifact-access-gate/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "fair-artifact-access-gate",
+ "version": "0.1.0",
+ "private": true,
+ "description": "FAIR scientific artifact hosting readiness gate for SCIBASE issue #14.",
+ "type": "module",
+ "scripts": {
+ "test": "node test/fair-artifact-access-gate.test.js",
+ "demo": "node scripts/demo.js",
+ "demo:video": "clang -fobjc-arc -framework Foundation -framework AppKit -framework AVFoundation -framework CoreMedia -framework CoreVideo scripts/render-demo-video.m -o /tmp/scibase-fair-artifact-access-demo && /tmp/scibase-fair-artifact-access-demo docs/demo.mp4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/fair-artifact-access-gate/scripts/demo.js b/fair-artifact-access-gate/scripts/demo.js
new file mode 100644
index 0000000..d2c77e4
--- /dev/null
+++ b/fair-artifact-access-gate/scripts/demo.js
@@ -0,0 +1,24 @@
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { assessFairArtifactAccessGate } from "../src/fair-artifact-access-gate.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const samplePath = join(here, "..", "data", "sample-artifacts.json");
+const sample = JSON.parse(await readFile(samplePath, "utf8"));
+const report = assessFairArtifactAccessGate(sample);
+
+console.log(`Project: ${sample.title}`);
+console.log(`Ready: ${report.ready ? "yes" : "no"}`);
+console.log(`Artifact families: ${report.catalog.families.join(", ")}`);
+console.log(
+ `FAIR: ${report.fairSignals.signals
+ .map((signal) => `${signal.key}=${signal.ready ? "pass" : "fail"}`)
+ .join(", ")}`
+);
+console.log(`Reviewer packet entries: ${report.reviewerPacket.entryCount}`);
+console.log(`Reviewer packet hash: ${report.reviewerPacket.packetHash}`);
+console.log(
+ `Audit digest: standards=${report.reviewerPacket.auditDigest.standardsReady}, fair=${report.reviewerPacket.auditDigest.fairReady}, restricted=${report.reviewerPacket.auditDigest.restrictedArtifactCount}`
+);
+console.log(`Blockers: ${report.blockers.length === 0 ? "none" : report.blockers.join("; ")}`);
diff --git a/fair-artifact-access-gate/scripts/render-demo-video.m b/fair-artifact-access-gate/scripts/render-demo-video.m
new file mode 100644
index 0000000..830a708
--- /dev/null
+++ b/fair-artifact-access-gate/scripts/render-demo-video.m
@@ -0,0 +1,156 @@
+#import
+#import
+#import
+#import
+
+static void FillRounded(NSRect rect, CGFloat radius, NSColor *color) {
+ NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:radius yRadius:radius];
+ [color setFill];
+ [path fill];
+}
+
+static void StrokeRounded(NSRect rect, CGFloat radius, NSColor *color) {
+ NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:radius yRadius:radius];
+ [color setStroke];
+ [path setLineWidth:2.0];
+ [path stroke];
+}
+
+static void DrawText(NSString *text, NSRect rect, CGFloat size, NSColor *color, NSFontWeight weight) {
+ NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
+ style.lineBreakMode = NSLineBreakByWordWrapping;
+ NSDictionary *attrs = @{
+ NSFontAttributeName: [NSFont systemFontOfSize:size weight:weight],
+ NSForegroundColorAttributeName: color,
+ NSParagraphStyleAttributeName: style
+ };
+ [text drawInRect:rect withAttributes:attrs];
+}
+
+static NSColor *RGB(CGFloat r, CGFloat g, CGFloat b) {
+ return [NSColor colorWithCalibratedRed:r / 255.0 green:g / 255.0 blue:b / 255.0 alpha:1.0];
+}
+
+static void DrawCard(NSString *title, NSArray *lines, NSRect rect, NSColor *fill, NSColor *stroke, NSColor *titleColor, NSColor *bodyColor) {
+ FillRounded(rect, 14.0, fill);
+ StrokeRounded(rect, 14.0, stroke);
+ DrawText(title, NSMakeRect(rect.origin.x + 22, rect.origin.y + rect.size.height - 56, rect.size.width - 44, 30), 22, titleColor, NSFontWeightBold);
+ for (NSUInteger i = 0; i < lines.count; i++) {
+ DrawText(lines[i], NSMakeRect(rect.origin.x + 22, rect.origin.y + rect.size.height - 96 - (CGFloat)i * 31, rect.size.width - 44, 28), 18, bodyColor, NSFontWeightRegular);
+ }
+}
+
+static void DrawFrame(CGContextRef cg, int width, int height, int frame) {
+ [NSGraphicsContext saveGraphicsState];
+ NSGraphicsContext *context = [NSGraphicsContext graphicsContextWithCGContext:cg flipped:NO];
+ [NSGraphicsContext setCurrentContext:context];
+
+ [[NSColor colorWithCalibratedRed:245.0 / 255.0 green:247.0 / 255.0 blue:249.0 / 255.0 alpha:1.0] setFill];
+ NSRectFill(NSMakeRect(0, 0, width, height));
+
+ FillRounded(NSMakeRect(64, 54, 1152, 612), 18, [NSColor whiteColor]);
+ StrokeRounded(NSMakeRect(64, 54, 1152, 612), 18, RGB(207, 216, 223));
+
+ CGFloat progress = MIN(1.0, MAX(0.0, ((CGFloat)frame - 8.0) / 64.0));
+ DrawText(@"FAIR Artifact Access Gate", NSMakeRect(104, 595, 760, 48), 36, RGB(23, 33, 43), NSFontWeightBold);
+ DrawText(@"Scientific data and code hosting readiness for SCIBASE issue #14", NSMakeRect(104, 563, 820, 28), 18, RGB(82, 99, 113), NSFontWeightRegular);
+
+ DrawCard(@"Metadata Standards", @[@"JSON-LD", @"DataCite", @"schema.org"], NSMakeRect(104, 362, 252, 152), RGB(233, 246, 240), RGB(159, 208, 186), RGB(23, 72, 49), RGB(39, 99, 74));
+ DrawCard(@"FAIR Signals", @[@"Findable", @"Accessible", @"Reusable"], NSMakeRect(386, 362, 252, 152), RGB(237, 242, 251), RGB(171, 192, 231), RGB(32, 60, 105), RGB(49, 85, 143));
+ DrawCard(@"Access Policy", @[@"Public links", @"Reviewer URLs", @"Restriction reasons"], NSMakeRect(668, 362, 252, 152), RGB(255, 246, 223), RGB(228, 195, 109), RGB(106, 77, 7), RGB(122, 92, 17));
+ DrawCard(@"Export Packet", @[@"Metadata", @"Manifest", @"Checksums"], NSMakeRect(950, 362, 226, 152), RGB(248, 237, 247), RGB(213, 171, 208), RGB(90, 40, 84), RGB(115, 56, 107));
+
+ FillRounded(NSMakeRect(104, 136, 1072, 174), 14, RGB(23, 33, 43));
+ DrawText(@"Astrocyte Calcium Imaging Atlas", NSMakeRect(132, 250, 700, 34), 24, [NSColor whiteColor], NSFontWeightBold);
+ DrawText(@"6 artifact families: dataset, notebook, code, figure, raw instrument output, model", NSMakeRect(132, 209, 920, 28), 18, RGB(200, 211, 220), NSFontWeightRegular);
+ DrawText(@"Ready: standards pass, FAIR pass, restricted reviewer export pass", NSMakeRect(132, 172, 820, 28), 18, RGB(200, 211, 220), NSFontWeightRegular);
+ DrawText(@"Deterministic packet hash supports reviewer and collaborator handoff", NSMakeRect(132, 135, 820, 28), 18, RGB(200, 211, 220), NSFontWeightRegular);
+
+ CGFloat barWidth = 980.0 * progress;
+ FillRounded(NSMakeRect(132, 93, barWidth, 10), 5, RGB(64, 175, 120));
+ DrawText(@"reviewer packet ready", NSMakeRect(132 + barWidth + 14, 84, 220, 28), 14, RGB(82, 99, 113), NSFontWeightMedium);
+
+ [NSGraphicsContext restoreGraphicsState];
+}
+
+int main(int argc, const char *argv[]) {
+ @autoreleasepool {
+ if (argc < 2) {
+ fprintf(stderr, "usage: render-demo-video output.mp4\n");
+ return 2;
+ }
+
+ NSString *outputPath = [NSString stringWithUTF8String:argv[1]];
+ NSURL *outputURL = [NSURL fileURLWithPath:outputPath];
+ [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil];
+
+ const int width = 1280;
+ const int height = 720;
+ NSError *error = nil;
+ AVAssetWriter *writer = [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeMPEG4 error:&error];
+ if (!writer) {
+ NSLog(@"writer error: %@", error);
+ return 1;
+ }
+
+ NSDictionary *videoSettings = @{
+ AVVideoCodecKey: AVVideoCodecTypeH264,
+ AVVideoWidthKey: @(width),
+ AVVideoHeightKey: @(height)
+ };
+ AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:videoSettings];
+ input.expectsMediaDataInRealTime = NO;
+
+ NSDictionary *pixelBufferAttributes = @{
+ (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32ARGB),
+ (NSString *)kCVPixelBufferWidthKey: @(width),
+ (NSString *)kCVPixelBufferHeightKey: @(height)
+ };
+ AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input sourcePixelBufferAttributes:pixelBufferAttributes];
+
+ if (![writer canAddInput:input]) {
+ NSLog(@"cannot add writer input");
+ return 1;
+ }
+ [writer addInput:input];
+ [writer startWriting];
+ [writer startSessionAtSourceTime:kCMTimeZero];
+
+ CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
+ for (int frame = 0; frame < 96; frame++) {
+ while (!input.readyForMoreMediaData) {
+ [NSThread sleepForTimeInterval:0.01];
+ }
+
+ CVPixelBufferRef buffer = NULL;
+ CVPixelBufferPoolCreatePixelBuffer(NULL, adaptor.pixelBufferPool, &buffer);
+ CVPixelBufferLockBaseAddress(buffer, 0);
+ void *baseAddress = CVPixelBufferGetBaseAddress(buffer);
+ size_t bytesPerRow = CVPixelBufferGetBytesPerRow(buffer);
+ CGContextRef cg = CGBitmapContextCreate(baseAddress, width, height, 8, bytesPerRow, colorSpace, kCGImageAlphaNoneSkipFirst);
+ DrawFrame(cg, width, height, frame);
+ CGContextRelease(cg);
+ CVPixelBufferUnlockBaseAddress(buffer, 0);
+
+ CMTime presentationTime = CMTimeMake(frame, 24);
+ [adaptor appendPixelBuffer:buffer withPresentationTime:presentationTime];
+ CVPixelBufferRelease(buffer);
+ }
+
+ CGColorSpaceRelease(colorSpace);
+ [input markAsFinished];
+
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
+ [writer finishWritingWithCompletionHandler:^{
+ dispatch_semaphore_signal(sema);
+ }];
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
+
+ if (writer.status != AVAssetWriterStatusCompleted) {
+ NSLog(@"writer failed: %@", writer.error);
+ return 1;
+ }
+ }
+
+ return 0;
+}
diff --git a/fair-artifact-access-gate/src/fair-artifact-access-gate.js b/fair-artifact-access-gate/src/fair-artifact-access-gate.js
new file mode 100644
index 0000000..dea9987
--- /dev/null
+++ b/fair-artifact-access-gate/src/fair-artifact-access-gate.js
@@ -0,0 +1,331 @@
+import { createHash } from "node:crypto";
+
+const SUPPORTED_FAMILIES = new Set([
+ "code",
+ "dataset",
+ "figure",
+ "model",
+ "notebook",
+ "raw-instrument-output"
+]);
+
+const REQUIRED_TAG_GROUPS = ["keywords", "instruments", "organisms", "variables"];
+
+const REQUIRED_METADATA_STANDARDS = [
+ {
+ key: "jsonLd",
+ label: "JSON-LD",
+ fields: ["@context", "@type", "name", "identifier"]
+ },
+ {
+ key: "dataCite",
+ label: "DataCite",
+ fields: ["identifier", "creators", "publisher", "publicationYear", "resourceType"]
+ },
+ {
+ key: "schemaOrg",
+ label: "schema.org",
+ fields: ["name", "description", "license", "distribution"]
+ }
+];
+
+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 sha256(value) {
+ return createHash("sha256").update(stableStringify(value)).digest("hex");
+}
+
+function hasValue(value) {
+ if (Array.isArray(value)) {
+ return value.length > 0;
+ }
+
+ return value !== undefined && value !== null && value !== "";
+}
+
+function projectUrl(project, artifactPath) {
+ const base = String(project.persistentBaseUrl || "").replace(/\/$/, "");
+ const encodedPath = String(artifactPath)
+ .split("/")
+ .map((segment) => encodeURIComponent(segment))
+ .join("/");
+
+ return `${base}/${encodedPath}`;
+}
+
+export function buildMetadataStandardsIndex(project) {
+ const metadata = project.metadata || {};
+ const standards = REQUIRED_METADATA_STANDARDS.map((standard) => {
+ const payload = metadata[standard.key] || {};
+ const missingFields = standard.fields.filter((field) => !hasValue(payload[field]));
+
+ return {
+ key: standard.key,
+ label: standard.label,
+ ready: missingFields.length === 0,
+ missingFields,
+ hash: sha256(payload)
+ };
+ });
+
+ return {
+ standards,
+ allReady: standards.every((standard) => standard.ready),
+ readyStandards: standards.filter((standard) => standard.ready).map((standard) => standard.key),
+ blockedStandards: standards
+ .filter((standard) => !standard.ready)
+ .map((standard) => ({
+ key: standard.key,
+ missingFields: standard.missingFields
+ }))
+ };
+}
+
+export function buildArtifactCatalog(project) {
+ const blockers = [];
+ const artifacts = Array.isArray(project.artifacts) ? project.artifacts : [];
+ const rows = artifacts.map((artifact) => {
+ const unsupportedFamily = !SUPPORTED_FAMILIES.has(artifact.family);
+ const restricted = artifact.access === "restricted";
+ const row = {
+ path: artifact.path,
+ family: artifact.family,
+ format: artifact.format,
+ access: artifact.access || "unknown",
+ preview: artifact.preview || null,
+ versioned: artifact.versioned === true,
+ diffable: artifact.diffable === true,
+ machineReadable: artifact.machineReadable === true,
+ sizeBytes: Number(artifact.sizeBytes || 0),
+ persistentUrl: projectUrl(project, artifact.path),
+ reviewerUrl: restricted ? `${projectUrl(project, artifact.path)}?reviewer=1` : null,
+ hash: sha256({
+ path: artifact.path,
+ family: artifact.family,
+ format: artifact.format,
+ version: project.version,
+ sizeBytes: artifact.sizeBytes
+ })
+ };
+
+ if (unsupportedFamily) {
+ blockers.push(`unsupported artifact family: ${artifact.family || "missing"}`);
+ }
+
+ if (!artifact.preview) {
+ blockers.push(`artifact lacks preview: ${artifact.path}`);
+ }
+
+ if (artifact.versioned !== true) {
+ blockers.push(`artifact is not versioned: ${artifact.path}`);
+ }
+
+ if (!["public", "restricted"].includes(row.access)) {
+ blockers.push(`artifact has invalid access policy: ${artifact.path}`);
+ }
+
+ if (restricted && artifact.reviewerAccess !== true) {
+ blockers.push(`restricted artifact lacks reviewer access: ${artifact.path}`);
+ }
+
+ if (restricted && !artifact.restrictionReason) {
+ blockers.push(`restricted artifact lacks restriction reason: ${artifact.path}`);
+ }
+
+ return row;
+ });
+
+ const familyCounts = rows.reduce((counts, row) => {
+ counts[row.family] = (counts[row.family] || 0) + 1;
+ return counts;
+ }, {});
+
+ return {
+ artifacts: rows,
+ families: Object.keys(familyCounts).sort(),
+ familyCounts,
+ publicCount: rows.filter((row) => row.access === "public").length,
+ restrictedCount: rows.filter((row) => row.access === "restricted").length,
+ machineReadableCount: rows.filter((row) => row.machineReadable).length,
+ versionedCount: rows.filter((row) => row.versioned).length,
+ diffableCount: rows.filter((row) => row.diffable).length,
+ totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
+ ready: artifacts.length > 0 && blockers.length === 0,
+ blockers
+ };
+}
+
+export function evaluateFairSignals(project, catalog, standardsIndex) {
+ const tagBlockers = REQUIRED_TAG_GROUPS
+ .filter((tagGroup) => !hasValue(project.tags?.[tagGroup]))
+ .map((tagGroup) => `missing scientific tag group: ${tagGroup}`);
+
+ const signals = [
+ {
+ key: "findable",
+ ready:
+ project.indexed === true &&
+ hasValue(project.doi) &&
+ hasValue(project.persistentBaseUrl) &&
+ standardsIndex.readyStandards.includes("dataCite"),
+ evidence: ["DOI", "persistent URLs", "repository index", "DataCite metadata"]
+ },
+ {
+ key: "accessible",
+ ready: catalog.artifacts.every((artifact) => {
+ if (artifact.access === "public") {
+ return true;
+ }
+
+ const original = project.artifacts.find((candidate) => candidate.path === artifact.path) || {};
+ return (
+ artifact.access === "restricted" &&
+ original.reviewerAccess === true &&
+ hasValue(original.restrictionReason)
+ );
+ }),
+ evidence: ["public links", "restricted-access reasons", "reviewer access URLs"]
+ },
+ {
+ key: "interoperable",
+ ready:
+ standardsIndex.readyStandards.includes("jsonLd") &&
+ standardsIndex.readyStandards.includes("schemaOrg") &&
+ catalog.artifacts
+ .filter((artifact) => artifact.machineReadable)
+ .every((artifact) => hasValue(artifact.format)),
+ evidence: ["JSON-LD", "schema.org", "machine-readable formats"]
+ },
+ {
+ key: "reusable",
+ ready:
+ hasValue(project.license) &&
+ hasValue(project.version) &&
+ tagBlockers.length === 0 &&
+ catalog.artifacts.every((artifact) => artifact.versioned),
+ evidence: ["license", "version", "scientific tags", "artifact versioning"]
+ }
+ ];
+
+ const blockers = [
+ ...tagBlockers,
+ ...signals
+ .filter((signal) => !signal.ready)
+ .map((signal) => `FAIR signal failed: ${signal.key}`)
+ ];
+
+ return {
+ signals,
+ allReady: signals.every((signal) => signal.ready),
+ blockers
+ };
+}
+
+export function planReviewerExportPacket(project, catalog, standardsIndex, fairSignals) {
+ const metadataEntries = [
+ {
+ path: "metadata/standards-index.json",
+ kind: "metadata",
+ hash: sha256(standardsIndex)
+ },
+ {
+ path: "reports/fair-signals.json",
+ kind: "report",
+ hash: sha256(fairSignals)
+ },
+ {
+ path: "policies/access-policy.json",
+ kind: "policy",
+ hash: sha256({
+ restricted: project.artifacts
+ .filter((artifact) => artifact.access === "restricted")
+ .map((artifact) => ({
+ path: artifact.path,
+ reason: artifact.restrictionReason,
+ reviewerAccess: artifact.reviewerAccess === true
+ }))
+ })
+ },
+ {
+ path: "manifests/artifact-catalog.json",
+ kind: "manifest",
+ hash: sha256(catalog)
+ }
+ ];
+
+ const artifactEntries = catalog.artifacts.map((artifact) => ({
+ path: artifact.path,
+ kind: artifact.family,
+ access: artifact.access,
+ persistentUrl: artifact.persistentUrl,
+ reviewerUrl: artifact.reviewerUrl,
+ hash: artifact.hash
+ }));
+
+ const entries = [...metadataEntries, ...artifactEntries];
+ const packetHash = sha256({
+ projectId: project.projectId,
+ version: project.version,
+ entries
+ });
+
+ return {
+ projectId: project.projectId,
+ title: project.title,
+ version: project.version,
+ entryCount: entries.length,
+ entries,
+ packetHash,
+ auditDigest: {
+ projectId: project.projectId,
+ standardsReady: standardsIndex.allReady,
+ fairReady: fairSignals.allReady,
+ restrictedArtifactCount: catalog.restrictedCount,
+ packetHash
+ }
+ };
+}
+
+export function assessFairArtifactAccessGate(project) {
+ const standardsIndex = buildMetadataStandardsIndex(project);
+ const catalog = buildArtifactCatalog(project);
+ const fairSignals = evaluateFairSignals(project, catalog, standardsIndex);
+ const reviewerPacket = planReviewerExportPacket(project, catalog, standardsIndex, fairSignals);
+
+ const metadataBlockers = standardsIndex.blockedStandards.map(
+ (standard) => `metadata standard not ready: ${standard.key}`
+ );
+
+ const blockers = [
+ ...metadataBlockers,
+ ...catalog.blockers,
+ ...fairSignals.blockers
+ ];
+
+ return {
+ projectId: project.projectId,
+ ready:
+ standardsIndex.allReady &&
+ catalog.ready &&
+ fairSignals.allReady &&
+ blockers.length === 0,
+ standardsIndex,
+ catalog,
+ fairSignals,
+ reviewerPacket,
+ blockers
+ };
+}
diff --git a/fair-artifact-access-gate/test/fair-artifact-access-gate.test.js b/fair-artifact-access-gate/test/fair-artifact-access-gate.test.js
new file mode 100644
index 0000000..e0c4a28
--- /dev/null
+++ b/fair-artifact-access-gate/test/fair-artifact-access-gate.test.js
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { assessFairArtifactAccessGate } from "../src/fair-artifact-access-gate.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const samplePath = join(here, "..", "data", "sample-artifacts.json");
+const sample = JSON.parse(await readFile(samplePath, "utf8"));
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+{
+ const report = assessFairArtifactAccessGate(sample);
+ assert.equal(report.ready, true);
+ assert.equal(report.blockers.length, 0);
+ assert.deepEqual(report.catalog.families, [
+ "code",
+ "dataset",
+ "figure",
+ "model",
+ "notebook",
+ "raw-instrument-output"
+ ]);
+ assert.equal(report.standardsIndex.allReady, true);
+ assert.equal(report.fairSignals.allReady, true);
+ assert.equal(report.reviewerPacket.entryCount, sample.artifacts.length + 4);
+ assert.equal(report.reviewerPacket.auditDigest.packetHash, report.reviewerPacket.packetHash);
+}
+
+{
+ const project = clone(sample);
+ delete project.metadata.dataCite.publisher;
+ const report = assessFairArtifactAccessGate(project);
+ assert.equal(report.ready, false);
+ assert.ok(report.blockers.includes("metadata standard not ready: dataCite"));
+ assert.ok(report.blockers.includes("FAIR signal failed: findable"));
+}
+
+{
+ const project = clone(sample);
+ const rawArtifact = project.artifacts.find(
+ (artifact) => artifact.family === "raw-instrument-output"
+ );
+ rawArtifact.reviewerAccess = false;
+ delete rawArtifact.restrictionReason;
+ const report = assessFairArtifactAccessGate(project);
+ assert.equal(report.ready, false);
+ assert.ok(
+ report.blockers.includes(
+ "restricted artifact lacks reviewer access: raw/instrument/session-0423.tiff"
+ )
+ );
+ assert.ok(
+ report.blockers.includes(
+ "restricted artifact lacks restriction reason: raw/instrument/session-0423.tiff"
+ )
+ );
+ assert.ok(report.blockers.includes("FAIR signal failed: accessible"));
+}
+
+{
+ const project = clone(sample);
+ delete project.tags.variables;
+ const report = assessFairArtifactAccessGate(project);
+ assert.equal(report.ready, false);
+ assert.ok(report.blockers.includes("missing scientific tag group: variables"));
+ assert.ok(report.blockers.includes("FAIR signal failed: reusable"));
+}