From 2ae6e15df3104be0d23f600ec45441e7d4b70ae6 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 15:34:33 +0000 Subject: [PATCH] docs: add schema drift map --- docs/schema-drift-map.md | 83 +++ protocol/schema-drift-map.json | 700 ++++++++++++++++++++++++++ scripts/validate-schema-drift-map.mjs | 119 +++++ 3 files changed, 902 insertions(+) create mode 100644 docs/schema-drift-map.md create mode 100644 protocol/schema-drift-map.json create mode 100644 scripts/validate-schema-drift-map.mjs diff --git a/docs/schema-drift-map.md b/docs/schema-drift-map.md new file mode 100644 index 00000000..caabe96e --- /dev/null +++ b/docs/schema-drift-map.md @@ -0,0 +1,83 @@ +# Schema Drift Map + +This document records the naming drift between the protocol schemas, the L1 +cloud-core migration skeleton, the audit chain, the evidence chain, and the +deploy manifest example. It is a closure artifact for M0 audit work only. + +## Scope + +- protocol: `protocol/schemas/*.json` +- DB migration: `internal/db/migrations/0001_cloud_core_skeleton.sql` +- audit event: `protocol/schemas/audit-event.schema.json` +- evidence chain: `protocol/evidence-chain.md` +- evidence record: `protocol/schemas/evidence-record.schema.json` +- deploy manifest: `deploy/deploy.json` +- deploy schema: `deploy/deploy.schema.json` + +The source of truth for machine checks is `protocol/schema-drift-map.json`. +This page is the human summary. + +## Aligned Fields + +The following IDs are already aligned at the protocol layer and are carried +through the current examples: + +- `projectId` +- `operationId` +- `auditId` +- `traceId` +- `evidenceId` +- `serviceId` +- `environment` +- `resourceId` +- `sessionId` family: `gatewaySessionId`, `agentSessionId`, `workerSessionId` +- evidence chain roots: `chainId`, `schemaVersion`, `scenario`, `title` +- evidence chain collections: `hardwareOperation`, `auditEvents`, `traceEvents`, `evidenceRecords` + +## Known Gaps + +These are the places where the L1 skeleton still drifts from protocol naming +and needs a real persistence fix before production data is written: + +- `audit_events.request_id` should become `traceId` +- `audit_events.actor` should split into `actorType` and `actorId` +- `audit_events.source` should become `actorId` +- `audit_events.operation` should become `action` +- `audit_events.target` should split into `targetType` and `targetId` +- `audit_events.timestamp` should become `occurredAt` +- `evidence_records.evidence_type` should become `kind` +- `evidence_records.metadata_json.sha256` needs a first-class persisted target +- `hardware_operations.operation_json.resourceId` needs a tested mapping path +- `hardware_operations.operation_json.capabilityId` needs a tested mapping path + +## Evidence Chain + +The evidence chain fixture set is already internally coherent. It binds the +root IDs to the protocol schemas without changing the storage model: + +- `chainId` anchors the fixture +- `operationId` ties hardware, audit, trace, and evidence together +- `traceId` threads through audit and trace events and is copied into evidence + metadata +- `gatewaySessionId`, `agentSessionId`, and `workerSessionId` stay consistent + where they appear +- `hardwareOperation`, `auditEvents`, `traceEvents`, and `evidenceRecords` + each point back to their schema counterparts + +## Impact + +The current examples and validators are sufficient for audit evidence and MVP +closure, but they are not a persistence contract. The drift map is intentionally +read-only so later implementation work can pick one of two paths: + +1. rename the real storage columns to match the protocol, or +2. keep the current skeleton and add a tested mapping layer before writes. + +## Validation + +Run: + +```sh +node --check scripts/validate-schema-drift-map.mjs +node scripts/validate-schema-drift-map.mjs +``` diff --git a/protocol/schema-drift-map.json b/protocol/schema-drift-map.json new file mode 100644 index 00000000..5dc68126 --- /dev/null +++ b/protocol/schema-drift-map.json @@ -0,0 +1,700 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hwlab.pikastech.local/protocol/schema-drift-map.json", + "title": "HWLAB Schema Drift Map", + "version": 1, + "sources": { + "protocol": "protocol/schemas", + "dbMigration": "internal/db/migrations/0001_cloud_core_skeleton.sql", + "audit": "protocol/schemas/audit-event.schema.json", + "evidenceChain": "protocol/evidence-chain.md", + "evidence": "protocol/schemas/evidence-record.schema.json", + "deployManifest": "deploy/deploy.json", + "deploy": "deploy/deploy.schema.json" + }, + "summary": { + "aligned": [ + "projectId", + "operationId", + "auditId", + "traceId", + "evidenceId", + "serviceId", + "environment", + "resourceId", + "sessionId" + ], + "gapCount": 12, + "notes": [ + "This map describes naming drift only and does not change any storage implementation.", + "A field can be aligned at the protocol level while still requiring a DB migration rename or mapping layer before real persistence." + ] + }, + "entities": [ + { + "name": "hardware_operation", + "protocol": "protocol/schemas/hardware-operation.schema.json", + "dbTable": "hardware_operations", + "drift": [ + { + "field": "operationId", + "protocolField": "operationId", + "dbColumn": "id", + "status": "aligned" + }, + { + "field": "projectId", + "protocolField": "projectId", + "dbColumn": "project_id", + "status": "aligned" + }, + { + "field": "gatewaySessionId", + "protocolField": "gatewaySessionId", + "dbColumn": "gateway_session_id", + "status": "aligned" + }, + { + "field": "agentSessionId", + "protocolField": "agentSessionId", + "dbColumn": "agent_session_id", + "status": "aligned" + }, + { + "field": "workerSessionId", + "protocolField": "workerSessionId", + "dbColumn": "worker_session_id", + "status": "aligned" + }, + { + "field": "resourceId", + "protocolField": "resourceId", + "dbColumn": "operation_json.resourceId", + "status": "gap", + "gap": "The skeleton table has no dedicated resource column and currently stores the operation payload as opaque JSON." + }, + { + "field": "capabilityId", + "protocolField": "capabilityId", + "dbColumn": "operation_json.capabilityId", + "status": "gap", + "gap": "The skeleton table has no dedicated capability column and currently stores the operation payload as opaque JSON." + }, + { + "field": "requestedBy", + "protocolField": "requestedBy", + "dbColumn": "requested_by", + "status": "aligned" + }, + { + "field": "status", + "protocolField": "status", + "dbColumn": "status", + "status": "aligned" + }, + { + "field": "environment", + "protocolField": "environment", + "dbColumn": "operation_json.environment", + "status": "gap", + "gap": "The skeleton table does not carry a dedicated environment column for the operation row." + }, + { + "field": "requestedAt", + "protocolField": "requestedAt", + "dbColumn": "requested_at", + "status": "aligned" + }, + { + "field": "updatedAt", + "protocolField": "updatedAt", + "dbColumn": "updated_at", + "status": "aligned" + } + ] + }, + { + "name": "audit_event", + "protocol": "protocol/schemas/audit-event.schema.json", + "dbTable": "audit_events", + "drift": [ + { + "field": "auditId", + "protocolField": "auditId", + "dbColumn": "id", + "status": "aligned" + }, + { + "field": "traceId", + "protocolField": "traceId", + "dbColumn": "request_id", + "status": "gap", + "gap": "The migration still uses request_id instead of traceId." + }, + { + "field": "actorType", + "protocolField": "actorType", + "dbColumn": "actor", + "status": "gap", + "gap": "The migration stores a single actor string instead of a typed actorType field." + }, + { + "field": "actorId", + "protocolField": "actorId", + "dbColumn": "source", + "status": "gap", + "gap": "The migration stores source instead of a stable actorId." + }, + { + "field": "action", + "protocolField": "action", + "dbColumn": "operation", + "status": "gap", + "gap": "The migration uses operation instead of the protocol action name." + }, + { + "field": "targetType", + "protocolField": "targetType", + "dbColumn": "target", + "status": "gap", + "gap": "The migration stores a single target string instead of targetType and targetId." + }, + { + "field": "targetId", + "protocolField": "targetId", + "dbColumn": "target", + "status": "gap", + "gap": "The migration stores a single target string instead of targetType and targetId." + }, + { + "field": "projectId", + "protocolField": "projectId", + "dbColumn": "event_json.projectId", + "status": "aligned", + "notes": "Present in protocol and fixtures, but not a dedicated migration column." + }, + { + "field": "gatewaySessionId", + "protocolField": "gatewaySessionId", + "dbColumn": "event_json.gatewaySessionId", + "status": "aligned", + "notes": "Optional correlation field in protocol; still payload-only in the skeleton." + }, + { + "field": "workerSessionId", + "protocolField": "workerSessionId", + "dbColumn": "event_json.workerSessionId", + "status": "aligned", + "notes": "Optional correlation field in protocol; still payload-only in the skeleton." + }, + { + "field": "operationId", + "protocolField": "operationId", + "dbColumn": "event_json.operationId", + "status": "aligned", + "notes": "Present in protocol and fixtures, but not a dedicated migration column." + }, + { + "field": "serviceId", + "protocolField": "serviceId", + "dbColumn": "event_json.serviceId", + "status": "aligned", + "notes": "Frozen service ID appears in payload only." + }, + { + "field": "environment", + "protocolField": "environment", + "dbColumn": "event_json.environment", + "status": "aligned", + "notes": "MVP environment is carried in payload only." + }, + { + "field": "occurredAt", + "protocolField": "occurredAt", + "dbColumn": "timestamp", + "status": "gap", + "gap": "The migration uses timestamp instead of occurredAt." + } + ] + }, + { + "name": "evidence_chain", + "protocol": "protocol/evidence-chain.md", + "dbTable": null, + "drift": [ + { + "field": "chainId", + "protocolField": "chainId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "schemaVersion", + "protocolField": "schemaVersion", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "scenario", + "protocolField": "scenario", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "title", + "protocolField": "title", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "projectId", + "protocolField": "projectId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "operationId", + "protocolField": "operationId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "traceId", + "protocolField": "traceId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "environment", + "protocolField": "environment", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "gatewaySessionId", + "protocolField": "gatewaySessionId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "agentSessionId", + "protocolField": "agentSessionId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "workerSessionId", + "protocolField": "workerSessionId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "resourceId", + "protocolField": "resourceId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "capabilityId", + "protocolField": "capabilityId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "hardwareOperation", + "protocolField": "hardwareOperation", + "dbColumn": "protocol/schemas/hardware-operation.schema.json", + "status": "aligned" + }, + { + "field": "auditEvents", + "protocolField": "auditEvents", + "dbColumn": "protocol/schemas/audit-event.schema.json", + "status": "aligned" + }, + { + "field": "traceEvents", + "protocolField": "traceEvents", + "dbColumn": "protocol/schemas/trace-event.schema.json", + "status": "aligned" + }, + { + "field": "evidenceRecords", + "protocolField": "evidenceRecords", + "dbColumn": "protocol/schemas/evidence-record.schema.json", + "status": "aligned" + } + ] + }, + { + "name": "trace_event", + "protocol": "protocol/schemas/trace-event.schema.json", + "dbTable": "agent_trace_events", + "drift": [ + { + "field": "traceEventId", + "protocolField": "traceEventId", + "dbColumn": "id", + "status": "aligned" + }, + { + "field": "traceId", + "protocolField": "traceId", + "dbColumn": "trace_id", + "status": "aligned" + }, + { + "field": "projectId", + "protocolField": "projectId", + "dbColumn": "event_json.projectId", + "status": "aligned", + "notes": "Project scope is payload-only in the skeleton." + }, + { + "field": "agentSessionId", + "protocolField": "agentSessionId", + "dbColumn": "agent_session_id", + "status": "aligned" + }, + { + "field": "workerSessionId", + "protocolField": "workerSessionId", + "dbColumn": "worker_session_id", + "status": "aligned" + }, + { + "field": "operationId", + "protocolField": "operationId", + "dbColumn": "event_json.operationId", + "status": "aligned", + "notes": "Operation scope is payload-only in the skeleton." + }, + { + "field": "serviceId", + "protocolField": "serviceId", + "dbColumn": "event_json.serviceId", + "status": "aligned" + }, + { + "field": "level", + "protocolField": "level", + "dbColumn": "level", + "status": "aligned" + }, + { + "field": "message", + "protocolField": "message", + "dbColumn": "message", + "status": "aligned" + }, + { + "field": "environment", + "protocolField": "environment", + "dbColumn": "event_json.environment", + "status": "aligned" + }, + { + "field": "occurredAt", + "protocolField": "occurredAt", + "dbColumn": "occurred_at", + "status": "aligned" + } + ] + }, + { + "name": "evidence_record", + "protocol": "protocol/schemas/evidence-record.schema.json", + "dbTable": "evidence_records", + "drift": [ + { + "field": "evidenceId", + "protocolField": "evidenceId", + "dbColumn": "id", + "status": "aligned" + }, + { + "field": "projectId", + "protocolField": "projectId", + "dbColumn": "project_id", + "status": "aligned" + }, + { + "field": "operationId", + "protocolField": "operationId", + "dbColumn": "operation_id", + "status": "aligned" + }, + { + "field": "agentSessionId", + "protocolField": "agentSessionId", + "dbColumn": "metadata_json.agentSessionId", + "status": "aligned", + "notes": "Optional in protocol and currently payload-only in the skeleton." + }, + { + "field": "workerSessionId", + "protocolField": "workerSessionId", + "dbColumn": "metadata_json.workerSessionId", + "status": "aligned", + "notes": "Optional in protocol and currently payload-only in the skeleton." + }, + { + "field": "kind", + "protocolField": "kind", + "dbColumn": "evidence_type", + "status": "gap", + "gap": "The migration uses evidence_type, but the protocol requires kind." + }, + { + "field": "uri", + "protocolField": "uri", + "dbColumn": "uri", + "status": "aligned" + }, + { + "field": "sha256", + "protocolField": "sha256", + "dbColumn": "metadata_json.sha256", + "status": "gap", + "gap": "The migration has no dedicated sha256 column." + }, + { + "field": "serviceId", + "protocolField": "serviceId", + "dbColumn": "metadata_json.serviceId", + "status": "aligned", + "notes": "Frozen service ID is present in protocol and fixtures." + }, + { + "field": "environment", + "protocolField": "environment", + "dbColumn": "metadata_json.environment", + "status": "aligned" + }, + { + "field": "createdAt", + "protocolField": "createdAt", + "dbColumn": "created_at", + "status": "aligned" + } + ] + }, + { + "name": "deploy_manifest", + "protocol": "deploy/deploy.schema.json", + "dbTable": null, + "drift": [ + { + "field": "manifestVersion", + "protocolField": "manifestVersion", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "environment", + "protocolField": "environment", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "commitId", + "protocolField": "commitId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "profiles.dev.name", + "protocolField": "profiles.dev.name", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "services[].serviceId", + "protocolField": "services[].serviceId", + "dbColumn": "n/a", + "status": "aligned" + }, + { + "field": "services[].replicas", + "protocolField": "services[].replicas", + "dbColumn": "n/a", + "status": "aligned" + } + ] + } + ], + "requiredKeyCoverage": { + "projectId": { + "protocol": [ + "hardware-operation", + "audit-event", + "trace-event", + "evidence-record", + "deploy-manifest" + ], + "dbMigration": [ + "projects.project_id", + "gateway_sessions.project_id", + "box_resources.project_id", + "wiring_configs.project_id", + "hardware_operations.project_id", + "agent_sessions.project_id", + "audit_events.event_json.projectId", + "agent_trace_events.event_json.projectId", + "evidence_records.project_id" + ] + }, + "operationId": { + "protocol": [ + "hardware-operation", + "audit-event", + "trace-event", + "evidence-record" + ], + "dbMigration": [ + "hardware_operations.id", + "audit_events.event_json.operationId", + "agent_trace_events.event_json.operationId", + "evidence_records.operation_id" + ] + }, + "auditId": { + "protocol": [ + "audit-event", + "evidence-record.metadata.producedByAuditId", + "trace-event.metadata.auditId" + ], + "dbMigration": [ + "audit_events.id", + "audit_events.event_json.auditId", + "agent_trace_events.event_json.auditId", + "evidence_records.metadata_json.producedByAuditId" + ] + }, + "traceId": { + "protocol": [ + "audit-event", + "trace-event", + "evidence-record.metadata.traceId" + ], + "dbMigration": [ + "audit_events.request_id", + "agent_trace_events.trace_id", + "evidence_records.metadata_json.traceId" + ] + }, + "evidenceId": { + "protocol": [ + "evidence-record", + "audit-event.targetId", + "trace-event.metadata.evidenceId" + ], + "dbMigration": [ + "evidence_records.id", + "audit_events.event_json.evidenceId", + "agent_trace_events.event_json.evidenceId" + ] + }, + "serviceId": { + "protocol": [ + "audit-event", + "trace-event", + "evidence-record", + "deploy-manifest.services[].serviceId" + ], + "dbMigration": [ + "audit_events.event_json.serviceId", + "agent_trace_events.event_json.serviceId", + "evidence_records.metadata_json.serviceId", + "gateway_sessions.gateway_service_id" + ] + }, + "environment": { + "protocol": [ + "hardware-operation", + "audit-event", + "trace-event", + "evidence-record", + "deploy-manifest" + ], + "dbMigration": [ + "hardware_operations.operation_json.environment", + "audit_events.event_json.environment", + "agent_trace_events.event_json.environment", + "evidence_records.metadata_json.environment" + ] + }, + "resourceId": { + "protocol": [ + "hardware-operation", + "audit-event.metadata.resourceId", + "evidence-record.metadata.resourceId" + ], + "dbMigration": [ + "box_resources.id", + "hardware_operations.operation_json.resourceId", + "audit_events.event_json.resourceId", + "evidence_records.metadata_json.resourceId" + ] + }, + "sessionId": { + "protocol": [ + "gatewaySessionId", + "agentSessionId", + "workerSessionId" + ], + "dbMigration": [ + "gateway_sessions.id", + "agent_sessions.id", + "worker_sessions.id", + "hardware_operations.gateway_session_id", + "agent_trace_events.agent_session_id", + "agent_trace_events.worker_session_id", + "evidence_records.metadata_json.agentSessionId", + "evidence_records.metadata_json.workerSessionId" + ] + } + }, + "missingToFixBeforeRealPersistence": [ + { + "field": "audit_events.request_id", + "reason": "Must be renamed or mapped to traceId before audit rows are treated as protocol-shaped records." + }, + { + "field": "audit_events.actor", + "reason": "Must be split into actorType and actorId or mapped through a persisted envelope." + }, + { + "field": "audit_events.source", + "reason": "Must be renamed or mapped to actorId." + }, + { + "field": "audit_events.operation", + "reason": "Must be renamed or mapped to action." + }, + { + "field": "audit_events.target", + "reason": "Must be split into targetType and targetId." + }, + { + "field": "audit_events.timestamp", + "reason": "Must be renamed or mapped to occurredAt." + }, + { + "field": "evidence_records.evidence_type", + "reason": "Must be renamed or mapped to kind." + }, + { + "field": "evidence_records.metadata_json.sha256", + "reason": "Must become a first-class persisted field or a tested mapping target." + }, + { + "field": "hardware_operations.operation_json.resourceId", + "reason": "Must be exposed through a real column or tested extraction path." + }, + { + "field": "hardware_operations.operation_json.capabilityId", + "reason": "Must be exposed through a real column or tested extraction path." + } + ] +} diff --git a/scripts/validate-schema-drift-map.mjs b/scripts/validate-schema-drift-map.mjs new file mode 100644 index 00000000..75ba3e80 --- /dev/null +++ b/scripts/validate-schema-drift-map.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const driftMapPath = path.join(repoRoot, "protocol/schema-drift-map.json"); + +const requiredKeys = [ + "projectId", + "operationId", + "auditId", + "traceId", + "evidenceId", + "serviceId", + "environment", + "resourceId", + "sessionId" +]; + +const requiredStatuses = new Set(["aligned", "gap"]); + +function assertObject(value, label) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); +} + +function loadDriftMap() { + const raw = readFile(driftMapPath, "utf8"); + return raw.then((content) => JSON.parse(content)); +} + +function assertRequiredCoverage(map) { + assertObject(map, "drift map"); + assert.equal(map.version, 1, "drift map version must be 1"); + assertObject(map.summary, "drift map summary"); + assert.ok(Array.isArray(map.summary.aligned), "drift map summary.aligned must be an array"); + assert.ok(Array.isArray(map.entities), "drift map entities must be an array"); + assert.ok( + map.entities.length >= 5, + "expected drift map entities for protocol, DB, audit, evidence chain, evidence, and deploy" + ); + assertObject(map.requiredKeyCoverage, "drift map requiredKeyCoverage"); + + for (const key of requiredKeys) { + assert.ok(Object.hasOwn(map.requiredKeyCoverage, key), `missing required coverage for ${key}`); + const coverage = map.requiredKeyCoverage[key]; + assertObject(coverage, `coverage for ${key}`); + assert.ok(Array.isArray(coverage.protocol) && coverage.protocol.length >= 1, `${key} protocol coverage must exist`); + assert.ok( + Array.isArray(coverage.dbMigration) && coverage.dbMigration.length >= 1, + `${key} dbMigration coverage must exist` + ); + } +} + +function assertEntityDrift(entity) { + assertObject(entity, `entity ${entity?.name ?? ""}`); + assert.equal(typeof entity.name, "string", "entity.name must be a string"); + assert.equal(typeof entity.protocol, "string", `entity ${entity.name} protocol must be a string`); + assert.ok(Array.isArray(entity.drift) && entity.drift.length >= 1, `entity ${entity.name} drift must exist`); + + for (const [index, entry] of entity.drift.entries()) { + assertObject(entry, `${entity.name}.drift[${index}]`); + assert.equal(typeof entry.field, "string", `${entity.name}.drift[${index}].field must be a string`); + assert.equal(typeof entry.protocolField, "string", `${entity.name}.drift[${index}].protocolField must be a string`); + assert.equal(typeof entry.dbColumn, "string", `${entity.name}.drift[${index}].dbColumn must be a string`); + assert.ok(requiredStatuses.has(entry.status), `${entity.name}.drift[${index}].status must be aligned or gap`); + if (entry.status === "gap") { + assert.equal(typeof entry.gap, "string", `${entity.name}.drift[${index}].gap must describe the gap`); + assert.ok(entry.gap.length > 0, `${entity.name}.drift[${index}].gap must not be empty`); + } + } +} + +function assertSummary(map) { + const aligned = new Set(map.summary?.aligned ?? []); + for (const key of requiredKeys) { + assert.ok(aligned.has(key), `summary.aligned must include ${key}`); + } + assert.equal(typeof map.summary.gapCount, "number", "summary.gapCount must be numeric"); + assert.ok(map.summary.gapCount >= 1, "summary.gapCount must report at least one gap"); +} + +async function main() { + const map = await loadDriftMap(); + + assert.equal(map.$schema, "https://json-schema.org/draft/2020-12/schema", "drift map $schema"); + assert.equal( + map.$id, + "https://hwlab.pikastech.local/protocol/schema-drift-map.json", + "drift map $id" + ); + + assertSummary(map); + assertRequiredCoverage(map); + + let alignCount = 0; + let gapCount = 0; + for (const entity of map.entities) { + assertEntityDrift(entity); + alignCount += entity.drift.filter((entry) => entry.status === "aligned").length; + gapCount += entity.drift.filter((entry) => entry.status === "gap").length; + } + + assert.equal(gapCount, map.summary.gapCount, "summary.gapCount must match computed gap count"); + + assert.ok( + Array.isArray(map.missingToFixBeforeRealPersistence) && map.missingToFixBeforeRealPersistence.length >= 1, + "expected a non-empty list of persistence blockers" + ); + + assert.ok(alignCount >= 20, "expected broad alignment coverage"); + assert.ok(gapCount >= 5, "expected explicit drift gaps"); + + console.log(`validated schema drift map with ${alignCount} aligned fields and ${gapCount} gaps`); +} + +await main();