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 = ` + + + + Dataset Schema Evolution Gate + ${escapeXml(audit.datasetId)} ${escapeXml(audit.previousVersion)} -> ${escapeXml(audit.nextVersion)} + + ${escapeXml(audit.decision)} + Score + ${audit.compatibilityScore}/100 + Top Findings + ${findingRows} + Consumer Decisions + ${audit.compatibility.map((consumer, index) => { + const y = 335 + index * 48; + return `${escapeXml(consumer.id)}: ${escapeXml(consumer.decision)}`; + }).join("\n")} + Generated from synthetic sample data. No secrets, live records, or payment data. +`; + 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 @@ + + + + + Dataset Schema Evolution Gate + ds-cell-response-042 2026.04 -> 2026.05 + + hold-for-migration + Score + 44/100 + Top Findings + WARNING - renamed_field - donor_group +WARNING - renamed_field - dose_mg +WARNING - unit_change - dose_ug +WARNING - type_change - response_score +INFO - added_field - instrument_batch + Consumer Decisions + preview-table-v2: review +public-api-normalized-response: hold +notebook-rerun-dose-response: hold + Generated from synthetic sample data. No secrets, live records, or payment data. + 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();