diff --git a/dataset-schema-evolution-gate/README.md b/dataset-schema-evolution-gate/README.md
new file mode 100644
index 00000000..d39cde45
--- /dev/null
+++ b/dataset-schema-evolution-gate/README.md
@@ -0,0 +1,34 @@
+# Dataset Schema Evolution Gate
+
+This module adds a focused Scientific/Engineering Data & Code Hosting guard for dataset schema evolution. It evaluates whether a newly uploaded dataset version remains safe for previews, API consumers, and executable reruns before the platform exposes the version as reusable research infrastructure.
+
+It is intentionally self-contained and dependency-free so reviewers can run it quickly.
+
+## What It Checks
+
+- Removed, renamed, added, and reordered fields across dataset versions.
+- Breaking type, unit, identifier, nullability, and semantic-role drift.
+- Whether existing preview lanes, API consumers, and rerun jobs can still use the new version.
+- DataCite and schema.org variable metadata updates for discoverability.
+- Reviewer actions for migration notes, compatibility holds, and rerun validation.
+
+## Usage
+
+```bash
+node dataset-schema-evolution-gate/test.js
+node dataset-schema-evolution-gate/demo.js
+```
+
+The demo writes reviewer artifacts to `dataset-schema-evolution-gate/reports/`:
+
+- `schema-evolution-audit.json`
+- `reviewer-packet.md`
+- `schema-evolution-summary.svg`
+- `demo.mp4`
+
+## Maintainer-Friendly Notes
+
+- Uses synthetic dataset metadata only.
+- Does not call external services.
+- Does not inspect private files, credentials, payment data, or live research records.
+- Keeps scope distinct from FAIR manifests, package integrity, preview cache, raw-instrument preview, notebook preview, retention/tombstone, model-card lineage, license compatibility, sensitive-redaction, environment drift, provenance-chain, quarantine/rerun, quota/dedup, and access-gate slices.
diff --git a/dataset-schema-evolution-gate/demo.js b/dataset-schema-evolution-gate/demo.js
new file mode 100644
index 00000000..5d4a5fc9
--- /dev/null
+++ b/dataset-schema-evolution-gate/demo.js
@@ -0,0 +1,93 @@
+const fs = require("fs");
+const path = require("path");
+const { buildAudit } = require("./index");
+const { previousVersion, nextVersion, consumers } = require("./sample-data");
+
+const reportDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportDir, { recursive: true });
+
+const audit = buildAudit(previousVersion, nextVersion, consumers);
+
+function writeJson() {
+ fs.writeFileSync(
+ path.join(reportDir, "schema-evolution-audit.json"),
+ `${JSON.stringify(audit, null, 2)}\n`
+ );
+}
+
+function writeMarkdown() {
+ const lines = [
+ "# Dataset Schema Evolution Review",
+ "",
+ `Dataset: \`${audit.datasetId}\``,
+ `Versions: \`${audit.previousVersion}\` -> \`${audit.nextVersion}\``,
+ `Decision: \`${audit.decision}\``,
+ `Compatibility score: \`${audit.compatibilityScore}\``,
+ "",
+ "## Findings",
+ "",
+ ...audit.findings.map((finding) => `- **${finding.severity}** ${finding.kind}: ${finding.message}`),
+ "",
+ "## Consumer Compatibility",
+ "",
+ ...audit.compatibility.map((consumer) => `- **${consumer.id}** (${consumer.kind}): ${consumer.decision}`),
+ "",
+ "## Reviewer Actions",
+ "",
+ ...audit.reviewerActions.map((action) => `- ${action}`)
+ ];
+ fs.writeFileSync(path.join(reportDir, "reviewer-packet.md"), `${lines.join("\n")}\n`);
+}
+
+function writeSvg() {
+ const colors = {
+ "hold-for-migration": "#b42318",
+ "review-before-release": "#b54708",
+ compatible: "#067647"
+ };
+ const findingRows = audit.findings.slice(0, 5).map((finding, index) => {
+ const y = 235 + index * 36;
+ return `${finding.severity.toUpperCase()} - ${escapeXml(finding.kind)} - ${escapeXml(finding.field)}`;
+ }).join("\n");
+
+ const svg = ``;
+ fs.writeFileSync(path.join(reportDir, "schema-evolution-summary.svg"), `${svg}\n`);
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+writeJson();
+writeMarkdown();
+writeSvg();
+
+console.log(`dataset-schema-evolution-gate demo wrote reports to ${reportDir}`);
diff --git a/dataset-schema-evolution-gate/index.js b/dataset-schema-evolution-gate/index.js
new file mode 100644
index 00000000..2473ca52
--- /dev/null
+++ b/dataset-schema-evolution-gate/index.js
@@ -0,0 +1,295 @@
+function normalizeField(field) {
+ return {
+ name: field.name,
+ previousNames: field.previousNames || [],
+ type: field.type || "unknown",
+ role: field.role || "unknown",
+ unit: field.unit || "",
+ required: Boolean(field.required),
+ nullable: Boolean(field.nullable),
+ allowedValues: field.allowedValues || []
+ };
+}
+
+function fieldsByName(schema) {
+ const map = new Map();
+ for (const rawField of schema || []) {
+ const field = normalizeField(rawField);
+ map.set(field.name, field);
+ for (const previousName of field.previousNames) {
+ if (!map.has(previousName)) map.set(previousName, field);
+ }
+ }
+ return map;
+}
+
+function classifyTypeChange(before, after) {
+ if (before.type === after.type) return null;
+ if (before.type === "number" && after.type === "integer") {
+ return {
+ severity: "warning",
+ reason: "numeric narrowing may be acceptable if fractional historical values are absent"
+ };
+ }
+ if (before.type === "integer" && after.type === "number") {
+ return {
+ severity: "info",
+ reason: "integer to number is generally backward compatible"
+ };
+ }
+ return {
+ severity: "blocking",
+ reason: `type changed from ${before.type} to ${after.type}`
+ };
+}
+
+function compareSchemas(previousVersion, nextVersion) {
+ const previousFields = (previousVersion.schema || []).map(normalizeField);
+ const nextFields = (nextVersion.schema || []).map(normalizeField);
+ const nextByName = fieldsByName(nextFields);
+ const previousByName = fieldsByName(previousFields);
+ const findings = [];
+
+ for (const before of previousFields) {
+ const after = nextByName.get(before.name);
+ if (!after) {
+ findings.push({
+ field: before.name,
+ severity: before.required ? "blocking" : "warning",
+ kind: "removed_field",
+ message: `${before.name} was removed from the new schema`
+ });
+ continue;
+ }
+
+ if (after.name !== before.name) {
+ findings.push({
+ field: before.name,
+ replacement: after.name,
+ severity: "warning",
+ kind: "renamed_field",
+ message: `${before.name} was renamed to ${after.name}; consumers need a migration alias`
+ });
+ }
+
+ const typeChange = classifyTypeChange(before, after);
+ if (typeChange) {
+ findings.push({
+ field: after.name,
+ severity: typeChange.severity,
+ kind: "type_change",
+ message: typeChange.reason
+ });
+ }
+
+ if (before.unit !== after.unit) {
+ findings.push({
+ field: after.name,
+ severity: "warning",
+ kind: "unit_change",
+ message: `${before.name} unit changed from ${before.unit || "none"} to ${after.unit || "none"}`
+ });
+ }
+
+ if (before.role !== after.role) {
+ findings.push({
+ field: after.name,
+ severity: "blocking",
+ kind: "semantic_role_change",
+ message: `${before.name} role changed from ${before.role} to ${after.role}`
+ });
+ }
+
+ if (!before.required && after.required) {
+ findings.push({
+ field: after.name,
+ severity: "warning",
+ kind: "required_tightening",
+ message: `${after.name} became required`
+ });
+ }
+
+ if (before.nullable && !after.nullable) {
+ findings.push({
+ field: after.name,
+ severity: "warning",
+ kind: "nullable_tightening",
+ message: `${after.name} no longer accepts null values`
+ });
+ }
+
+ if (before.allowedValues.length > 0 && after.allowedValues.length > 0) {
+ const removedValues = before.allowedValues.filter((value) => !after.allowedValues.includes(value));
+ if (removedValues.length > 0) {
+ findings.push({
+ field: after.name,
+ severity: "blocking",
+ kind: "enum_contraction",
+ message: `${after.name} removed allowed values: ${removedValues.join(", ")}`
+ });
+ }
+ }
+ }
+
+ for (const after of nextFields) {
+ if (!previousByName.has(after.name) && after.previousNames.length === 0) {
+ findings.push({
+ field: after.name,
+ severity: after.required ? "warning" : "info",
+ kind: "added_field",
+ message: `${after.name} was added to the schema`
+ });
+ }
+ }
+
+ return findings;
+}
+
+function resolveFieldName(requiredField, nextByName) {
+ const match = nextByName.get(requiredField);
+ return match ? match.name : requiredField;
+}
+
+function evaluateConsumerCompatibility(consumers, nextVersion, findings) {
+ const nextByName = fieldsByName(nextVersion.schema || []);
+ const blockingFields = new Set(findings.filter((finding) => finding.severity === "blocking").map((finding) => finding.field));
+ const warningFields = new Set(findings.filter((finding) => finding.severity === "warning").map((finding) => finding.field));
+
+ return consumers.map((consumer) => {
+ const issues = [];
+ for (const requiredField of consumer.requiredFields || []) {
+ const nextField = nextByName.get(requiredField);
+ if (!nextField) {
+ issues.push({
+ severity: "blocking",
+ field: requiredField,
+ message: `${consumer.id} requires missing field ${requiredField}`
+ });
+ continue;
+ }
+
+ const compatibleName = resolveFieldName(requiredField, nextByName);
+ const acceptedTypes = consumer.acceptedTypes && consumer.acceptedTypes[requiredField];
+ if (acceptedTypes && !acceptedTypes.includes(nextField.type)) {
+ issues.push({
+ severity: "blocking",
+ field: compatibleName,
+ message: `${consumer.id} expects ${requiredField} as ${acceptedTypes.join(" or ")}, received ${nextField.type}`
+ });
+ }
+
+ if (blockingFields.has(compatibleName)) {
+ issues.push({
+ severity: "blocking",
+ field: compatibleName,
+ message: `${consumer.id} depends on a blocking schema change for ${compatibleName}`
+ });
+ } else if (warningFields.has(compatibleName)) {
+ issues.push({
+ severity: "warning",
+ field: compatibleName,
+ message: `${consumer.id} should review schema drift for ${compatibleName}`
+ });
+ }
+ }
+
+ const blocking = issues.some((issue) => issue.severity === "blocking");
+ const warning = issues.some((issue) => issue.severity === "warning");
+ return {
+ id: consumer.id,
+ kind: consumer.kind,
+ description: consumer.description,
+ decision: blocking ? "hold" : warning ? "review" : "compatible",
+ issues
+ };
+ });
+}
+
+function buildMetadataUpdates(dataset, findings) {
+ const changedFields = Array.from(new Set(findings.map((finding) => finding.field)));
+ const variables = (dataset.schema || []).map((field) => ({
+ name: field.name,
+ propertyID: field.role,
+ unitText: field.unit || undefined,
+ valueRequired: Boolean(field.required)
+ }));
+
+ return {
+ datacite: {
+ identifier: dataset.doi,
+ version: dataset.version,
+ titles: [{ title: dataset.title }],
+ descriptions: [
+ {
+ descriptionType: "TechnicalInfo",
+ description: `Schema evolution review touched ${changedFields.length} field(s): ${changedFields.join(", ")}`
+ }
+ ]
+ },
+ schemaOrg: {
+ "@context": "https://schema.org",
+ "@type": "Dataset",
+ identifier: dataset.doi,
+ version: dataset.version,
+ name: dataset.title,
+ license: dataset.license,
+ variableMeasured: variables
+ }
+ };
+}
+
+function buildReviewerActions(findings, compatibility) {
+ const actions = [];
+ const blockingFindings = findings.filter((finding) => finding.severity === "blocking");
+ const heldConsumers = compatibility.filter((consumer) => consumer.decision === "hold");
+
+ if (blockingFindings.length > 0) {
+ actions.push("Hold automatic publication until blocking schema drift is acknowledged or migrated.");
+ }
+ if (heldConsumers.length > 0) {
+ actions.push("Hold automatic publication until affected consumers have migration coverage.");
+ actions.push(`Disable affected consumers before release: ${heldConsumers.map((consumer) => consumer.id).join(", ")}.`);
+ }
+ if (findings.some((finding) => finding.kind === "renamed_field")) {
+ actions.push("Add migration aliases for renamed fields before API reuse.");
+ }
+ if (findings.some((finding) => finding.kind === "unit_change")) {
+ actions.push("Record unit conversions in metadata and rerun notebooks that depend on changed units.");
+ }
+ if (actions.length === 0) {
+ actions.push("Approve schema evolution and refresh previews, API metadata, and rerun cache.");
+ }
+
+ return actions;
+}
+
+function buildAudit(previousVersion, nextVersion, consumers) {
+ const findings = compareSchemas(previousVersion, nextVersion);
+ const compatibility = evaluateConsumerCompatibility(consumers, nextVersion, findings);
+ const metadataUpdates = buildMetadataUpdates(nextVersion, findings);
+ const reviewerActions = buildReviewerActions(findings, compatibility);
+ const blockingCount = findings.filter((finding) => finding.severity === "blocking").length;
+ const warningCount = findings.filter((finding) => finding.severity === "warning").length;
+ const heldConsumerCount = compatibility.filter((consumer) => consumer.decision === "hold").length;
+ const score = Math.max(0, 100 - blockingCount * 25 - warningCount * 8 - heldConsumerCount * 12);
+
+ return {
+ datasetId: nextVersion.datasetId,
+ previousVersion: previousVersion.version,
+ nextVersion: nextVersion.version,
+ decision: blockingCount > 0 || heldConsumerCount > 0 ? "hold-for-migration" : warningCount > 0 ? "review-before-release" : "compatible",
+ compatibilityScore: score,
+ findings,
+ compatibility,
+ metadataUpdates,
+ reviewerActions
+ };
+}
+
+module.exports = {
+ buildAudit,
+ compareSchemas,
+ evaluateConsumerCompatibility,
+ buildMetadataUpdates,
+ buildReviewerActions
+};
diff --git a/dataset-schema-evolution-gate/reports/demo.mp4 b/dataset-schema-evolution-gate/reports/demo.mp4
new file mode 100644
index 00000000..4d9b80d8
Binary files /dev/null and b/dataset-schema-evolution-gate/reports/demo.mp4 differ
diff --git a/dataset-schema-evolution-gate/reports/reviewer-packet.md b/dataset-schema-evolution-gate/reports/reviewer-packet.md
new file mode 100644
index 00000000..bf3149b0
--- /dev/null
+++ b/dataset-schema-evolution-gate/reports/reviewer-packet.md
@@ -0,0 +1,27 @@
+# Dataset Schema Evolution Review
+
+Dataset: `ds-cell-response-042`
+Versions: `2026.04` -> `2026.05`
+Decision: `hold-for-migration`
+Compatibility score: `44`
+
+## Findings
+
+- **warning** renamed_field: donor_group was renamed to cohort; consumers need a migration alias
+- **warning** renamed_field: dose_mg was renamed to dose_ug; consumers need a migration alias
+- **warning** unit_change: dose_mg unit changed from mg to ug
+- **warning** type_change: numeric narrowing may be acceptable if fractional historical values are absent
+- **info** added_field: instrument_batch was added to the schema
+
+## Consumer Compatibility
+
+- **preview-table-v2** (preview): review
+- **public-api-normalized-response** (api): hold
+- **notebook-rerun-dose-response** (rerun): hold
+
+## Reviewer Actions
+
+- Hold automatic publication until affected consumers have migration coverage.
+- Disable affected consumers before release: public-api-normalized-response, notebook-rerun-dose-response.
+- Add migration aliases for renamed fields before API reuse.
+- Record unit conversions in metadata and rerun notebooks that depend on changed units.
diff --git a/dataset-schema-evolution-gate/reports/schema-evolution-audit.json b/dataset-schema-evolution-gate/reports/schema-evolution-audit.json
new file mode 100644
index 00000000..ba447601
--- /dev/null
+++ b/dataset-schema-evolution-gate/reports/schema-evolution-audit.json
@@ -0,0 +1,167 @@
+{
+ "datasetId": "ds-cell-response-042",
+ "previousVersion": "2026.04",
+ "nextVersion": "2026.05",
+ "decision": "hold-for-migration",
+ "compatibilityScore": 44,
+ "findings": [
+ {
+ "field": "donor_group",
+ "replacement": "cohort",
+ "severity": "warning",
+ "kind": "renamed_field",
+ "message": "donor_group was renamed to cohort; consumers need a migration alias"
+ },
+ {
+ "field": "dose_mg",
+ "replacement": "dose_ug",
+ "severity": "warning",
+ "kind": "renamed_field",
+ "message": "dose_mg was renamed to dose_ug; consumers need a migration alias"
+ },
+ {
+ "field": "dose_ug",
+ "severity": "warning",
+ "kind": "unit_change",
+ "message": "dose_mg unit changed from mg to ug"
+ },
+ {
+ "field": "response_score",
+ "severity": "warning",
+ "kind": "type_change",
+ "message": "numeric narrowing may be acceptable if fractional historical values are absent"
+ },
+ {
+ "field": "instrument_batch",
+ "severity": "info",
+ "kind": "added_field",
+ "message": "instrument_batch was added to the schema"
+ }
+ ],
+ "compatibility": [
+ {
+ "id": "preview-table-v2",
+ "kind": "preview",
+ "description": "Metadata-aware table preview",
+ "decision": "review",
+ "issues": [
+ {
+ "severity": "warning",
+ "field": "response_score",
+ "message": "preview-table-v2 should review schema drift for response_score"
+ }
+ ]
+ },
+ {
+ "id": "public-api-normalized-response",
+ "kind": "api",
+ "description": "Public API endpoint for normalized response scores",
+ "decision": "hold",
+ "issues": [
+ {
+ "severity": "warning",
+ "field": "dose_ug",
+ "message": "public-api-normalized-response should review schema drift for dose_ug"
+ },
+ {
+ "severity": "blocking",
+ "field": "response_score",
+ "message": "public-api-normalized-response expects response_score as number, received integer"
+ },
+ {
+ "severity": "warning",
+ "field": "response_score",
+ "message": "public-api-normalized-response should review schema drift for response_score"
+ }
+ ]
+ },
+ {
+ "id": "notebook-rerun-dose-response",
+ "kind": "rerun",
+ "description": "Notebook rerun for dose response figure regeneration",
+ "decision": "hold",
+ "issues": [
+ {
+ "severity": "warning",
+ "field": "dose_ug",
+ "message": "notebook-rerun-dose-response should review schema drift for dose_ug"
+ },
+ {
+ "severity": "blocking",
+ "field": "response_score",
+ "message": "notebook-rerun-dose-response expects response_score as number, received integer"
+ },
+ {
+ "severity": "warning",
+ "field": "response_score",
+ "message": "notebook-rerun-dose-response should review schema drift for response_score"
+ }
+ ]
+ }
+ ],
+ "metadataUpdates": {
+ "datacite": {
+ "identifier": "10.5555/scibase.cell-response.042",
+ "version": "2026.05",
+ "titles": [
+ {
+ "title": "Cell response assay normalized table"
+ }
+ ],
+ "descriptions": [
+ {
+ "descriptionType": "TechnicalInfo",
+ "description": "Schema evolution review touched 5 field(s): donor_group, dose_mg, dose_ug, response_score, instrument_batch"
+ }
+ ]
+ },
+ "schemaOrg": {
+ "@context": "https://schema.org",
+ "@type": "Dataset",
+ "identifier": "10.5555/scibase.cell-response.042",
+ "version": "2026.05",
+ "name": "Cell response assay normalized table",
+ "license": "CC-BY-4.0",
+ "variableMeasured": [
+ {
+ "name": "sample_id",
+ "propertyID": "identifier",
+ "valueRequired": true
+ },
+ {
+ "name": "cohort",
+ "propertyID": "category",
+ "valueRequired": true
+ },
+ {
+ "name": "dose_ug",
+ "propertyID": "measure",
+ "unitText": "ug",
+ "valueRequired": true
+ },
+ {
+ "name": "response_score",
+ "propertyID": "measure",
+ "unitText": "normalized_score",
+ "valueRequired": true
+ },
+ {
+ "name": "assay_date",
+ "propertyID": "timestamp",
+ "valueRequired": true
+ },
+ {
+ "name": "instrument_batch",
+ "propertyID": "category",
+ "valueRequired": false
+ }
+ ]
+ }
+ },
+ "reviewerActions": [
+ "Hold automatic publication until affected consumers have migration coverage.",
+ "Disable affected consumers before release: public-api-normalized-response, notebook-rerun-dose-response.",
+ "Add migration aliases for renamed fields before API reuse.",
+ "Record unit conversions in metadata and rerun notebooks that depend on changed units."
+ ]
+}
diff --git a/dataset-schema-evolution-gate/reports/schema-evolution-summary.svg b/dataset-schema-evolution-gate/reports/schema-evolution-summary.svg
new file mode 100644
index 00000000..e1b13799
--- /dev/null
+++ b/dataset-schema-evolution-gate/reports/schema-evolution-summary.svg
@@ -0,0 +1,28 @@
+
diff --git a/dataset-schema-evolution-gate/sample-data.js b/dataset-schema-evolution-gate/sample-data.js
new file mode 100644
index 00000000..8a6430e8
--- /dev/null
+++ b/dataset-schema-evolution-gate/sample-data.js
@@ -0,0 +1,68 @@
+const previousVersion = {
+ datasetId: "ds-cell-response-042",
+ version: "2026.04",
+ title: "Cell response assay normalized table",
+ license: "CC-BY-4.0",
+ doi: "10.5555/scibase.cell-response.042",
+ schema: [
+ { name: "sample_id", type: "string", role: "identifier", required: true, nullable: false },
+ { name: "donor_group", type: "string", role: "category", required: true, nullable: false, allowedValues: ["control", "treated"] },
+ { name: "dose_mg", type: "number", role: "measure", unit: "mg", required: true, nullable: false },
+ { name: "response_score", type: "number", role: "measure", unit: "normalized_score", required: true, nullable: false },
+ { name: "assay_date", type: "date", role: "timestamp", required: true, nullable: false }
+ ]
+};
+
+const nextVersion = {
+ datasetId: "ds-cell-response-042",
+ version: "2026.05",
+ title: "Cell response assay normalized table",
+ license: "CC-BY-4.0",
+ doi: "10.5555/scibase.cell-response.042",
+ schema: [
+ { name: "sample_id", type: "string", role: "identifier", required: true, nullable: false },
+ { name: "cohort", previousNames: ["donor_group"], type: "string", role: "category", required: true, nullable: false, allowedValues: ["control", "treated", "washout"] },
+ { name: "dose_ug", previousNames: ["dose_mg"], type: "number", role: "measure", unit: "ug", required: true, nullable: false },
+ { name: "response_score", type: "integer", role: "measure", unit: "normalized_score", required: true, nullable: false },
+ { name: "assay_date", type: "date", role: "timestamp", required: true, nullable: false },
+ { name: "instrument_batch", type: "string", role: "category", required: false, nullable: true }
+ ]
+};
+
+const consumers = [
+ {
+ id: "preview-table-v2",
+ kind: "preview",
+ description: "Metadata-aware table preview",
+ requiredFields: ["sample_id", "response_score", "assay_date"],
+ acceptedTypes: { sample_id: ["string"], response_score: ["number", "integer"], assay_date: ["date"] },
+ toleratedUnitConversions: {}
+ },
+ {
+ id: "public-api-normalized-response",
+ kind: "api",
+ description: "Public API endpoint for normalized response scores",
+ requiredFields: ["sample_id", "donor_group", "dose_mg", "response_score"],
+ acceptedTypes: {
+ sample_id: ["string"],
+ donor_group: ["string"],
+ dose_mg: ["number"],
+ response_score: ["number"]
+ },
+ toleratedUnitConversions: { "dose_mg->dose_ug": "multiply by 1000" }
+ },
+ {
+ id: "notebook-rerun-dose-response",
+ kind: "rerun",
+ description: "Notebook rerun for dose response figure regeneration",
+ requiredFields: ["sample_id", "dose_mg", "response_score"],
+ acceptedTypes: { sample_id: ["string"], dose_mg: ["number"], response_score: ["number"] },
+ toleratedUnitConversions: {}
+ }
+];
+
+module.exports = {
+ previousVersion,
+ nextVersion,
+ consumers
+};
diff --git a/dataset-schema-evolution-gate/test.js b/dataset-schema-evolution-gate/test.js
new file mode 100644
index 00000000..7eed16bd
--- /dev/null
+++ b/dataset-schema-evolution-gate/test.js
@@ -0,0 +1,50 @@
+const assert = require("assert");
+const {
+ buildAudit,
+ compareSchemas,
+ evaluateConsumerCompatibility
+} = require("./index");
+const {
+ previousVersion,
+ nextVersion,
+ consumers
+} = require("./sample-data");
+
+function runTests() {
+ const findings = compareSchemas(previousVersion, nextVersion);
+ assert(findings.some((finding) => finding.kind === "renamed_field" && finding.field === "donor_group"));
+ assert(findings.some((finding) => finding.kind === "unit_change" && finding.field === "dose_ug"));
+ assert(findings.some((finding) => finding.kind === "type_change" && finding.field === "response_score"));
+ assert(findings.some((finding) => finding.kind === "added_field" && finding.field === "instrument_batch"));
+
+ const compatibility = evaluateConsumerCompatibility(consumers, nextVersion, findings);
+ const preview = compatibility.find((consumer) => consumer.id === "preview-table-v2");
+ const api = compatibility.find((consumer) => consumer.id === "public-api-normalized-response");
+ const rerun = compatibility.find((consumer) => consumer.id === "notebook-rerun-dose-response");
+
+ assert.strictEqual(preview.decision, "review");
+ assert.strictEqual(api.decision, "hold");
+ assert.strictEqual(rerun.decision, "hold");
+ assert(api.issues.some((issue) => issue.field === "response_score"));
+
+ const audit = buildAudit(previousVersion, nextVersion, consumers);
+ assert.strictEqual(audit.decision, "hold-for-migration");
+ assert(audit.compatibilityScore < 100);
+ assert(audit.metadataUpdates.datacite.descriptions[0].description.includes("Schema evolution review"));
+ assert(audit.metadataUpdates.schemaOrg.variableMeasured.some((variable) => variable.name === "dose_ug"));
+ assert(audit.reviewerActions.some((action) => action.includes("Hold automatic publication")));
+
+ const compatibleNext = {
+ ...previousVersion,
+ version: "2026.04.1",
+ schema: previousVersion.schema.concat([
+ { name: "optional_note", type: "string", role: "note", required: false, nullable: true }
+ ])
+ };
+ const compatibleAudit = buildAudit(previousVersion, compatibleNext, consumers);
+ assert.strictEqual(compatibleAudit.decision, "compatible");
+
+ console.log("dataset-schema-evolution-gate tests passed");
+}
+
+runTests();