diff --git a/infra/deploy-runner/README.md b/infra/deploy-runner/README.md index 68f6bed..1a6060f 100644 --- a/infra/deploy-runner/README.md +++ b/infra/deploy-runner/README.md @@ -172,6 +172,28 @@ runtime and descriptor equality. The ordinary overlay backup is the automatic rollback source; rollback restores the predecessor gateway and descriptor together before recreating the same backend service. +The successor autonomy/provider-v5 slice keeps MCP authority explicit without +turning every write into a second permission dialogue. MCP tool availability is +the capability boundary, while the user's current objective is the intent +boundary; Engine L2 may act autonomously only inside their intersection. A +graph `plan` remains a mandatory machine barrier whose target, diff, revision +and blockers are inspected by the agent. It is not a repeated approval prompt +after an implementation objective is already authorized. Retries must add new +evidence or change the attempted variant, and three identical failures without +new evidence or state change are a critical stop. The slice also advances the +installer to `0.1.6` and adds `gelios.provider.v5 -> +fleet.positions.current.v4` beside the immutable v4/v3 rollback line: + +```bash +node infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs \ + 20260720-004 +``` + +Its seven-entry allowlist recreates only `nodedc-backend`; n8n, L1, databases, +credential values and the node-intelligence image remain outside the change. +The runner accepts both the exact target and the exact predecessor after an +automatic rollback, and rejects every mixed state. + For `platform` artifacts, the allowlist includes the versioned Ontology Core, the frozen legacy Gelios compatibility service, and the provider-neutral External Data Plane sources. The Gelios service remains reproducible only to diff --git a/infra/deploy-runner/build-engine-composite-provider-v4-artifact.mjs b/infra/deploy-runner/build-engine-composite-provider-v4-artifact.mjs new file mode 100644 index 0000000..1be4a4e --- /dev/null +++ b/infra/deploy-runner/build-engine-composite-provider-v4-artifact.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(here, "../../.."); +const engineRoot = resolve( + process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"), +); +const artifactRoot = resolve( + process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"), +); +const [patchId = "", ...extra] = process.argv.slice(2); +if (extra.length || !/^engine-composite-provider-v4-\d{8}-\d{3}$/.test(patchId)) { + throw new Error( + "usage: build-engine-composite-provider-v4-artifact.mjs " + + "", + ); +} + +const targetSha256 = Object.freeze({ + "nodedc-source/server/assets/provider-packages/v1/catalog.json": + "9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": + "689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f", + "nodedc-source/server/dataProductPublishGrant/service.js": + "83f045f4e0f332644310172ed51bb652d808bf04155b11c02e46bdffc95f7220", + "nodedc-source/server/dataProductPublishGrant/store.js": + "98662da6acd0489a9cae4b726eb61ce89d9b2c788b2ea95433e9c4f02930c095", +}); +const entries = Object.freeze(Object.keys(targetSha256)); +const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`); + +await assertFresh(artifact); +await assertExactSources(); +const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-composite-provider-v4-")); +try { + const payload = join(stage, "payload"); + for (const relativePath of entries) { + const source = join(engineRoot, relativePath); + const destination = join(payload, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination, 0); + } + await writeFile( + join(stage, "manifest.env"), + `id=${patchId}\ncomponent=engine\ntype=app-overlay\n`, + { encoding: "utf8", flag: "wx", mode: 0o644 }, + ); + await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o644, + }); + await mkdir(artifactRoot, { recursive: true }); + run("python3", ["-c", canonicalTarScript(), artifact, stage]); + console.log(JSON.stringify({ + ok: true, + patchId, + artifact, + sha256: digest(await readFile(artifact)), + entries, + targetSha256, + services: ["nodedc-backend"], + transition: "exact-v3-to-v4", + providerPackage: "gelios.provider.v4", + capabilities: [ + "gelios.monitoring_config.current.read", + "gelios.units.current.read", + ], + dataProductId: "fleet.positions.current.v3", + credentialValues: "preserved", + untouched: ["n8n", "L1 graph", "Engine UI", "databases"], + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertExactSources() { + for (const [relativePath, expected] of Object.entries(targetSha256)) { + const path = join(engineRoot, relativePath); + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error(`engine_composite_provider_source_unsafe:${relativePath}`); + } + const actual = digest(await readFile(path)); + if (actual !== expected) { + throw new Error( + `engine_composite_provider_target_mismatch:${relativePath}:` + + `expected=${expected}:actual=${actual}`, + ); + } + } + + const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8")); + const provider = catalog?.packages?.[0]; + const capabilityIds = provider?.capabilities?.map((item) => item.id); + const requestUrls = provider?.capabilities?.map((item) => item.request?.url); + if ( + catalog?.schemaVersion !== "nodedc.engine.provider-security-catalog/v1" || + provider?.id !== "gelios.provider.v4" || + provider?.version !== "4.0.0" || + provider?.providerCredential?.credentialType !== "httpBearerAuth" || + JSON.stringify(capabilityIds) !== JSON.stringify([ + "gelios.monitoring_config.current.read", + "gelios.units.current.read", + ]) || + JSON.stringify(requestUrls) !== JSON.stringify([ + "https://api.geliospro.com/api/v1/users/me/monitoring-config", + "https://api.geliospro.com/api/v1/units?incltrip=true", + ]) || + provider?.capabilities?.some( + (item) => item.dataProductIds?.length !== 1 || + item.dataProductIds[0] !== "fleet.positions.current.v3", + ) + ) { + throw new Error("engine_composite_provider_catalog_projection_mismatch"); + } +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("artifact_already_exists"); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'xb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error(`${command}_failed:${result.stderr || result.stdout}`); + } +} diff --git a/infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs b/infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs index 72c72cd..12bc92a 100644 --- a/infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs +++ b/infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs @@ -17,6 +17,7 @@ const previouslyIssuedPatchIds = new Set([ 'engine-data-product-publish-grant-20260717-001', 'engine-data-product-publish-grant-20260717-002', ]) +const compositeProviderCatalogTargetSha256 = '9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a' if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) { throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs ') @@ -109,6 +110,13 @@ try { 'nodedc-source/dist', 'nodedc-source/server/tests', ], + providerPackage: 'gelios.provider.v4', + providerRequests: [ + 'https://api.geliospro.com/api/v1/users/me/monitoring-config', + 'https://api.geliospro.com/api/v1/units?incltrip=true', + ], + dataProductId: 'fleet.positions.current.v3', + credentialValues: 'preserved', preservedPredecessor: Object.keys(credentialSinkPredecessorSha256), }, null, 2)) } finally { @@ -116,10 +124,14 @@ try { } async function assertSourceBoundary() { - for (const [relativePath, expectedSha256] of Object.entries(credentialSinkPredecessorSha256)) { - const bytes = await readFile(join(engineRoot, relativePath)) - if (digest(bytes) !== expectedSha256) { - throw new Error(`engine_credential_sink_predecessor_drift:${relativePath}`) + // These files are deliberately excluded from the artifact. Their exact + // installed predecessor is verified by the root-owned runner during plan + // and apply; the local builder only proves that it cannot package a link or + // another unsafe filesystem object in their place. + for (const relativePath of Object.keys(credentialSinkPredecessorSha256)) { + const info = await lstat(join(engineRoot, relativePath)) + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error(`engine_credential_sink_predecessor_unsafe:${relativePath}`) } } const runtimeOverridePath = join( @@ -129,6 +141,13 @@ async function assertSourceBoundary() { if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) { throw new Error('engine_publish_grant_runtime_override_mismatch') } + const catalogPath = join( + engineRoot, + 'nodedc-source/server/assets/provider-packages/v1/catalog.json', + ) + if (digest(await readFile(catalogPath)) !== compositeProviderCatalogTargetSha256) { + throw new Error('engine_composite_provider_catalog_target_mismatch') + } const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8') if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) { @@ -175,6 +194,32 @@ async function assertSourceBoundary() { if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) { throw new Error('engine_publish_grant_source_set_mismatch') } + const providerCatalogSource = await readFile(join(grantDirectory, 'providerCatalog.js'), 'utf8') + const grantServiceSource = await readFile(join(grantDirectory, 'service.js'), 'utf8') + const grantStoreSource = await readFile(join(grantDirectory, 'store.js'), 'utf8') + for (const marker of [ + 'function capabilityRequests(capability)', + 'function exactHttpRequestUrl(n8n)', + 'capabilityIds', + 'providerRequestNodeIds', + 'providerCredentialRefs.size !== 1', + ]) { + if (!providerCatalogSource.includes(marker)) { + throw new Error(`engine_composite_provider_resolver_missing:${marker}`) + } + } + if ( + !grantServiceSource.includes('capabilities: descriptor.capabilityIds') + || !grantServiceSource.includes('providerRequestNodeIds: descriptor.providerRequestNodeIds') + ) { + throw new Error('engine_composite_provider_plan_projection_missing') + } + if ( + !grantStoreSource.includes('Array.isArray(raw.capabilityIds)') + || !grantStoreSource.includes('Array.isArray(raw.providerRequestNodeIds)') + ) { + throw new Error('engine_composite_provider_store_projection_missing') + } for (const entry of entries) { const source = resolve(engineRoot, entry) const info = await lstat(source) diff --git a/infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs b/infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs new file mode 100644 index 0000000..ee6fbfe --- /dev/null +++ b/infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const platformRoot = resolve(here, '../..') +const engineRoot = resolve( + process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'), +) +const canonicalArtifactRoot = resolve(here, '../deploy-artifacts') +const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot) +const [transitionId = '20260720-004', ...extra] = process.argv.slice(2) +if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) { + throw new Error('usage: build-engine-mcp-autonomy-provider-v5-artifact.mjs [YYYYMMDD-NNN]') +} + +const id = `engine-mcp-autonomy-provider-v5-${transitionId}` +const target = join(artifactRoot, `nodedc-${id}.tgz`) +const predecessorArtifact = join( + canonicalArtifactRoot, + 'nodedc-engine-mcp-control-plane-20260718-003.tgz', +) +const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba' +const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json' +const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js' +const files = [ + 'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs', + 'nodedc-source/server/assets/engine-agent-npm/package.json', + 'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz', + 'nodedc-source/server/assets/provider-packages/v1/catalog.json', + 'nodedc-source/server/engineAgents/store.js', + gatewayRel, + descriptorRel, +] +const expectedSha256 = new Map([ + [files[0], 'c15c9da4f90f44a4e9e12f3683127e906d614fb98e562faa0c939505c973e074'], + [files[1], '2ca8dcab0fa04bb1b21add1f75a9be61d5aa97753443ee1917302b4e0da5780a'], + [files[2], 'd007a81cb4e4af569b3c54d3869b0f60b5597e3b531bff232145fa1851d8572a'], + [files[3], '63e0741646197f0b1b3c64a4095e1bc8fb3a95ee6caf20b0293f89d869c9e620'], + [files[4], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'], + [files[5], '5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c'], +]) + +await assertFresh(target) +assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact') +const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-autonomy-provider-v5-')) +const payload = join(stage, 'payload') +try { + await mkdir(payload, { recursive: true }) + for (const rel of files.slice(0, -1)) { + const source = join(engineRoot, rel) + const stat = await lstat(source) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`) + assertSha(await readFile(source), expectedSha256.get(rel), rel) + await mkdir(dirname(join(payload, rel)), { recursive: true }) + await cp(source, join(payload, rel), { force: false }) + } + + const descriptor = JSON.parse(extractMember( + predecessorArtifact, + `payload/${descriptorRel}`, + )) + if ( + descriptor?.action !== 'activate' + || descriptor?.releaseId !== '2.33.2-974a9fb3492f' + || descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15' + || descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131' + ) throw new Error('mcp_autonomy_predecessor_descriptor_mismatch') + descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel) + await mkdir(dirname(join(payload, descriptorRel)), { recursive: true }) + await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8') + + await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8') + await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8') + await mkdir(artifactRoot, { recursive: true }) + run('python3', ['-c', canonicalTarScript(), target, stage]) + console.log(JSON.stringify({ + ok: true, + id, + artifact: target, + artifactSha256: sha(await readFile(target)), + services: ['nodedc-backend'], + mcpVersion: '0.6.0', + installerVersion: '0.1.6', + authority: 'mcp-capability-intersect-user-objective', + retryBoundary: 'three-identical-failures-without-new-evidence', + providerPackages: ['gelios.provider.v4', 'gelios.provider.v5'], + targetDataProduct: 'fleet.positions.current.v4', + preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'credential values'], + files, + }, null, 2)) +} finally { + await rm(stage, { recursive: true, force: true }) +} + +async function assertFresh(path) { + try { + await lstat(path) + } catch (error) { + if (error?.code === 'ENOENT') return + throw error + } + throw new Error('artifact_already_exists') +} + +function extractMember(archive, member) { + const script = [ + 'import pathlib,sys,tarfile', + 'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]', + "with tarfile.open(p,'r:gz') as t:", + ' m=t.getmember(name)', + " if not m.isfile(): raise SystemExit('member-not-file')", + ' f=t.extractfile(m)', + " if f is None: raise SystemExit('member-unreadable')", + ' sys.stdout.buffer.write(f.read())', + ].join('\n') + return run('python3', ['-c', script, archive, member]).stdout +} + +function canonicalTarScript() { + return [ + 'import gzip,io,pathlib,sys,tarfile', + 'root=pathlib.Path(sys.argv[2])', + "with open(sys.argv[1],'xb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + ' for x in paths:', + ' info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())', + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join('\n') +} + +function assertSha(bytes, expected, label) { + const actual = sha(bytes) + if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`) +} + +function sha(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + maxBuffer: 128 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`) + return result +} diff --git a/infra/deploy-runner/build-engine-provider-authority-diagnostics-artifact.mjs b/infra/deploy-runner/build-engine-provider-authority-diagnostics-artifact.mjs new file mode 100644 index 0000000..eac127f --- /dev/null +++ b/infra/deploy-runner/build-engine-provider-authority-diagnostics-artifact.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(here, "../../.."); +const engineRoot = resolve( + process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"), +); +const artifactRoot = resolve( + process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"), +); +const [patchId = "", ...extra] = process.argv.slice(2); +if (extra.length || !/^engine-provider-authority-diagnostics-\d{8}-\d{3}$/.test(patchId)) { + throw new Error( + "usage: build-engine-provider-authority-diagnostics-artifact.mjs " + + "", + ); +} + +const targetSha256 = Object.freeze({ + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": + "1a14299167ebe17efd677fa3c59b84c80e12d6846bac6f40f9bcae0729ab22c6", +}); +const entries = Object.freeze(Object.keys(targetSha256)); +const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`); + +await assertFresh(artifact); +await assertExactSources(); +const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-authority-diagnostics-")); +try { + const payload = join(stage, "payload"); + for (const relativePath of entries) { + const destination = join(payload, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(join(engineRoot, relativePath), destination); + } + await writeFile( + join(stage, "manifest.env"), + `id=${patchId}\ncomponent=engine\ntype=app-overlay\n`, + { encoding: "utf8", flag: "wx", mode: 0o644 }, + ); + await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o644, + }); + await mkdir(artifactRoot, { recursive: true }); + run("python3", ["-c", canonicalTarScript(), artifact, stage]); + console.log(JSON.stringify({ + ok: true, + patchId, + artifact, + sha256: digest(await readFile(artifact)), + entries, + targetSha256, + services: ["nodedc-backend"], + transition: "exact-safe-provider-authority-reason-codes", + providerPackage: "gelios.provider.v4", + dataProductId: "fleet.positions.current.v3", + credentialValues: "preserved", + untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"], + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertExactSources() { + for (const [relativePath, expected] of Object.entries(targetSha256)) { + const sourcePath = join(engineRoot, relativePath); + const info = await lstat(sourcePath); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error(`engine_provider_authority_diagnostics_source_unsafe:${relativePath}`); + } + const actual = digest(await readFile(sourcePath)); + if (actual !== expected) { + throw new Error( + `engine_provider_authority_diagnostics_target_mismatch:${relativePath}:` + + `expected=${expected}:actual=${actual}`, + ); + } + const source = await readFile(sourcePath, "utf8"); + for (const marker of [ + "publish_grant_provider_request_not_exact", + "publish_grant_provider_credential_binding_invalid", + "publish_grant_provider_transport_policy_not_exact", + "publish_grant_provider_request_path_unreachable", + "publish_grant_provider_credential_identity_mismatch", + ]) { + if (!source.includes(marker)) { + throw new Error(`engine_provider_authority_diagnostics_marker_missing:${marker}`); + } + } + } +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("artifact_already_exists"); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'xb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error(`${command}_failed:${result.stderr || result.stdout}`); + } +} diff --git a/infra/deploy-runner/build-engine-provider-rotating-slot-artifact.mjs b/infra/deploy-runner/build-engine-provider-rotating-slot-artifact.mjs new file mode 100644 index 0000000..98ca4aa --- /dev/null +++ b/infra/deploy-runner/build-engine-provider-rotating-slot-artifact.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(here, "../../.."); +const engineRoot = resolve( + process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"), +); +const artifactRoot = resolve( + process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"), +); +const [patchId = "", ...extra] = process.argv.slice(2); +if (extra.length || !/^engine-provider-rotating-slot-\d{8}-\d{3}$/.test(patchId)) { + throw new Error( + "usage: build-engine-provider-rotating-slot-artifact.mjs " + + "", + ); +} + +const targetSha256 = Object.freeze({ + "nodedc-source/server/assets/provider-packages/v1/catalog.json": + "17f3e368f3264cbbd708965c9e1fd735aa974f15a1383bf88cd6d14a43dbf32d", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": + "f6cf5f4de9e57f87fb904e2136a9f34d494e1ffc289aec3ed9ed788b5583a062", +}); +const entries = Object.freeze(Object.keys(targetSha256)); +const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`); + +await assertFresh(artifact); +await assertExactSources(); +const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-rotating-slot-")); +try { + const payload = join(stage, "payload"); + for (const relativePath of entries) { + const destination = join(payload, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(join(engineRoot, relativePath), destination); + } + await writeFile( + join(stage, "manifest.env"), + `id=${patchId}\ncomponent=engine\ntype=app-overlay\n`, + { encoding: "utf8", flag: "wx", mode: 0o644 }, + ); + await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o644, + }); + await mkdir(artifactRoot, { recursive: true }); + run("python3", ["-c", canonicalTarScript(), artifact, stage]); + console.log(JSON.stringify({ + ok: true, + patchId, + artifact, + sha256: digest(await readFile(artifact)), + entries, + targetSha256, + services: ["nodedc-backend"], + transition: "exact-active-credential-slot-alignment", + providerPackage: "gelios.provider.v4", + authModeId: "gelios.rest-rotating-bearer.v3", + credentialSlot: "ndcProviderRotatingAccessApi", + credentialValues: "preserved", + untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"], + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertExactSources() { + for (const [relativePath, expected] of Object.entries(targetSha256)) { + const path = join(engineRoot, relativePath); + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error(`engine_provider_rotating_slot_source_unsafe:${relativePath}`); + } + const actual = digest(await readFile(path)); + if (actual !== expected) { + throw new Error( + `engine_provider_rotating_slot_target_mismatch:${relativePath}:` + + `expected=${expected}:actual=${actual}`, + ); + } + } + const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8")); + const provider = catalog?.packages?.[0]; + if ( + provider?.id !== "gelios.provider.v4" || + provider?.providerCredential?.authModeId !== "gelios.rest-rotating-bearer.v3" || + provider?.providerCredential?.credentialType !== "ndcProviderRotatingAccessApi" + ) { + throw new Error("engine_provider_rotating_slot_catalog_projection_mismatch"); + } +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("artifact_already_exists"); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'xb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error(`${command}_failed:${result.stderr || result.stdout}`); + } +} diff --git a/infra/deploy-runner/build-engine-provider-security-catalog-artifact.mjs b/infra/deploy-runner/build-engine-provider-security-catalog-artifact.mjs new file mode 100644 index 0000000..92505ac --- /dev/null +++ b/infra/deploy-runner/build-engine-provider-security-catalog-artifact.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(here, "../.."); +const artifactRoot = resolve( + process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"), +); +const [transitionId = "20260719-006", ...extra] = process.argv.slice(2); +if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) { + throw new Error("usage: build-engine-provider-security-catalog-artifact.mjs [YYYYMMDD-NNN]"); +} + +const id = `engine-provider-security-catalog-${transitionId}`; +const target = join(artifactRoot, `nodedc-${id}.tgz`); +const file = "nodedc-source/server/assets/provider-packages/v1/catalog.json"; +const expectedSha256 = "992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428"; +const catalogBytes = Buffer.from(`${JSON.stringify({ + schemaVersion: "nodedc.engine.provider-security-catalog/v1", + packages: [{ + id: "gelios.provider.v3", + version: "3.0.0", + providerId: "gelios", + providerCredential: { + authModeId: "gelios.rest-rotating-bearer.v3", + credentialType: "httpBearerAuth", + }, + capabilities: [{ + id: "gelios.units.current.read", + classification: "read", + status: "implemented", + request: { + method: "GET", + url: "https://api.geliospro.com/api/v1/units", + }, + dataProductIds: ["fleet.positions.current.v2"], + }], + publisher: { + nodeType: "n8n-nodes-ndc.ndcDataProductPublish", + credentialType: "ndcDataProductWriterApi", + }, + }], +}, null, 2)}\n`, "utf8"); + +await assertFresh(target); +if (sha(catalogBytes) !== expectedSha256) throw new Error(`pinned_catalog_sha256_mismatch:${file}`); + +const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-security-catalog-")); +const payload = join(stage, "payload"); +try { + await mkdir(dirname(join(payload, file)), { recursive: true }); + await writeFile(join(payload, file), catalogBytes, { flag: "wx", mode: 0o644 }); + await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8"); + await writeFile(join(stage, "files.txt"), `${file}\n`, "utf8"); + await mkdir(artifactRoot, { recursive: true }); + run("python3", ["-c", canonicalTarScript(), target, stage]); + console.log(JSON.stringify({ + ok: true, + id, + artifact: target, + artifactSha256: sha(await readFile(target)), + services: ["nodedc-backend"], + providerPackage: "gelios.provider.v3", + providerCredential: "httpBearerAuth", + endpoint: "https://api.geliospro.com/api/v1/units", + dataProductId: "fleet.positions.current.v2", + preserved: ["n8n", "L1 graph", "Engine UI", "databases", "provider credential values"], + files: [file], + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("artifact_already_exists"); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +function sha(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`); + return result; +} diff --git a/infra/deploy-runner/build-engine-provider-target-host-policy-artifact.mjs b/infra/deploy-runner/build-engine-provider-target-host-policy-artifact.mjs new file mode 100644 index 0000000..786fd77 --- /dev/null +++ b/infra/deploy-runner/build-engine-provider-target-host-policy-artifact.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(here, "../../.."); +const engineRoot = resolve( + process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"), +); +const artifactRoot = resolve( + process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"), +); +const [patchId = "", ...extra] = process.argv.slice(2); +if (extra.length || !/^engine-provider-target-host-policy-\d{8}-\d{3}$/.test(patchId)) { + throw new Error( + "usage: build-engine-provider-target-host-policy-artifact.mjs " + + "", + ); +} + +const targetSha256 = Object.freeze({ + "nodedc-source/server/routes/n8n.js": + "9bc3638e271102abec91bb80413329befb89d60c0e4dc0548f0dd11e93220d0a", +}); +const entries = Object.freeze(Object.keys(targetSha256)); +const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`); + +await assertFresh(artifact); +await assertExactSources(); +const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-target-host-policy-")); +try { + const payload = join(stage, "payload"); + for (const relativePath of entries) { + const destination = join(payload, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(join(engineRoot, relativePath), destination); + } + await writeFile( + join(stage, "manifest.env"), + `id=${patchId}\ncomponent=engine\ntype=app-overlay\n`, + { encoding: "utf8", flag: "wx", mode: 0o644 }, + ); + await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o644, + }); + await mkdir(artifactRoot, { recursive: true }); + run("python3", ["-c", canonicalTarScript(), artifact, stage]); + console.log(JSON.stringify({ + ok: true, + patchId, + artifact, + sha256: digest(await readFile(artifact)), + entries, + targetSha256, + services: ["nodedc-backend"], + transition: "exact-provider-literal-target-host", + providerPackage: "gelios.provider.v4", + dataProductId: "fleet.positions.current.v3", + credentialValues: "preserved", + untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"], + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertExactSources() { + for (const [relativePath, expected] of Object.entries(targetSha256)) { + const sourcePath = join(engineRoot, relativePath); + const info = await lstat(sourcePath); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error(`engine_provider_target_host_policy_source_unsafe:${relativePath}`); + } + const actual = digest(await readFile(sourcePath)); + if (actual !== expected) { + throw new Error( + `engine_provider_target_host_policy_target_mismatch:${relativePath}:` + + `expected=${expected}:actual=${actual}`, + ); + } + const source = await readFile(sourcePath, "utf8"); + for (const marker of [ + "const eligibleHosts = explicitHosts.length", + "allowedHosts: [targetHost]", + "credential_host_not_allowed", + ]) { + if (!source.includes(marker)) { + throw new Error(`engine_provider_target_host_policy_marker_missing:${marker}`); + } + } + } +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("artifact_already_exists"); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'xb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error(`${command}_failed:${result.stderr || result.stdout}`); + } +} diff --git a/infra/deploy-runner/build-external-data-plane-artifact.mjs b/infra/deploy-runner/build-external-data-plane-artifact.mjs index fa1de6d..701c225 100644 --- a/infra/deploy-runner/build-external-data-plane-artifact.mjs +++ b/infra/deploy-runner/build-external-data-plane-artifact.mjs @@ -24,7 +24,9 @@ const files = [ ["packages/external-provider-contract/src/data-product.mjs", "platform/packages/external-provider-contract/src/data-product.mjs"], ["packages/external-provider-contract/src/intake-batch.mjs", "platform/packages/external-provider-contract/src/intake-batch.mjs"], ["packages/external-provider-contract/src/index.mjs", "platform/packages/external-provider-contract/src/index.mjs"], + ["packages/external-provider-contract/src/provider-package.mjs", "platform/packages/external-provider-contract/src/provider-package.mjs"], ["packages/external-provider-contract/src/sensitive-field-policy.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs"], + ["packages/external-provider-contract/providers/gelios", "platform/packages/external-provider-contract/providers/gelios"], ]; const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); const ignoredDirectoryNames = new Set(["test"]); diff --git a/infra/deploy-runner/build-module-foundry-consumer-policy-artifact.mjs b/infra/deploy-runner/build-module-foundry-consumer-policy-artifact.mjs new file mode 100644 index 0000000..0ce3a7d --- /dev/null +++ b/infra/deploy-runner/build-module-foundry-consumer-policy-artifact.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(scriptDir, "../.."); +const workspaceRoot = resolve(platformRoot, ".."); +const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE"); +const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [patchId = "module-foundry-consumer-policy-v4-20260720-001", ...extra] = process.argv.slice(2); + +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { + throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen"); +} + +const files = [ + "registry/data-product-consumer-policies.json", + "scripts/validate-registry.mjs", + "server/foundry-data-product-consumer.mjs", + "server/foundry-data-product-consumer.test.mjs", +]; +const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-consumer-policy-artifact-")); +const payload = join(stage, "payload"); +const artifact = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`); +const checksum = `${artifact}.sha256`; + +await assertConsumerPolicyBoundary(); + +try { + await mkdir(payload, { recursive: true }); + for (const relativePath of files) { + const source = resolve(foundryRoot, relativePath); + const sourceStat = await lstat(source); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + throw new Error(`source_file_rejected:${relativePath}`); + } + const destination = join(payload, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { force: true, verbatimSymlinks: true }); + } + + await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8"); + await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); + await mkdir(artifactDir, { recursive: true }); + + const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); + + const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex"); + await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8"); + console.log(JSON.stringify({ ok: true, patchId, artifact, checksum, sha256, files }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertConsumerPolicyBoundary() { + const registry = JSON.parse(await readFile(resolve(foundryRoot, files[0]), "utf8")); + if (registry?.schemaVersion !== "nodedc.foundry.data-product-consumer-policies/v1") { + throw new Error("foundry_consumer_policy_registry_invalid"); + } + const matches = registry.policies?.filter((policy) => ( + policy?.dataProductId === "fleet.positions.current.v4" && policy?.productVersion === "4.0.0" + )) || []; + if (matches.length !== 1) throw new Error("foundry_consumer_policy_v4_missing_or_ambiguous"); + const policy = matches[0]; + if ( + policy.id !== "map-moving-object-current-v4" + || policy.version !== "4.0.0" + || policy.staleAfterMs !== null + || policy.statusContract?.attribute !== "signal_state" + || JSON.stringify(policy.statusContract?.allowedValues) !== JSON.stringify(["active", "inactive"]) + || policy.statusContract?.missing !== "reject" + || policy.statusContract?.freshness !== "none" + || JSON.stringify(policy.terminalStatuses) !== JSON.stringify(["inactive"]) + || policy.removeMode !== "canonical-tombstone-or-snapshot-rebase" + ) throw new Error("foundry_consumer_policy_v4_contract_invalid"); + + const serializedPolicy = JSON.stringify(policy); + if (/(provider|tenant|connection|endpoint|credential|token|secret|authorization)/i.test(serializedPolicy)) { + throw new Error("foundry_consumer_policy_v4_transport_boundary_violation"); + } + + const consumer = await readFile(resolve(foundryRoot, files[2]), "utf8"); + for (const marker of [ + "data_product_consumer_fact_status_invalid", + "data_product_consumer_status_field_not_projected", + 'statusContract.freshness === "none"', + ]) { + if (!consumer.includes(marker)) throw new Error(`foundry_consumer_policy_v4_runtime_guard_missing:${marker}`); + } +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} diff --git a/infra/deploy-runner/build-module-foundry-filter-toggle-artifact.mjs b/infra/deploy-runner/build-module-foundry-filter-toggle-artifact.mjs new file mode 100644 index 0000000..7dcac0a --- /dev/null +++ b/infra/deploy-runner/build-module-foundry-filter-toggle-artifact.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(scriptDir, "../.."); +const workspaceRoot = resolve(platformRoot, ".."); +const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE"); +const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [patchId = "module-foundry-filter-toggle-20260720-001", ...extra] = process.argv.slice(2); + +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { + throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen"); +} + +const files = [ + "apps/catalog/src/MapFixturePreview.tsx", + "apps/catalog/src/mapPresentationProfile.ts", + "scripts/map-presentation-filters.test.mjs", +]; +const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-filter-toggle-artifact-")); +const payload = join(stage, "payload"); +const artifact = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`); +const checksum = `${artifact}.sha256`; + +await assertFilterToggleBoundary(); + +try { + await mkdir(payload, { recursive: true }); + for (const relativePath of files) { + const source = resolve(foundryRoot, relativePath); + const sourceStat = await lstat(source); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + throw new Error(`source_file_rejected:${relativePath}`); + } + const destination = join(payload, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { force: true, verbatimSymlinks: true }); + } + + await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8"); + await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); + await mkdir(artifactDir, { recursive: true }); + + const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); + + const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex"); + await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8"); + console.log(JSON.stringify({ ok: true, patchId, artifact, checksum, sha256, files }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertFilterToggleBoundary() { + const helper = await readFile(resolve(foundryRoot, files[1]), "utf8"); + for (const marker of [ + "toggleMapPresentationFacetSelection", + "const { [field]: _removed, ...unconstrained } = facets", + "return unconstrained", + ]) { + if (!helper.includes(marker)) throw new Error(`foundry_filter_toggle_helper_missing:${marker}`); + } + + const preview = await readFile(resolve(foundryRoot, files[0]), "utf8"); + if (!preview.includes("filters: toggleMapPresentationFacetSelection(filters, field, value)")) { + throw new Error("foundry_filter_toggle_component_contract_missing"); + } + + const test = await readFile(resolve(foundryRoot, files[2]), "utf8"); + for (const marker of [ + "interactive deselect of the last chip removes the facet constraint", + "interactive deselect preserves other values and facet constraints", + "deselecting the last chip remains empty and never normalizes to all", + ]) { + if (!test.includes(marker)) throw new Error(`foundry_filter_toggle_regression_missing:${marker}`); + } +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} diff --git a/infra/deploy-runner/build-ontology-core-artifact.mjs b/infra/deploy-runner/build-ontology-core-artifact.mjs new file mode 100644 index 0000000..64c8ac1 --- /dev/null +++ b/infra/deploy-runner/build-ontology-core-artifact.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(scriptDir, "../.."); +const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [patchId = "ontology-core-20260719-001", ...extra] = process.argv.slice(2); + +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { + throw new Error("usage: build-ontology-core-artifact.mjs [patch-id]"); +} + +const sourceRoot = resolve(platformRoot, "services/ontology-core"); +const destinationRoot = "platform/ontology-core"; +const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules", "test"]); +const stage = await mkdtemp(join(tmpdir(), "nodedc-ontology-core-artifact-")); +const payload = join(stage, "payload"); +const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`); + +try { + await copySafe(sourceRoot, join(payload, destinationRoot)); + await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8"); + await writeFile(join(stage, "files.txt"), `${destinationRoot}\n`, "utf8"); + await mkdir(artifactDir, { recursive: true }); + + const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + }); + if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); + + console.log(JSON.stringify({ + ok: true, + patchId, + artifact: target, + sha256: createHash("sha256").update(await readFile(target)).digest("hex"), + entries: [destinationRoot], + servicesExpected: ["ontology-core", "ai-workspace-hub"], + excluded: [".env*", "node_modules", "test", "secrets"], + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +async function copySafe(source, destination) { + const sourceStat = await lstat(source); + if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, source)}`); + if (sourceStat.isFile()) { + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { force: true, verbatimSymlinks: true }); + return; + } + if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${relative(platformRoot, source)}`); + + await mkdir(destination, { recursive: true }); + for (const entry of await readdir(source, { withFileTypes: true })) { + if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) continue; + const childSource = join(source, entry.name); + const childDestination = join(destination, entry.name); + if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`); + await copySafe(childSource, childDestination); + } +} diff --git a/infra/deploy-runner/build-platform-gelios-provider-v3-artifact.mjs b/infra/deploy-runner/build-platform-gelios-provider-v3-artifact.mjs new file mode 100644 index 0000000..7bc6801 --- /dev/null +++ b/infra/deploy-runner/build-platform-gelios-provider-v3-artifact.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(here, "../.."); +const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts")); +const [transitionId = "20260719-005", ...extra] = process.argv.slice(2); +if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) { + throw new Error("usage: build-platform-gelios-provider-v3-artifact.mjs [YYYYMMDD-NNN]"); +} + +const id = `platform-gelios-provider-v3-${transitionId}`; +const target = join(artifactRoot, `nodedc-${id}.tgz`); +const files = [ + "platform/packages/external-provider-contract/providers/gelios/v3/README.md", + "platform/packages/external-provider-contract/providers/gelios/v3/index.mjs", + "platform/packages/external-provider-contract/providers/gelios/v3/package.mjs", +]; +const expectedSha256 = new Map([ + [files[0], "7ac2318e455786895bebf2e7ee75ae9f86e8958fc5d4699fd0c8c603b8a92ca1"], + [files[1], "dbb82752248fd45c4d3670dadb435123d0575881bfa9458fc0757a421471de24"], + [files[2], "67170084c2d55c3adbed05f1d63c71403689fb59ebc9811e495a55a3c4fa41bf"], +]); + +await assertFresh(target); +const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-gelios-provider-v3-")); +const payload = join(stage, "payload"); +try { + await mkdir(payload, { recursive: true }); + for (const rel of files) { + const source = join(platformRoot, rel.replace(/^platform\//, "")); + const stat = await lstat(source); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`); + const bytes = await readFile(source); + if (sha(bytes) !== expectedSha256.get(rel)) throw new Error(`source_sha256_mismatch:${rel}`); + await mkdir(dirname(join(payload, rel)), { recursive: true }); + await cp(source, join(payload, rel), { force: false }); + } + await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=platform\ntype=app-overlay\n`, "utf8"); + await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); + await mkdir(artifactRoot, { recursive: true }); + run("python3", ["-c", canonicalTarScript(), target, stage]); + console.log(JSON.stringify({ + ok: true, + id, + artifact: target, + artifactSha256: sha(await readFile(target)), + services: [], + providerPackage: "gelios.provider.v3", + dataProductId: "fleet.positions.current.v2", + runtimeEffect: "catalog-only; no service restart", + files, + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("artifact_already_exists"); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +function sha(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`); + return result; +} diff --git a/infra/deploy-runner/build-platform-gelios-provider-v4-artifact.mjs b/infra/deploy-runner/build-platform-gelios-provider-v4-artifact.mjs new file mode 100644 index 0000000..3108845 --- /dev/null +++ b/infra/deploy-runner/build-platform-gelios-provider-v4-artifact.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(here, "../.."); +const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts")); +const [transitionId = "20260720-001", ...extra] = process.argv.slice(2); +if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) { + throw new Error("usage: build-platform-gelios-provider-v4-artifact.mjs [YYYYMMDD-NNN]"); +} + +const id = `platform-gelios-provider-v4-${transitionId}`; +const target = join(artifactRoot, `nodedc-${id}.tgz`); +const files = [ + "platform/packages/external-provider-contract/src/provider-package.mjs", + "platform/packages/external-provider-contract/providers/gelios/v4/README.md", + "platform/packages/external-provider-contract/providers/gelios/v4/index.mjs", + "platform/packages/external-provider-contract/providers/gelios/v4/package.mjs", + "platform/services/external-data-plane/definitions/fleet.positions.current.v3.json", +]; +const expectedSha256 = new Map([ + [files[0], "8ae73cae0be8cbf8484125e1ccf836fdc245b31872a5c7888ced289ed1895ba2"], + [files[1], "9f38f5cb267269b54053fa590a3970abb4b6b9803fc931e997be4293aa6e592a"], + [files[2], "b92fb1fed6ff2fe29c3dc77978e7c35dcdf4c7f2af4ae63ada23e9c3119e7fd9"], + [files[3], "55a46f825e12bcef2354fefd956e674b72fea68b6997df3fcbe89b0a074ec9b8"], + [files[4], "04d458f050313468990849f60d37a8a6c9aadd355137d4084b66556bb4a4b673"], +]); + +await assertFresh(target); +const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-gelios-provider-v4-")); +const payload = join(stage, "payload"); +try { + await mkdir(payload, { recursive: true }); + for (const rel of files) { + const source = join(platformRoot, rel.replace(/^platform\//, "")); + const stat = await lstat(source); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`); + const bytes = await readFile(source); + if (sha(bytes) !== expectedSha256.get(rel)) throw new Error(`source_sha256_mismatch:${rel}`); + await mkdir(dirname(join(payload, rel)), { recursive: true }); + await cp(source, join(payload, rel), { force: false }); + } + await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=platform\ntype=app-overlay\n`, "utf8"); + await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); + await mkdir(artifactRoot, { recursive: true }); + run("python3", ["-c", canonicalTarScript(), target, stage]); + console.log(JSON.stringify({ + ok: true, + id, + artifact: target, + artifactSha256: sha(await readFile(target)), + services: ["external-data-plane"], + ontologyPackage: "gelios@1.1.0", + providerPackage: "gelios.provider.v4", + dataProductId: "fleet.positions.current.v3", + files, + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("artifact_already_exists"); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +function sha(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`); +} diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 2477285..5b48684 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -208,6 +208,45 @@ ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256 = { ENGINE_MCP_ONTOLOGY_SDK_NEW_PATHS = ( "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz", ) +ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES = ( + "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs", + "nodedc-source/server/assets/engine-agent-npm/package.json", + "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz", + "nodedc-source/server/assets/provider-packages/v1/catalog.json", + "nodedc-source/server/engineAgents/store.js", + ENGINE_NODE_INTELLIGENCE_GATEWAY_REL, + ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL, +) +ENGINE_MCP_AUTONOMY_PROVIDER_V5_PREDECESSOR_SHA256 = { + "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445", + "nodedc-source/server/assets/engine-agent-npm/package.json": "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597", + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "17f3e368f3264cbbd708965c9e1fd735aa974f15a1383bf88cd6d14a43dbf32d", + "nodedc-source/server/engineAgents/store.js": "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f", + ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15", +} +ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256 = { + "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "c15c9da4f90f44a4e9e12f3683127e906d614fb98e562faa0c939505c973e074", + "nodedc-source/server/assets/engine-agent-npm/package.json": "2ca8dcab0fa04bb1b21add1f75a9be61d5aa97753443ee1917302b4e0da5780a", + "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz": "d007a81cb4e4af569b3c54d3869b0f60b5597e3b531bff232145fa1851d8572a", + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "63e0741646197f0b1b3c64a4095e1bc8fb3a95ee6caf20b0293f89d869c9e620", + "nodedc-source/server/engineAgents/store.js": "4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285", + ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c", +} +ENGINE_MCP_AUTONOMY_PROVIDER_V5_NEW_PATHS = ( + "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz", +) +ENGINE_PROVIDER_SECURITY_CATALOG_REL = ( + "nodedc-source/server/assets/provider-packages/v1/catalog.json" +) +ENGINE_PROVIDER_SECURITY_CATALOG_ARTIFACT_ENTRIES = ( + ENGINE_PROVIDER_SECURITY_CATALOG_REL, +) +ENGINE_PROVIDER_SECURITY_CATALOG_PREDECESSOR_SHA256 = ( + "30f89052b6557a2444550bd6e7eed30747b2d9b7ff3a22d079d9905fe0a04702" +) +ENGINE_PROVIDER_SECURITY_CATALOG_TARGET_SHA256 = ( + "992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428" +) ENGINE_CONTROL_PLANE_STATE_REL = "nodedc-control-plane" ENGINE_PUBLISH_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/publish-grants" ENGINE_READ_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/read-grants" @@ -321,6 +360,68 @@ ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES = ( "signedDataPlaneClient.js", "store.js", ) +ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256 = ( + "992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428" +) +ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256 = ( + "9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a" +) +ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS = ( + "nodedc-source/server/assets/provider-packages/v1/catalog.json", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js", + "nodedc-source/server/dataProductPublishGrant/service.js", + "nodedc-source/server/dataProductPublishGrant/store.js", +) +ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES = ( + "nodedc-source/server/assets/provider-packages/v1/catalog.json", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js", + "nodedc-source/server/dataProductPublishGrant/service.js", + "nodedc-source/server/dataProductPublishGrant/store.js", +) +ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256 = { + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d", + # Publish Grant 20260718-004 was applied after MCP Control Plane + # 20260718-003 and is therefore the authoritative installed predecessor. + "nodedc-source/server/dataProductPublishGrant/service.js": "6a417b25b080c05b40c771df4e2dca163491af2c9083ff73dc7e54ad3b360241", + "nodedc-source/server/dataProductPublishGrant/store.js": "a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c", +} +ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256 = { + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f", + "nodedc-source/server/dataProductPublishGrant/service.js": "83f045f4e0f332644310172ed51bb652d808bf04155b11c02e46bdffc95f7220", + "nodedc-source/server/dataProductPublishGrant/store.js": "98662da6acd0489a9cae4b726eb61ce89d9b2c788b2ea95433e9c4f02930c095", +} +ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES = ( + "nodedc-source/server/assets/provider-packages/v1/catalog.json", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js", +) +ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256 = { + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f", +} +ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256 = { + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "17f3e368f3264cbbd708965c9e1fd735aa974f15a1383bf88cd6d14a43dbf32d", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "f6cf5f4de9e57f87fb904e2136a9f34d494e1ffc289aec3ed9ed788b5583a062", +} +ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES = ( + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js", +) +ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_PREDECESSOR_SHA256 = { + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "f6cf5f4de9e57f87fb904e2136a9f34d494e1ffc289aec3ed9ed788b5583a062", +} +ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256 = { + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "1a14299167ebe17efd677fa3c59b84c80e12d6846bac6f40f9bcae0729ab22c6", +} +ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES = ( + "nodedc-source/server/routes/n8n.js", +) +ENGINE_PROVIDER_TARGET_HOST_POLICY_PREDECESSOR_SHA256 = { + "nodedc-source/server/routes/n8n.js": "f293a7794405badbabd2bf7ef088f96fe1167d9e249f05cbfc8af280a0d3e8f8", +} +ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256 = { + "nodedc-source/server/routes/n8n.js": "9bc3638e271102abec91bb80413329befb89d60c0e4dc0548f0dd11e93220d0a", +} ENGINE_AGENT_FULL_GRANT_MIGRATION_ARTIFACT_ENTRIES = ( ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL, ) @@ -338,6 +439,13 @@ ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = { "nodedc-source/server/routes/ndcAgentMcp.js": "534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962", ENGINE_CREDENTIAL_BACKEND_OVERRIDE_TEMPLATE_REL: "944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe", } +ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = { + **ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256, + # This exact successor was installed by the later restart-safe-auth + # transition; initial Publish-grant installation still uses the original + # predecessor map above. + "nodedc-source/server/index.js": "0ac408e0e9a7bc5c8e13a00afc957b982b065e2bc414919839e0b0a11aa05ba4", +} ENGINE_CREDENTIAL_SINK_CONTRACT_SHA256 = "b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b" ENGINE_BASE_COMPOSE_SERVICES = ( "postgresql", @@ -3475,6 +3583,523 @@ process.stdout.write('engine-mcp-ontology-sdk:0.6.0:0.1.5:gelios.provider.v2:htt } +def is_engine_mcp_autonomy_provider_v5_slice(component, entries): + return ( + component == "engine" + and entries is not None + and tuple(entries) == ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES + ) + + +def validate_engine_mcp_autonomy_provider_v5_payload(payload_dir, entries): + if tuple(entries) != ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES: + die("Engine MCP autonomy/provider v5 files.txt exact set/order mismatch") + for rel, expected_sha256 in ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256.items(): + path = payload_dir / rel + if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256: + die(f"Engine MCP autonomy/provider v5 target sha256 mismatch: {rel}") + + gateway = (payload_dir / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL).read_text( + encoding="utf-8" + ) + installer_path = ( + payload_dir + / "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs" + ) + installer = installer_path.read_text(encoding="utf-8") + package_path = payload_dir / "nodedc-source/server/assets/engine-agent-npm/package.json" + package = read_strict_json(package_path, "Engine MCP Codex installer package") + if package.get("name") != "@nodedc/engine-codex-agent" or package.get("version") != "0.1.6": + die("Engine MCP autonomy/provider v5 installer package identity mismatch") + if any( + marker not in gateway + for marker in ( + "const ENGINE_AGENT_MCP_VERSION = '0.6.0'", + "authenticateEngineAgentOntologyToken", + "apiRouter.post('/ontology-mcp'", + "'nodedc-engine-codex-agent-0.1.6.tgz'", + ) + ): + die("Engine MCP autonomy/provider v5 gateway contract is incomplete") + if any( + marker not in installer + for marker in ( + "MCP tool availability establishes capability authority", + "the user's requested objective establishes intent scope", + "machine safety barrier, not a permission ceremony", + "Never repeat an identical read, validation, apply or run", + "Three identical failures with no new evidence or state change are a critical stop", + "const ONTOLOGY_SERVER_NAME = 'nodedc_ontology'", + "version: '0.1.6'", + ) + ) or "only with explicit user confirmation" in installer: + die("Engine MCP capability-scoped autonomy policy is incomplete") + + archive_path = payload_dir / "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz" + expected_members = { + "package/bin/nodedc-engine-codex-agent.mjs": installer_path.read_bytes(), + "package/package.json": package_path.read_bytes(), + } + observed_members = {} + try: + with tarfile.open(archive_path, "r:gz") as archive: + for member in archive: + if not (member.isfile() or member.isdir()) or member.name.startswith("/"): + die("Engine MCP autonomy/provider v5 installer archive member is unsafe") + if member.isfile(): + source = archive.extractfile(member) + if source is None: + die("Engine MCP autonomy/provider v5 installer archive member is unreadable") + observed_members[member.name] = source.read(MAX_FILE_BYTES + 1) + except (OSError, tarfile.TarError): + die("Engine MCP autonomy/provider v5 installer archive is invalid") + if observed_members != expected_members: + die("Engine MCP autonomy/provider v5 installer archive/source equality mismatch") + + catalog = read_strict_json( + payload_dir / "nodedc-source/server/assets/provider-packages/v1/catalog.json", + "Engine provider security catalog", + ) + packages = catalog.get("packages") if isinstance(catalog, dict) else None + if ( + catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1" + or not isinstance(packages, list) + or [item.get("id") for item in packages if isinstance(item, dict)] + != ["gelios.provider.v4", "gelios.provider.v5"] + ): + die("Engine Gelios provider v4/v5 catalog identity mismatch") + expected_products = { + "gelios.provider.v4": "fleet.positions.current.v3", + "gelios.provider.v5": "fleet.positions.current.v4", + } + expected_requests = [ + ("gelios.monitoring_config.current.read", "https://api.geliospro.com/api/v1/users/me/monitoring-config"), + ("gelios.units.current.read", "https://api.geliospro.com/api/v1/units?incltrip=true"), + ] + for provider in packages: + provider_id = provider.get("id") + credential = provider.get("providerCredential") or {} + capabilities = provider.get("capabilities") + observed_requests = [ + (item.get("id"), (item.get("request") or {}).get("url")) + for item in capabilities + ] if isinstance(capabilities, list) else None + if ( + provider_id not in expected_products + or credential.get("credentialType") != "ndcProviderRotatingAccessApi" + or credential.get("authModeId") != "gelios.rest-rotating-bearer.v3" + or observed_requests != expected_requests + or any(item.get("dataProductIds") != [expected_products[provider_id]] for item in capabilities) + ): + die("Engine Gelios provider v4/v5 security projection mismatch") + + store = (payload_dir / "nodedc-source/server/engineAgents/store.js").read_text( + encoding="utf-8" + ) + if any( + marker not in store + for marker in ( + "const STORE_VERSION = 3", + "ontologyTokenHash", + "authenticateEngineAgentOntologyToken", + ) + ): + die("Engine MCP autonomy/provider v5 Ontology token store migration is incomplete") + + descriptor = read_engine_node_intelligence_descriptor( + payload_dir / ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL, + "Engine MCP autonomy/provider v5 node-intelligence descriptor", + ) + if ( + descriptor.get("action") != "activate" + or descriptor["source"].get("gatewaySha256") + != ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL] + or descriptor["source"].get("upstreamProjectionSha256") + != ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[ + "nodedc-source/server/nodeIntelligence/upstreamProjection.js" + ] + ): + die("Engine MCP autonomy/provider v5 descriptor target mismatch") + return descriptor + + +def preflight_engine_mcp_autonomy_provider_v5_predecessor(payload_dir): + candidate = validate_engine_mcp_autonomy_provider_v5_payload( + payload_dir, + ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES, + ) + installed = current_engine_node_intelligence_descriptor() + validate_installed_engine_node_intelligence_source(installed) + if installed is None or installed.get("action") != "activate": + die("Engine MCP autonomy/provider v5 requires active node intelligence") + expected_candidate = json.loads(json.dumps(installed)) + expected_candidate["source"]["gatewaySha256"] = ( + ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL] + ) + if candidate != expected_candidate: + die("Engine MCP autonomy/provider v5 crosses node-intelligence source boundary") + root = component_root("engine") + for rel, expected_sha256 in ENGINE_MCP_AUTONOMY_PROVIDER_V5_PREDECESSOR_SHA256.items(): + path = root / rel + if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256: + die(f"Engine MCP autonomy/provider v5 predecessor drift detected: {rel}") + for rel in ENGINE_MCP_AUTONOMY_PROVIDER_V5_NEW_PATHS: + path = root / rel + if path.exists() or path.is_symlink(): + die(f"Engine MCP autonomy/provider v5 new path already exists: {rel}") + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine MCP autonomy/provider v5 requires the active immutable backend runtime") + return { + "descriptor": candidate, + "predecessor_gateway_sha256": installed["source"]["gatewaySha256"], + "target_gateway_sha256": candidate["source"]["gatewaySha256"], + "backend_mode": backend["mode"], + } + + +def engine_mcp_autonomy_provider_v5_installed_state(): + root = component_root("engine") + target = all( + (root / rel).is_file() and not (root / rel).is_symlink() + and sha256_file(root / rel) == expected + for rel, expected in ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256.items() + ) + descriptor = current_engine_node_intelligence_descriptor() + if descriptor is not None: + validate_installed_engine_node_intelligence_source(descriptor) + if target and descriptor and descriptor.get("source", {}).get("gatewaySha256") == ( + ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL] + ): + return "target" + predecessor = all( + (root / rel).is_file() and not (root / rel).is_symlink() + and sha256_file(root / rel) == expected + for rel, expected in ENGINE_MCP_AUTONOMY_PROVIDER_V5_PREDECESSOR_SHA256.items() + ) + new_paths_absent = all( + not (root / rel).exists() and not (root / rel).is_symlink() + for rel in ENGINE_MCP_AUTONOMY_PROVIDER_V5_NEW_PATHS + ) + if predecessor and new_paths_absent and descriptor and descriptor.get("source", {}).get("gatewaySha256") == ( + ENGINE_MCP_AUTONOMY_PROVIDER_V5_PREDECESSOR_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL] + ): + return "predecessor" + die("Engine MCP autonomy/provider v5 installed state is neither target nor rollback predecessor") + + +def accept_engine_mcp_autonomy_provider_v5_runtime(): + root = component_root("engine") + descriptor = validate_engine_mcp_autonomy_provider_v5_payload( + root, + ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES, + ) + installed = current_engine_node_intelligence_descriptor() + if installed != descriptor: + die("Engine MCP autonomy/provider v5 installed descriptor equality failed") + validate_installed_engine_node_intelligence_source(installed) + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine MCP autonomy/provider v5 backend runtime acceptance failed") + live = run_engine_backend_probe( + ( + "node", + "--input-type=module", + "-e", + """ +const gateway=await import('file:///app/server/routes/engineAgentGateway.js'); +const store=await import('file:///app/server/engineAgents/store.js'); +const fs=await import('node:fs/promises'); +const catalog=JSON.parse(await fs.readFile('/app/server/assets/provider-packages/v1/catalog.json','utf8')); +const pkg=JSON.parse(await fs.readFile('/app/server/assets/engine-agent-npm/package.json','utf8')); +const ids=catalog.packages?.map((item)=>item.id).join(','); +const target=catalog.packages?.find((item)=>item.id==='gelios.provider.v5'); +if(gateway.ENGINE_AGENT_MCP_VERSION!=='0.6.0')process.exit(2); +if(typeof store.authenticateEngineAgentOntologyToken!=='function')process.exit(3); +if(pkg.version!=='0.1.6'||ids!=='gelios.provider.v4,gelios.provider.v5')process.exit(4); +if(target?.capabilities?.some((item)=>item.dataProductIds?.[0]!=='fleet.positions.current.v4'))process.exit(5); +process.stdout.write('engine-mcp-autonomy-provider-v5:0.6.0:0.1.6:'+ids); +""".strip(), + ), + "Engine MCP autonomy/provider v5 capability", + container_id=engine_backend_container_id(), + ) + expected = "engine-mcp-autonomy-provider-v5:0.6.0:0.1.6:gelios.provider.v4,gelios.provider.v5" + if live != expected: + die("Engine MCP autonomy/provider v5 live capability acceptance mismatch") + return { + "gateway_sha256": descriptor["source"]["gatewaySha256"], + "backend_mode": backend["mode"], + "capability": live, + } + + +def is_engine_provider_security_catalog_slice(component, entries): + return ( + component == "engine" + and entries is not None + and tuple(entries) == ENGINE_PROVIDER_SECURITY_CATALOG_ARTIFACT_ENTRIES + ) + + +def validate_engine_provider_security_catalog_payload(payload_dir, entries): + if tuple(entries) != ENGINE_PROVIDER_SECURITY_CATALOG_ARTIFACT_ENTRIES: + die("Engine provider security catalog files.txt exact set/order mismatch") + catalog_path = payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL + if ( + catalog_path.is_symlink() + or not catalog_path.is_file() + or sha256_file(catalog_path) != ENGINE_PROVIDER_SECURITY_CATALOG_TARGET_SHA256 + ): + die("Engine provider security catalog target sha256 mismatch") + catalog = read_strict_json(catalog_path, "Engine provider security catalog") + packages = catalog.get("packages") if isinstance(catalog, dict) else None + provider = packages[0] if isinstance(packages, list) and len(packages) == 1 else None + capabilities = provider.get("capabilities") if isinstance(provider, dict) else None + capability = capabilities[0] if isinstance(capabilities, list) and len(capabilities) == 1 else None + request = capability.get("request") if isinstance(capability, dict) else None + credential = provider.get("providerCredential") if isinstance(provider, dict) else None + publisher = provider.get("publisher") if isinstance(provider, dict) else None + if ( + catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1" + or not isinstance(provider, dict) + or provider.get("id") != "gelios.provider.v3" + or provider.get("version") != "3.0.0" + or provider.get("providerId") != "gelios" + or credential != { + "authModeId": "gelios.rest-rotating-bearer.v3", + "credentialType": "httpBearerAuth", + } + or not isinstance(capability, dict) + or capability.get("id") != "gelios.units.current.read" + or capability.get("classification") != "read" + or capability.get("status") != "implemented" + or request != { + "method": "GET", + "url": "https://api.geliospro.com/api/v1/units", + } + or capability.get("dataProductIds") != ["fleet.positions.current.v2"] + or publisher != { + "nodeType": "n8n-nodes-ndc.ndcDataProductPublish", + "credentialType": "ndcDataProductWriterApi", + } + ): + die("Engine provider security catalog v3 projection mismatch") + return catalog + + +def preflight_engine_provider_security_catalog_predecessor(): + catalog_path = component_root("engine") / ENGINE_PROVIDER_SECURITY_CATALOG_REL + try: + path_stat = catalog_path.lstat() + except FileNotFoundError: + die("Engine provider security catalog predecessor is missing") + if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode): + die("Engine provider security catalog predecessor is unsafe") + actual_sha256 = sha256_file(catalog_path) + if actual_sha256 != ENGINE_PROVIDER_SECURITY_CATALOG_PREDECESSOR_SHA256: + die( + "Engine provider security catalog predecessor drift detected: " + f"expected={ENGINE_PROVIDER_SECURITY_CATALOG_PREDECESSOR_SHA256} " + f"actual={actual_sha256}" + ) + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine provider security catalog requires the active immutable backend") + return {"catalog_sha256": actual_sha256, "backend_mode": backend["mode"]} + + +def accept_engine_provider_security_catalog_runtime(): + root = component_root("engine") + validate_engine_provider_security_catalog_payload( + root, + ENGINE_PROVIDER_SECURITY_CATALOG_ARTIFACT_ENTRIES, + ) + live = run_engine_backend_probe( + ( + "node", + "--input-type=module", + "-e", + """ +const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js'); +const catalog=await module.loadProviderSecurityCatalog(); +const validation=module.validateProviderSecurityCatalog(catalog); +const provider=catalog.packages?.[0]; +const capability=provider?.capabilities?.[0]; +if(!validation.ok||provider?.id!=='gelios.provider.v3'||provider?.providerCredential?.credentialType!=='httpBearerAuth'||capability?.request?.url!=='https://api.geliospro.com/api/v1/units'||capability?.dataProductIds?.[0]!=='fleet.positions.current.v2')process.exit(2); +process.stdout.write('engine-provider-catalog:gelios.provider.v3:httpBearerAuth:fleet.positions.current.v2'); +""".strip(), + ), + "Engine provider security catalog v3", + container_id=engine_backend_container_id(), + ) + expected = "engine-provider-catalog:gelios.provider.v3:httpBearerAuth:fleet.positions.current.v2" + if live != expected: + die("Engine provider security catalog live acceptance mismatch") + return {"catalog_sha256": ENGINE_PROVIDER_SECURITY_CATALOG_TARGET_SHA256, "live": live} + + +def accept_engine_composite_provider_runtime(): + catalog_path = component_root("engine") / ENGINE_PROVIDER_SECURITY_CATALOG_REL + if ( + catalog_path.is_symlink() + or not catalog_path.is_file() + or sha256_file(catalog_path) + != ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256 + ): + return None + live = run_engine_backend_probe( + ( + "node", + "--input-type=module", + "-e", + """ +const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js'); +const catalog=await module.loadProviderSecurityCatalog(); +const validation=module.validateProviderSecurityCatalog(catalog); +const credential={id:'native-provider',name:'provider',nodeDcCredentialId:'provider-ref'}; +const policy={kind:'httpRequest',allowedHosts:['api.geliospro.com'],requireHttps:true,disableRedirects:true}; +const slot='ndcProviderRotatingAccessApi'; +const read=(id,url,parameters={})=>({id,data:{n8n:{id,name:id,type:'n8n-nodes-base.httpRequest',parameters:{method:'GET',url,...parameters},credentials:{[slot]:credential}},nodedcAgentCredentialPolicies:{[slot]:policy}}}); +const query={sendQuery:true,specifyQuery:'keypair',queryParameters:{parameters:[{name:'incltrip',value:'true'}]}}; +const graph={nodes:[read('monitoring','https://api.geliospro.com/api/v1/users/me/monitoring-config'),read('units','https://api.geliospro.com/api/v1/units',query),{id:'publish',data:{n8n:{id:'publish',name:'publish',type:'n8n-nodes-ndc.ndcDataProductPublish',parameters:{dataProductId:'fleet.positions.current.v3'},credentials:{}}}}],edges:[{source:'monitoring',target:'publish'},{source:'units',target:'publish'}]}; +const resolved=module.resolveProviderConnectionFromGraph({graph,targetNodeId:'publish',catalog}); +if(!validation.ok||catalog.packages?.[0]?.id!=='gelios.provider.v4'||catalog.packages?.[0]?.providerCredential?.credentialType!==slot||!resolved.ok||resolved.descriptor?.providerCredentialRef!=='provider-ref'||resolved.descriptor?.capabilityIds?.join(',')!=='gelios.monitoring_config.current.read,gelios.units.current.read'||resolved.descriptor?.providerRequestNodeIds?.join(',')!=='monitoring,units')process.exit(2); +process.stdout.write('engine-composite-provider:gelios.provider.v4:ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units'); +""".strip(), + ), + "Engine composite provider authority", + container_id=engine_backend_container_id(), + ) + expected = ( + "engine-composite-provider:gelios.provider.v4:" + "ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units" + ) + if live != expected: + die("Engine composite provider live acceptance mismatch") + return { + "catalog_sha256": ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256, + "live": live, + } + + +def accept_engine_provider_rotating_slot_runtime(): + root = component_root("engine") + validate_engine_provider_rotating_slot_slice( + root, + ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES, + ) + live = run_engine_backend_probe( + ( + "node", + "--input-type=module", + "-e", + """ +const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js'); +const catalog=await module.loadProviderSecurityCatalog(); +const validation=module.validateProviderSecurityCatalog(catalog); +const credential={id:'native-provider',name:'provider',nodeDcCredentialId:'provider-ref'}; +const policy={kind:'httpRequest',allowedHosts:['api.geliospro.com'],requireHttps:true,disableRedirects:true}; +const slot='ndcProviderRotatingAccessApi'; +const read=(id,url,parameters={})=>({id,data:{n8n:{id,name:id,type:'n8n-nodes-base.httpRequest',parameters:{method:'GET',url,...parameters},credentials:{[slot]:credential}},nodedcAgentCredentialPolicies:{[slot]:policy}}}); +const query={sendQuery:true,specifyQuery:'keypair',queryParameters:{parameters:[{name:'incltrip',value:'true'}]}}; +const graph={nodes:[read('monitoring','https://api.geliospro.com/api/v1/users/me/monitoring-config'),read('units','https://api.geliospro.com/api/v1/units',query),{id:'publish',data:{n8n:{id:'publish',name:'publish',type:'n8n-nodes-ndc.ndcDataProductPublish',parameters:{dataProductId:'fleet.positions.current.v3'},credentials:{}}}}],edges:[{source:'monitoring',target:'publish'},{source:'units',target:'publish'}]}; +const resolved=module.resolveProviderConnectionFromGraph({graph,targetNodeId:'publish',catalog}); +if(!validation.ok||catalog.packages?.[0]?.id!=='gelios.provider.v4'||catalog.packages?.[0]?.providerCredential?.credentialType!==slot||!resolved.ok||resolved.descriptor?.providerCredentialRef!=='provider-ref'||resolved.descriptor?.capabilityIds?.join(',')!=='gelios.monitoring_config.current.read,gelios.units.current.read'||resolved.descriptor?.providerRequestNodeIds?.join(',')!=='monitoring,units')process.exit(2); +process.stdout.write('engine-provider-rotating-slot:gelios.provider.v4:ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units'); +""".strip(), + ), + "Engine provider rotating slot authority", + container_id=engine_backend_container_id(), + ) + expected = ( + "engine-provider-rotating-slot:gelios.provider.v4:" + "ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units" + ) + if live != expected: + die("Engine provider rotating slot live acceptance mismatch") + return { + "target_sha256": dict(ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256), + "live": live, + } + + +def accept_engine_provider_authority_diagnostics_runtime(): + root = component_root("engine") + validate_engine_provider_authority_diagnostics_slice( + root, + ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES, + ) + live = run_engine_backend_probe( + ( + "node", + "--input-type=module", + "-e", + """ +const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js'); +const catalog=await module.loadProviderSecurityCatalog(); +const slot='ndcProviderRotatingAccessApi'; +const credential={id:'native-provider',name:'provider',nodeDcCredentialId:'provider-ref'}; +const policy={kind:'httpRequest',allowedHosts:['api.geliospro.com'],requireHttps:true,disableRedirects:true}; +const read=(id,url,parameters={})=>({id,data:{n8n:{id,name:id,type:'n8n-nodes-base.httpRequest',parameters:{method:'GET',url,...parameters},credentials:{[slot]:credential}},nodedcAgentCredentialPolicies:{[slot]:structuredClone(policy)}}}); +const query={sendQuery:true,specifyQuery:'keypair',queryParameters:{parameters:[{name:'incltrip',value:'true'}]}}; +const graph={nodes:[read('monitoring','https://api.geliospro.com/api/v1/users/me/monitoring-config'),read('units','https://api.geliospro.com/api/v1/units',query),{id:'publish',data:{n8n:{id:'publish',name:'publish',type:'n8n-nodes-ndc.ndcDataProductPublish',parameters:{dataProductId:'fleet.positions.current.v3'},credentials:{}}}}],edges:[{source:'monitoring',target:'publish'},{source:'units',target:'publish'}]}; +const valid=module.resolveProviderConnectionFromGraph({graph,targetNodeId:'publish',catalog}); +const policyDrift=structuredClone(graph);policyDrift.nodes[0].data.nodedcAgentCredentialPolicies[slot].allowedHosts=['*.geliospro.com']; +const policyResult=module.resolveProviderConnectionFromGraph({graph:policyDrift,targetNodeId:'publish',catalog}); +const bindingDrift=structuredClone(graph);delete bindingDrift.nodes[0].data.n8n.credentials[slot].id; +const bindingResult=module.resolveProviderConnectionFromGraph({graph:bindingDrift,targetNodeId:'publish',catalog}); +if(!valid.ok||policyResult.blockers?.join(',')!=='publish_grant_provider_transport_policy_not_exact'||bindingResult.blockers?.join(',')!=='publish_grant_provider_credential_binding_invalid')process.exit(2); +process.stdout.write('engine-provider-authority-diagnostics:exact-reason-codes:v1'); +""".strip(), + ), + "Engine provider authority diagnostics", + container_id=engine_backend_container_id(), + ) + expected = "engine-provider-authority-diagnostics:exact-reason-codes:v1" + if live != expected: + die("Engine provider authority diagnostics live acceptance mismatch") + return { + "target_sha256": dict(ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256), + "live": live, + } + + +def accept_engine_provider_target_host_policy_runtime(): + root = component_root("engine") + validate_engine_provider_target_host_policy_slice( + root, + ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES, + ) + live = run_engine_backend_probe( + ( + "node", + "--input-type=module", + "-e", + """ +const module=await import('file:///app/server/routes/n8n.js'); +const slot='ndcProviderRotatingAccessApi'; +const entry={id:'provider',name:'provider',type:slot,nodeDcCredentialId:'provider-ref',data:{engineAgentAllowedHttpHosts:['*.geliospro.com','telemetry.example']}}; +const target={type:'n8n-nodes-base.httpRequest',parameters:{url:'https://api.geliospro.com/api/v1/units'}}; +const deniedTarget={type:'n8n-nodes-base.httpRequest',parameters:{url:'https://untrusted.example/collect'}}; +const allowed=module.buildEngineAgentCredentialTransportPolicy(entry,{nodes:[]},slot,target); +const denied=module.buildEngineAgentCredentialTransportPolicy(entry,{nodes:[]},slot,deniedTarget); +if(!allowed.bindable||allowed.policy?.allowedHosts?.join(',')!=='api.geliospro.com'||allowed.policy?.requireHttps!==true||allowed.policy?.disableRedirects!==true||denied.bindable||denied.reason!=='credential_host_not_allowed')process.exit(2); +process.stdout.write('engine-provider-target-host-policy:exact-literal-host:v1'); +""".strip(), + ), + "Engine provider target host policy", + container_id=engine_backend_container_id(), + ) + expected = "engine-provider-target-host-policy:exact-literal-host:v1" + if live != expected: + die("Engine provider target host policy live acceptance mismatch") + return { + "target_sha256": dict(ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256), + "live": live, + } + + def validate_no_lifecycle_scripts(payload_dir, label): forbidden = ("preinstall", "install", "postinstall", "prepare", "prepack", "postpack") for package_path in payload_dir.rglob("package.json"): @@ -3656,9 +4281,258 @@ def validate_engine_data_product_publish_grant_slice(payload_dir, entries): ): if tool not in gateway: die(f"Engine data product publish grant tool is missing: {tool}") + + catalog_path = payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL + if sha256_file(catalog_path) == ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256: + catalog = read_strict_json(catalog_path, "Engine composite provider security catalog") + packages = catalog.get("packages") if isinstance(catalog, dict) else None + provider = packages[0] if isinstance(packages, list) and len(packages) == 1 else None + capabilities = provider.get("capabilities") if isinstance(provider, dict) else None + if ( + catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1" + or not isinstance(provider, dict) + or provider.get("id") != "gelios.provider.v4" + or provider.get("version") != "4.0.0" + or provider.get("providerId") != "gelios" + or provider.get("providerCredential") != { + "authModeId": "gelios.rest-rotating-bearer.v3", + "credentialType": "httpBearerAuth", + } + or capabilities != [{ + "id": "gelios.monitoring_config.current.read", + "classification": "read", + "status": "implemented", + "request": { + "method": "GET", + "url": "https://api.geliospro.com/api/v1/users/me/monitoring-config", + }, + "dataProductIds": ["fleet.positions.current.v3"], + }, { + "id": "gelios.units.current.read", + "classification": "read", + "status": "implemented", + "request": { + "method": "GET", + "url": "https://api.geliospro.com/api/v1/units?incltrip=true", + }, + "dataProductIds": ["fleet.positions.current.v3"], + }] + or provider.get("publisher") != { + "nodeType": "n8n-nodes-ndc.ndcDataProductPublish", + "credentialType": "ndcDataProductWriterApi", + } + ): + die("Engine composite provider security catalog projection mismatch") + provider_catalog = ( + payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js" + ).read_text(encoding="utf-8") + publish_service = ( + payload_dir / "nodedc-source/server/dataProductPublishGrant/service.js" + ).read_text(encoding="utf-8") + publish_store = ( + payload_dir / "nodedc-source/server/dataProductPublishGrant/store.js" + ).read_text(encoding="utf-8") + if any( + marker not in provider_catalog + for marker in ( + "function capabilityRequests(capability)", + "function exactHttpRequestUrl(n8n)", + "capabilityIds", + "providerRequestNodeIds", + "providerCredentialRefs.size !== 1", + ) + ): + die("Engine composite provider resolver contract mismatch") + if any( + marker not in publish_service + for marker in ( + "capabilities: descriptor.capabilityIds", + "providerRequestNodeIds: descriptor.providerRequestNodeIds", + ) + ): + die("Engine composite provider plan projection mismatch") + if any( + marker not in publish_store + for marker in ( + "Array.isArray(raw.capabilityIds)", + "Array.isArray(raw.providerRequestNodeIds)", + "new Set(descriptor.providerRequestNodeIds).size", + ) + ): + die("Engine composite provider durable descriptor mismatch") validate_no_lifecycle_scripts(payload_dir, "Engine data product publish grant") +def validate_engine_composite_provider_v4_slice(payload_dir, entries): + if tuple(entries) != ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES: + die("Engine composite provider v4 files.txt exact set/order mismatch") + for rel, expected_sha256 in ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256.items(): + path = payload_dir / rel + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Engine composite provider v4 target is missing: {rel}") + if ( + stat.S_ISLNK(path_stat.st_mode) + or not stat.S_ISREG(path_stat.st_mode) + or sha256_file(path) != expected_sha256 + ): + die(f"Engine composite provider v4 target sha256 mismatch: {rel}") + + catalog = read_strict_json( + payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL, + "Engine composite provider v4 catalog", + ) + packages = catalog.get("packages") if isinstance(catalog, dict) else None + provider = packages[0] if isinstance(packages, list) and len(packages) == 1 else None + capabilities = provider.get("capabilities") if isinstance(provider, dict) else None + if ( + catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1" + or not isinstance(provider, dict) + or provider.get("id") != "gelios.provider.v4" + or provider.get("version") != "4.0.0" + or provider.get("providerCredential") != { + "authModeId": "gelios.rest-rotating-bearer.v3", + "credentialType": "httpBearerAuth", + } + or not isinstance(capabilities, list) + or [item.get("id") for item in capabilities if isinstance(item, dict)] != [ + "gelios.monitoring_config.current.read", + "gelios.units.current.read", + ] + or [item.get("dataProductIds") for item in capabilities if isinstance(item, dict)] != [ + ["fleet.positions.current.v3"], + ["fleet.positions.current.v3"], + ] + or [ + (item.get("request") or {}).get("url") + for item in capabilities + if isinstance(item, dict) + ] != [ + "https://api.geliospro.com/api/v1/users/me/monitoring-config", + "https://api.geliospro.com/api/v1/units?incltrip=true", + ] + ): + die("Engine composite provider v4 projection mismatch") + + source_markers = { + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": ( + "function exactHttpRequestUrl(n8n)", + "const capabilityIds = productCapabilities.map", + "providerCredentialRefs.size !== 1", + ), + "nodedc-source/server/dataProductPublishGrant/service.js": ( + "capabilities: descriptor.capabilityIds", + "providerRequestNodeIds: descriptor.providerRequestNodeIds", + ), + "nodedc-source/server/dataProductPublishGrant/store.js": ( + "Array.isArray(raw.capabilityIds)", + "Array.isArray(raw.providerRequestNodeIds)", + ), + } + for rel, markers in source_markers.items(): + source = (payload_dir / rel).read_text(encoding="utf-8") + if any(marker not in source for marker in markers): + die(f"Engine composite provider v4 source contract mismatch: {rel}") + + +def validate_engine_provider_rotating_slot_slice(payload_dir, entries): + if tuple(entries) != ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES: + die("Engine provider rotating slot files.txt exact set/order mismatch") + for rel, expected_sha256 in ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256.items(): + path = payload_dir / rel + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Engine provider rotating slot target is missing: {rel}") + if ( + stat.S_ISLNK(path_stat.st_mode) + or not stat.S_ISREG(path_stat.st_mode) + or sha256_file(path) != expected_sha256 + ): + die(f"Engine provider rotating slot target sha256 mismatch: {rel}") + + catalog = read_strict_json( + payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL, + "Engine provider rotating slot catalog", + ) + packages = catalog.get("packages") if isinstance(catalog, dict) else None + provider = packages[0] if isinstance(packages, list) and len(packages) == 1 else None + if ( + not isinstance(provider, dict) + or provider.get("id") != "gelios.provider.v4" + or provider.get("providerCredential") != { + "authModeId": "gelios.rest-rotating-bearer.v3", + "credentialType": "ndcProviderRotatingAccessApi", + } + ): + die("Engine provider rotating slot catalog projection mismatch") + + resolver = ( + payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js" + ).read_text(encoding="utf-8") + if ( + "'ndcProviderRotatingAccessApi'" not in resolver + or "n8n.credentials?.[providerPackage.providerCredential.credentialType]" not in resolver + or "nodedcAgentCredentialPolicies?.[providerPackage.providerCredential.credentialType]" not in resolver + ): + die("Engine provider rotating slot resolver contract mismatch") + + +def validate_engine_provider_authority_diagnostics_slice(payload_dir, entries): + if tuple(entries) != ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES: + die("Engine provider authority diagnostics files.txt exact set/order mismatch") + for rel, expected_sha256 in ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256.items(): + path = payload_dir / rel + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Engine provider authority diagnostics target is missing: {rel}") + if ( + stat.S_ISLNK(path_stat.st_mode) + or not stat.S_ISREG(path_stat.st_mode) + or sha256_file(path) != expected_sha256 + ): + die(f"Engine provider authority diagnostics target sha256 mismatch: {rel}") + source = ( + payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js" + ).read_text(encoding="utf-8") + required_markers = ( + "publish_grant_provider_request_not_exact", + "publish_grant_provider_credential_binding_invalid", + "publish_grant_provider_transport_policy_not_exact", + "publish_grant_provider_request_path_unreachable", + "publish_grant_provider_credential_identity_mismatch", + ) + if any(marker not in source for marker in required_markers): + die("Engine provider authority diagnostics reason-code contract mismatch") + + +def validate_engine_provider_target_host_policy_slice(payload_dir, entries): + if tuple(entries) != ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES: + die("Engine provider target host policy files.txt exact set/order mismatch") + for rel, expected_sha256 in ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256.items(): + path = payload_dir / rel + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Engine provider target host policy target is missing: {rel}") + if ( + stat.S_ISLNK(path_stat.st_mode) + or not stat.S_ISREG(path_stat.st_mode) + or sha256_file(path) != expected_sha256 + ): + die(f"Engine provider target host policy target sha256 mismatch: {rel}") + source = (payload_dir / "nodedc-source/server/routes/n8n.js").read_text(encoding="utf-8") + required_markers = ( + "const eligibleHosts = explicitHosts.length", + "allowedHosts: [targetHost]", + "credential_host_not_allowed", + ) + if any(marker not in source for marker in required_markers): + die("Engine provider target host policy contract mismatch") + + def validate_engine_agent_full_grant_migration_slice(payload_dir, entries): if tuple(entries) != ENGINE_AGENT_FULL_GRANT_MIGRATION_ARTIFACT_ENTRIES: die("Engine agent full grant migration files.txt exact set/order mismatch") @@ -3894,6 +4768,7 @@ def load_artifact(artifact, work_dir): and not is_engine_node_intelligence_transition(manifest["component"], entries) and not is_engine_mcp_control_plane_slice(manifest["component"], entries) and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries) + and not is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries) ): die("Engine node-intelligence payload requires the canonical transition") if is_engine_node_intelligence_transition(manifest["component"], entries): @@ -3902,11 +4777,28 @@ def load_artifact(artifact, work_dir): validate_engine_mcp_control_plane_payload(payload_dir, entries) if is_engine_mcp_ontology_sdk_slice(manifest["component"], entries): validate_engine_mcp_ontology_sdk_payload(payload_dir, entries) + if is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries): + validate_engine_mcp_autonomy_provider_v5_payload(payload_dir, entries) + if is_engine_composite_provider_v4_slice(manifest["component"], entries): + validate_engine_composite_provider_v4_slice(payload_dir, entries) + if is_engine_provider_rotating_slot_slice(manifest["component"], entries): + validate_engine_provider_rotating_slot_slice(payload_dir, entries) + if is_engine_provider_authority_diagnostics_slice(manifest["component"], entries): + validate_engine_provider_authority_diagnostics_slice(payload_dir, entries) + if is_engine_provider_target_host_policy_slice(manifest["component"], entries): + validate_engine_provider_target_host_policy_slice(payload_dir, entries) + if is_engine_provider_security_catalog_slice(manifest["component"], entries): + validate_engine_provider_security_catalog_payload(payload_dir, entries) if touches_engine_credential_sink(manifest["component"], entries): validate_engine_credential_sink_slice(payload_dir, entries) if ( not is_engine_mcp_control_plane_slice(manifest["component"], entries) and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries) + and not is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries) + and not is_engine_composite_provider_v4_slice(manifest["component"], entries) + and not is_engine_provider_rotating_slot_slice(manifest["component"], entries) + and not is_engine_provider_authority_diagnostics_slice(manifest["component"], entries) + and not is_engine_provider_target_host_policy_slice(manifest["component"], entries) and ( touches_engine_data_product_publish_grant(entries) or ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL in entries @@ -3916,6 +4808,7 @@ def load_artifact(artifact, work_dir): elif ( not is_engine_mcp_control_plane_slice(manifest["component"], entries) and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries) + and not is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries) and ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL in entries ): validate_engine_agent_full_grant_migration_slice(payload_dir, entries) @@ -4033,6 +4926,38 @@ def is_engine_data_product_publish_grant_slice(component, entries): ) +def is_engine_composite_provider_v4_slice(component, entries): + return ( + component == "engine" + and entries is not None + and tuple(entries) == ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES + ) + + +def is_engine_provider_rotating_slot_slice(component, entries): + return ( + component == "engine" + and entries is not None + and tuple(entries) == ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES + ) + + +def is_engine_provider_authority_diagnostics_slice(component, entries): + return ( + component == "engine" + and entries is not None + and tuple(entries) == ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES + ) + + +def is_engine_provider_target_host_policy_slice(component, entries): + return ( + component == "engine" + and entries is not None + and tuple(entries) == ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES + ) + + def is_engine_agent_full_grant_migration_slice(component, entries): return ( component == "engine" @@ -4078,7 +5003,7 @@ def validate_installed_engine_data_product_publish_grant_foundation(root): "nodedc-source/server/routes/engineAgentGateway.js", "nodedc-source/server/routes/n8n.js", } - for rel, expected_sha256 in ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256.items(): + for rel, expected_sha256 in ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256.items(): if rel in publish_owned_predecessors: continue path = root / rel @@ -4190,15 +5115,33 @@ def preflight_engine_data_product_publish_grant_predecessor(payload_dir=None): forbidden_changes = tuple( rel for rel in changed_paths - if not rel.startswith(ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_PREFIX) + if ( + not rel.startswith(ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_PREFIX) + and rel != ENGINE_PROVIDER_SECURITY_CATALOG_REL + ) ) if forbidden_changes: die( "Engine data product publish grant update crosses its source boundary: " f"path={forbidden_changes[0]}" ) + if ENGINE_PROVIDER_SECURITY_CATALOG_REL in changed_paths: + installed_catalog_sha256 = installed_files[ENGINE_PROVIDER_SECURITY_CATALOG_REL] + candidate_catalog_sha256 = candidate_files[ENGINE_PROVIDER_SECURITY_CATALOG_REL] + if ( + installed_catalog_sha256 + != ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256 + or candidate_catalog_sha256 + != ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256 + ): + die("Engine composite provider catalog transition mismatch") + if changed_paths != ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS: + die("Engine composite provider update exact changed path set mismatch") + mode = "installed-composite-provider-update" + else: + mode = "installed-source-update" return { - "mode": "installed-source-update", + "mode": mode, "compose_sha256": compose_sha256, "changed_paths": changed_paths, } @@ -4226,6 +5169,122 @@ def preflight_engine_data_product_publish_grant_predecessor(payload_dir=None): } +def preflight_engine_composite_provider_v4_predecessor(): + root = component_root("engine") + actual = {} + for rel, expected_sha256 in ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256.items(): + path = root / rel + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Engine composite provider v4 predecessor is missing: {rel}") + if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode): + die(f"Engine composite provider v4 predecessor is unsafe: {rel}") + actual_sha256 = sha256_file(path) + if actual_sha256 != expected_sha256: + die( + "Engine composite provider v4 predecessor drift detected: " + f"path={rel} expected={expected_sha256} actual={actual_sha256}" + ) + actual[rel] = actual_sha256 + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine composite provider v4 requires the active immutable backend") + return { + "mode": "exact-composite-provider-v3-to-v4", + "predecessor_sha256": actual, + "target_sha256": dict(ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256), + "backend_mode": backend["mode"], + } + + +def preflight_engine_provider_rotating_slot_predecessor(): + root = component_root("engine") + actual = {} + for rel, expected_sha256 in ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256.items(): + path = root / rel + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Engine provider rotating slot predecessor is missing: {rel}") + if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode): + die(f"Engine provider rotating slot predecessor is unsafe: {rel}") + actual_sha256 = sha256_file(path) + if actual_sha256 != expected_sha256: + die( + "Engine provider rotating slot predecessor drift detected: " + f"path={rel} expected={expected_sha256} actual={actual_sha256}" + ) + actual[rel] = actual_sha256 + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine provider rotating slot requires the active immutable backend") + return { + "mode": "exact-gelios-v4-credential-slot-alignment", + "predecessor_sha256": actual, + "target_sha256": dict(ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256), + "backend_mode": backend["mode"], + } + + +def preflight_engine_provider_authority_diagnostics_predecessor(): + root = component_root("engine") + actual = {} + for rel, expected_sha256 in ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_PREDECESSOR_SHA256.items(): + path = root / rel + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Engine provider authority diagnostics predecessor is missing: {rel}") + if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode): + die(f"Engine provider authority diagnostics predecessor is unsafe: {rel}") + actual_sha256 = sha256_file(path) + if actual_sha256 != expected_sha256: + die( + "Engine provider authority diagnostics predecessor drift detected: " + f"path={rel} expected={expected_sha256} actual={actual_sha256}" + ) + actual[rel] = actual_sha256 + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine provider authority diagnostics requires the active immutable backend") + return { + "mode": "exact-provider-authority-reason-codes", + "predecessor_sha256": actual, + "target_sha256": dict(ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256), + "backend_mode": backend["mode"], + } + + +def preflight_engine_provider_target_host_policy_predecessor(): + root = component_root("engine") + actual = {} + for rel, expected_sha256 in ENGINE_PROVIDER_TARGET_HOST_POLICY_PREDECESSOR_SHA256.items(): + path = root / rel + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Engine provider target host policy predecessor is missing: {rel}") + if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode): + die(f"Engine provider target host policy predecessor is unsafe: {rel}") + actual_sha256 = sha256_file(path) + if actual_sha256 != expected_sha256: + die( + "Engine provider target host policy predecessor drift detected: " + f"path={rel} expected={expected_sha256} actual={actual_sha256}" + ) + actual[rel] = actual_sha256 + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine provider target host policy requires the active immutable backend") + return { + "mode": "exact-provider-literal-target-host", + "predecessor_sha256": actual, + "target_sha256": dict(ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256), + "backend_mode": backend["mode"], + } + + def preflight_engine_agent_full_grant_migration_predecessor(): root = component_root("engine") store_path = root / ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL @@ -4267,6 +5326,12 @@ def component_services(component, entries=None): if ( is_engine_mcp_control_plane_slice(component, entries) or is_engine_mcp_ontology_sdk_slice(component, entries) + or is_engine_mcp_autonomy_provider_v5_slice(component, entries) + or is_engine_composite_provider_v4_slice(component, entries) + or is_engine_provider_rotating_slot_slice(component, entries) + or is_engine_provider_authority_diagnostics_slice(component, entries) + or is_engine_provider_target_host_policy_slice(component, entries) + or is_engine_provider_security_catalog_slice(component, entries) ): # This slice updates only the existing Engine backend control plane. # Node intelligence keeps the same immutable sidecar image and n8n/L1 @@ -6403,6 +7468,12 @@ def plan_artifact(artifact): node_intelligence_preflight = None mcp_control_plane_preflight = None mcp_ontology_sdk_preflight = None + mcp_autonomy_provider_v5_preflight = None + composite_provider_v4_preflight = None + provider_rotating_slot_preflight = None + provider_authority_diagnostics_preflight = None + provider_target_host_policy_preflight = None + provider_catalog_preflight = None with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp: manifest, entries, payload_dir = load_artifact(artifact, Path(tmp)) transition_descriptor = None @@ -6431,6 +7502,24 @@ def plan_artifact(artifact): mcp_ontology_sdk_preflight = preflight_engine_mcp_ontology_sdk_predecessor( payload_dir ) + if is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries): + mcp_autonomy_provider_v5_preflight = ( + preflight_engine_mcp_autonomy_provider_v5_predecessor(payload_dir) + ) + if is_engine_composite_provider_v4_slice(manifest["component"], entries): + composite_provider_v4_preflight = preflight_engine_composite_provider_v4_predecessor() + if is_engine_provider_rotating_slot_slice(manifest["component"], entries): + provider_rotating_slot_preflight = preflight_engine_provider_rotating_slot_predecessor() + if is_engine_provider_authority_diagnostics_slice(manifest["component"], entries): + provider_authority_diagnostics_preflight = ( + preflight_engine_provider_authority_diagnostics_predecessor() + ) + if is_engine_provider_target_host_policy_slice(manifest["component"], entries): + provider_target_host_policy_preflight = ( + preflight_engine_provider_target_host_policy_predecessor() + ) + if is_engine_provider_security_catalog_slice(manifest["component"], entries): + provider_catalog_preflight = preflight_engine_provider_security_catalog_predecessor() component = manifest["component"] root = component_root(component) @@ -6441,6 +7530,22 @@ def plan_artifact(artifact): component, entries, ) + touches_composite_provider_v4 = is_engine_composite_provider_v4_slice( + component, + entries, + ) + touches_provider_rotating_slot = is_engine_provider_rotating_slot_slice( + component, + entries, + ) + touches_provider_authority_diagnostics = is_engine_provider_authority_diagnostics_slice( + component, + entries, + ) + touches_provider_target_host_policy = is_engine_provider_target_host_policy_slice( + component, + entries, + ) touches_agent_grant_migration = is_engine_agent_full_grant_migration_slice( component, entries, @@ -6451,6 +7556,10 @@ def plan_artifact(artifact): if ( touches_engine_credential_sink(component, entries) or touches_publish_grant + or touches_composite_provider_v4 + or touches_provider_rotating_slot + or touches_provider_authority_diagnostics + or touches_provider_target_host_policy or touches_agent_grant_migration ): credential_backend_preflight = preflight_engine_credential_backend_runtime() @@ -6460,6 +7569,26 @@ def plan_artifact(artifact): publish_grant_predecessor_sha256 = publish_grant_preflight["compose_sha256"] if credential_backend_preflight["mode"] != "verified-derived-retry": die("Engine data product publish grant requires the active immutable credential backend") + if touches_composite_provider_v4: + if composite_provider_v4_preflight is None: + die("Engine composite provider v4 preflight is missing") + if credential_backend_preflight["mode"] != "verified-derived-retry": + die("Engine composite provider v4 requires the active immutable credential backend") + if touches_provider_rotating_slot: + if provider_rotating_slot_preflight is None: + die("Engine provider rotating slot preflight is missing") + if credential_backend_preflight["mode"] != "verified-derived-retry": + die("Engine provider rotating slot requires the active immutable credential backend") + if touches_provider_authority_diagnostics: + if provider_authority_diagnostics_preflight is None: + die("Engine provider authority diagnostics preflight is missing") + if credential_backend_preflight["mode"] != "verified-derived-retry": + die("Engine provider authority diagnostics requires the active immutable credential backend") + if touches_provider_target_host_policy: + if provider_target_host_policy_preflight is None: + die("Engine provider target host policy preflight is missing") + if credential_backend_preflight["mode"] != "verified-derived-retry": + die("Engine provider target host policy requires the active immutable credential backend") if touches_agent_grant_migration: agent_grant_migration_predecessor_sha256 = ( preflight_engine_agent_full_grant_migration_predecessor() @@ -6531,6 +7660,134 @@ def plan_artifact(artifact): print(f"backend_current_barrier={mcp_ontology_sdk_preflight['backend_mode']}") print("node_intelligence_image=preserved") print("n8n_l1=untouched") + if mcp_autonomy_provider_v5_preflight: + print("engine_mcp_transition=capability-scoped-autonomy+provider-v5") + print("engine_mcp_version=0.6.0") + print("engine_mcp_installer=0.1.6") + print("engine_mcp_authority=mcp-capability-intersect-user-objective") + print("engine_mcp_plan=machine-safety-barrier") + print("engine_mcp_retry_stop=three-identical-failures-without-new-evidence") + print("engine_provider_packages=gelios.provider.v4,gelios.provider.v5") + print("engine_data_product=fleet.positions.current.v4") + print( + "predecessor_gateway_sha256=" + f"{mcp_autonomy_provider_v5_preflight['predecessor_gateway_sha256']}" + ) + print( + "target_gateway_sha256=" + f"{mcp_autonomy_provider_v5_preflight['target_gateway_sha256']}" + ) + print(f"backend_current_barrier={mcp_autonomy_provider_v5_preflight['backend_mode']}") + print("node_intelligence_image=preserved") + print("n8n_l1=untouched") + if provider_catalog_preflight: + print("engine_provider_catalog_transition=gelios-rest-rotating-v3") + print("engine_provider_package=gelios.provider.v3") + print("engine_provider_credential=httpBearerAuth") + print("engine_provider_endpoint=https://api.geliospro.com/api/v1/units") + print("engine_data_product=fleet.positions.current.v2") + print( + "predecessor_catalog_sha256=" + f"{provider_catalog_preflight['catalog_sha256']}" + ) + print(f"target_catalog_sha256={ENGINE_PROVIDER_SECURITY_CATALOG_TARGET_SHA256}") + print(f"backend_current_barrier={provider_catalog_preflight['backend_mode']}") + print("n8n_l1=untouched") + print("engine_ui=untouched") + if composite_provider_v4_preflight: + print("engine_composite_provider_transition=exact-v3-to-v4") + print("engine_provider_package=gelios.provider.v4") + print( + "engine_provider_capabilities=" + "gelios.monitoring_config.current.read,gelios.units.current.read" + ) + print("engine_provider_requests=monitoring-config,units?incltrip=true") + print("engine_data_product=fleet.positions.current.v3") + for changed_path in ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES: + print(f"engine_composite_provider_changed_path={changed_path}") + print( + f"engine_composite_provider_predecessor_sha256[{changed_path}]=" + f"{composite_provider_v4_preflight['predecessor_sha256'][changed_path]}" + ) + print( + f"engine_composite_provider_target_sha256[{changed_path}]=" + f"{composite_provider_v4_preflight['target_sha256'][changed_path]}" + ) + print(f"backend_current_barrier={composite_provider_v4_preflight['backend_mode']}") + print("provider_credential_values=preserved") + print("backend_force_recreate=yes") + print("backend_pull=never") + print("n8n_l1=untouched") + print("engine_ui=untouched") + print("engine_databases=untouched") + if provider_rotating_slot_preflight: + print("engine_provider_credential_transition=exact-active-slot-alignment") + print("engine_provider_package=gelios.provider.v4") + print("engine_provider_auth_mode=gelios.rest-rotating-bearer.v3") + print("engine_provider_credential_slot=ndcProviderRotatingAccessApi") + print("engine_data_product=fleet.positions.current.v3") + for changed_path in ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES: + print(f"engine_provider_rotating_slot_changed_path={changed_path}") + print( + f"engine_provider_rotating_slot_predecessor_sha256[{changed_path}]=" + f"{provider_rotating_slot_preflight['predecessor_sha256'][changed_path]}" + ) + print( + f"engine_provider_rotating_slot_target_sha256[{changed_path}]=" + f"{provider_rotating_slot_preflight['target_sha256'][changed_path]}" + ) + print(f"backend_current_barrier={provider_rotating_slot_preflight['backend_mode']}") + print("provider_credential_values=preserved") + print("backend_force_recreate=yes") + print("backend_pull=never") + print("l2_graph=untouched") + print("n8n_l1=untouched") + print("engine_ui=untouched") + print("engine_databases=untouched") + if provider_authority_diagnostics_preflight: + print("engine_provider_authority_transition=exact-safe-reason-codes") + print("engine_provider_package=gelios.provider.v4") + print("engine_data_product=fleet.positions.current.v3") + print("provider_credential_values=preserved") + for changed_path in ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES: + print(f"engine_provider_authority_changed_path={changed_path}") + print( + f"engine_provider_authority_predecessor_sha256[{changed_path}]=" + f"{provider_authority_diagnostics_preflight['predecessor_sha256'][changed_path]}" + ) + print( + f"engine_provider_authority_target_sha256[{changed_path}]=" + f"{provider_authority_diagnostics_preflight['target_sha256'][changed_path]}" + ) + print(f"backend_current_barrier={provider_authority_diagnostics_preflight['backend_mode']}") + print("backend_force_recreate=yes") + print("backend_pull=never") + print("l2_graph=untouched") + print("n8n_l1=untouched") + print("engine_ui=untouched") + print("engine_databases=untouched") + if provider_target_host_policy_preflight: + print("engine_provider_transport_transition=exact-literal-target-host") + print("engine_provider_package=gelios.provider.v4") + print("engine_data_product=fleet.positions.current.v3") + print("provider_credential_values=preserved") + for changed_path in ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES: + print(f"engine_provider_target_host_policy_changed_path={changed_path}") + print( + f"engine_provider_target_host_policy_predecessor_sha256[{changed_path}]=" + f"{provider_target_host_policy_preflight['predecessor_sha256'][changed_path]}" + ) + print( + f"engine_provider_target_host_policy_target_sha256[{changed_path}]=" + f"{provider_target_host_policy_preflight['target_sha256'][changed_path]}" + ) + print(f"backend_current_barrier={provider_target_host_policy_preflight['backend_mode']}") + print("backend_force_recreate=yes") + print("backend_pull=never") + print("l2_graph=untouched") + print("n8n_l1=untouched") + print("engine_ui=untouched") + print("engine_databases=untouched") if transition_descriptor: print(f"n8n_transition={transition_descriptor['action']}") print(f"n8n_version={transition_descriptor['n8nVersion']}") @@ -6592,6 +7849,19 @@ def plan_artifact(artifact): print(f"predecessor_compose_sha256={publish_grant_predecessor_sha256}") print(f"backend_current_barrier={credential_backend_preflight['mode']}") print(f"runtime_private_state=runner-managed:{component_root('engine') / ENGINE_PUBLISH_GRANT_STATE_REL}") + if publish_grant_preflight["mode"] == "installed-composite-provider-update": + print("engine_provider_package=gelios.provider.v4") + print("engine_provider_requests=monitoring-config,units?incltrip=true") + print("engine_data_product=fleet.positions.current.v3") + print( + "predecessor_catalog_sha256=" + f"{ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256}" + ) + print( + "target_catalog_sha256=" + f"{ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256}" + ) + print("provider_credential_values=preserved") if touches_agent_grant_migration: print(f"predecessor_store_sha256={agent_grant_migration_predecessor_sha256}") print(f"target_store_sha256={ENGINE_AGENT_FULL_GRANT_MIGRATION_TARGET_SHA256}") @@ -7512,9 +8782,15 @@ def component_healthchecks(component, entries=None, services=None): if ( ( is_engine_data_product_publish_grant_slice(component, entries) + or is_engine_composite_provider_v4_slice(component, entries) + or is_engine_provider_rotating_slot_slice(component, entries) + or is_engine_provider_authority_diagnostics_slice(component, entries) + or is_engine_provider_target_host_policy_slice(component, entries) or is_engine_agent_full_grant_migration_slice(component, entries) or is_engine_mcp_control_plane_slice(component, entries) or is_engine_mcp_ontology_sdk_slice(component, entries) + or is_engine_mcp_autonomy_provider_v5_slice(component, entries) + or is_engine_provider_security_catalog_slice(component, entries) ) and services is not None ): @@ -7823,18 +9099,42 @@ def run_healthchecks(component, entries=None, services=None): component, entries, ) + touches_composite_provider_v4 = is_engine_composite_provider_v4_slice( + component, + entries, + ) + touches_provider_rotating_slot = is_engine_provider_rotating_slot_slice( + component, + entries, + ) + touches_provider_authority_diagnostics = is_engine_provider_authority_diagnostics_slice( + component, + entries, + ) + touches_provider_target_host_policy = is_engine_provider_target_host_policy_slice( + component, + entries, + ) touches_agent_grant_migration = is_engine_agent_full_grant_migration_slice( component, entries, ) touches_mcp_control_plane = is_engine_mcp_control_plane_slice(component, entries) touches_mcp_ontology_sdk = is_engine_mcp_ontology_sdk_slice(component, entries) + touches_mcp_autonomy_provider_v5 = is_engine_mcp_autonomy_provider_v5_slice(component, entries) + touches_provider_catalog = is_engine_provider_security_catalog_slice(component, entries) if ( touches_engine_credential_sink(component, entries) or touches_publish_grant + or touches_composite_provider_v4 + or touches_provider_rotating_slot + or touches_provider_authority_diagnostics + or touches_provider_target_host_policy or touches_agent_grant_migration or touches_mcp_control_plane or touches_mcp_ontology_sdk + or touches_mcp_autonomy_provider_v5 + or touches_provider_catalog ): # The HTTP endpoint can become ready before Docker publishes the first # successful health probe. Wait for the Compose health barrier before @@ -7845,9 +9145,15 @@ def run_healthchecks(component, entries=None, services=None): if ( touches_engine_credential_sink(component, entries) or touches_publish_grant + or touches_composite_provider_v4 + or touches_provider_rotating_slot + or touches_provider_authority_diagnostics + or touches_provider_target_host_policy or touches_agent_grant_migration or touches_mcp_control_plane or touches_mcp_ontology_sdk + or touches_mcp_autonomy_provider_v5 + or touches_provider_catalog ): runtime = preflight_engine_credential_backend_runtime() if runtime["mode"] != "verified-derived-retry": @@ -7858,6 +9164,55 @@ def run_healthchecks(component, entries=None, services=None): accept_engine_mcp_control_plane_runtime() if touches_mcp_ontology_sdk: accept_engine_mcp_ontology_sdk_runtime() + if touches_mcp_autonomy_provider_v5: + state = engine_mcp_autonomy_provider_v5_installed_state() + if state == "target": + accept_engine_mcp_autonomy_provider_v5_runtime() + if touches_provider_catalog: + accept_engine_provider_security_catalog_runtime() + if touches_publish_grant: + accept_engine_composite_provider_runtime() + if touches_composite_provider_v4: + root = component_root("engine") + installed_sha256 = { + rel: sha256_file(root / rel) + for rel in ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES + } + if installed_sha256 == ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256: + if accept_engine_composite_provider_runtime() is None: + die("Engine composite provider v4 live acceptance was not executed") + elif installed_sha256 != ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256: + die("Engine composite provider v4 installed source state is neither target nor rollback predecessor") + if touches_provider_rotating_slot: + root = component_root("engine") + installed_sha256 = { + rel: sha256_file(root / rel) + for rel in ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES + } + if installed_sha256 == ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256: + accept_engine_provider_rotating_slot_runtime() + elif installed_sha256 != ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256: + die("Engine provider rotating slot installed state is neither target nor rollback predecessor") + if touches_provider_authority_diagnostics: + root = component_root("engine") + installed_sha256 = { + rel: sha256_file(root / rel) + for rel in ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES + } + if installed_sha256 == ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256: + accept_engine_provider_authority_diagnostics_runtime() + elif installed_sha256 != ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_PREDECESSOR_SHA256: + die("Engine provider authority diagnostics installed state is neither target nor rollback predecessor") + if touches_provider_target_host_policy: + root = component_root("engine") + installed_sha256 = { + rel: sha256_file(root / rel) + for rel in ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES + } + if installed_sha256 == ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256: + accept_engine_provider_target_host_policy_runtime() + elif installed_sha256 != ENGINE_PROVIDER_TARGET_HOST_POLICY_PREDECESSOR_SHA256: + die("Engine provider target host policy installed state is neither target nor rollback predecessor") container_name = COMPONENTS[component].get("health_container") if container_name: healthcheck_container(container_name) @@ -7922,6 +9277,26 @@ def apply_artifact(artifact): publish_backend_preflight = preflight_engine_credential_backend_runtime() if publish_backend_preflight["mode"] != "verified-derived-retry": die("Engine data product publish grant requires the active immutable credential backend") + if is_engine_composite_provider_v4_slice(component, entries): + preflight_engine_composite_provider_v4_predecessor() + composite_backend_preflight = preflight_engine_credential_backend_runtime() + if composite_backend_preflight["mode"] != "verified-derived-retry": + die("Engine composite provider v4 requires the active immutable credential backend") + if is_engine_provider_rotating_slot_slice(component, entries): + preflight_engine_provider_rotating_slot_predecessor() + rotating_slot_backend_preflight = preflight_engine_credential_backend_runtime() + if rotating_slot_backend_preflight["mode"] != "verified-derived-retry": + die("Engine provider rotating slot requires the active immutable credential backend") + if is_engine_provider_authority_diagnostics_slice(component, entries): + preflight_engine_provider_authority_diagnostics_predecessor() + authority_diagnostics_backend_preflight = preflight_engine_credential_backend_runtime() + if authority_diagnostics_backend_preflight["mode"] != "verified-derived-retry": + die("Engine provider authority diagnostics requires the active immutable credential backend") + if is_engine_provider_target_host_policy_slice(component, entries): + preflight_engine_provider_target_host_policy_predecessor() + target_host_policy_backend_preflight = preflight_engine_credential_backend_runtime() + if target_host_policy_backend_preflight["mode"] != "verified-derived-retry": + die("Engine provider target host policy requires the active immutable credential backend") if is_engine_agent_full_grant_migration_slice(component, entries): preflight_engine_agent_full_grant_migration_predecessor() migration_backend_preflight = preflight_engine_credential_backend_runtime() @@ -7936,6 +9311,10 @@ def apply_artifact(artifact): preflight_engine_mcp_control_plane_predecessor(payload_dir) if is_engine_mcp_ontology_sdk_slice(component, entries): preflight_engine_mcp_ontology_sdk_predecessor(payload_dir) + if is_engine_mcp_autonomy_provider_v5_slice(component, entries): + preflight_engine_mcp_autonomy_provider_v5_predecessor(payload_dir) + if is_engine_provider_security_catalog_slice(component, entries): + preflight_engine_provider_security_catalog_predecessor() if not artifact_only and not compose_root.is_dir(): if bootstrap_root and is_relative_to(compose_root.resolve(strict=False), root.resolve(strict=False)): compose_root.mkdir(parents=True, exist_ok=True) @@ -8111,9 +9490,15 @@ def apply_artifact(artifact): elif ( ( is_engine_data_product_publish_grant_slice(component, entries) + or is_engine_composite_provider_v4_slice(component, entries) + or is_engine_provider_rotating_slot_slice(component, entries) + or is_engine_provider_authority_diagnostics_slice(component, entries) + or is_engine_provider_target_host_policy_slice(component, entries) or is_engine_agent_full_grant_migration_slice(component, entries) or is_engine_mcp_control_plane_slice(component, entries) or is_engine_mcp_ontology_sdk_slice(component, entries) + or is_engine_mcp_autonomy_provider_v5_slice(component, entries) + or is_engine_provider_security_catalog_slice(component, entries) ) and services is not None ): diff --git a/infra/deploy-runner/test_engine_composite_provider_v4.py b/infra/deploy-runner/test_engine_composite_provider_v4.py new file mode 100644 index 0000000..6982088 --- /dev/null +++ b/infra/deploy-runner/test_engine_composite_provider_v4.py @@ -0,0 +1,157 @@ +import hashlib +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +BUILDER_PATH = SCRIPT_DIR / "build-engine-composite-provider-v4-artifact.mjs" +ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA" +PATCH_ID = "engine-composite-provider-v4-20991231-999" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_engine_composite_provider_v4", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineCompositeProviderV4Test(unittest.TestCase): + def test_predecessor_map_matches_the_applied_release_order(self): + self.assertEqual( + RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256, + { + "nodedc-source/server/assets/provider-packages/v1/catalog.json": + "992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": + "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d", + "nodedc-source/server/dataProductPublishGrant/service.js": + "6a417b25b080c05b40c771df4e2dca163491af2c9083ff73dc7e54ad3b360241", + "nodedc-source/server/dataProductPublishGrant/store.js": + "a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c", + }, + ) + + def build(self, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT) + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER_PATH), PATCH_ID], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(completed.stdout) + + def test_builder_is_deterministic_and_emits_exact_backend_only_slice(self): + current_sha256 = { + relative_path: hashlib.sha256((ENGINE_ROOT / relative_path).read_bytes()).hexdigest() + for relative_path in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES + } + if current_sha256 != RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256: + self.skipTest("historical v4 builder source has advanced to its exact successor") + with tempfile.TemporaryDirectory(prefix="nodedc-engine-composite-v4-") as directory: + root = Path(directory) + first = self.build(root / "first") + second = self.build(root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual( + first["sha256"], + hashlib.sha256(first_artifact.read_bytes()).hexdigest(), + ) + self.assertEqual(first["services"], ["nodedc-backend"]) + self.assertEqual(first["credentialValues"], "preserved") + self.assertEqual( + tuple(first["entries"]), + RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES, + ) + + extract = root / "extract" + with tarfile.open(first_artifact, "r:gz") as archive: + archive.extractall(extract, filter="data") + entries = tuple( + (extract / "files.txt").read_text(encoding="utf-8").splitlines() + ) + payload = extract / "payload" + self.assertTrue(RUNNER.is_engine_composite_provider_v4_slice("engine", entries)) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + self.assertEqual( + RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)), + ("http://127.0.0.1:3001/health",), + ) + RUNNER.validate_engine_composite_provider_v4_slice(payload, entries) + for relative_path, expected in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256.items(): + self.assertEqual( + hashlib.sha256((payload / relative_path).read_bytes()).hexdigest(), + expected, + ) + + def test_preflight_requires_every_exact_predecessor_and_immutable_backend(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-composite-v4-preflight-") as directory: + root = Path(directory) + for relative_path in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES: + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("predecessor\n", encoding="utf-8") + + def exact_sha(path): + relative_path = Path(path).relative_to(root).as_posix() + return RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256[relative_path] + + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", side_effect=exact_sha), + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ), + ): + result = RUNNER.preflight_engine_composite_provider_v4_predecessor() + self.assertEqual(result["mode"], "exact-composite-provider-v3-to-v4") + self.assertEqual( + result["predecessor_sha256"], + RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256, + ) + self.assertEqual( + result["target_sha256"], + RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256, + ) + + drift_path = root / RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES[1] + + def drift_sha(path): + if Path(path) == drift_path: + return "0" * 64 + return exact_sha(path) + + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", side_effect=drift_sha), + ): + with self.assertRaises(RUNNER.DeployError): + RUNNER.preflight_engine_composite_provider_v4_predecessor() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_engine_mcp_autonomy_provider_v5.py b/infra/deploy-runner/test_engine_mcp_autonomy_provider_v5.py new file mode 100644 index 0000000..854f07f --- /dev/null +++ b/infra/deploy-runner/test_engine_mcp_autonomy_provider_v5.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +PLATFORM_ROOT = SCRIPT_DIR.parent.parent +ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +BUILDER = SCRIPT_DIR / "build-engine-mcp-autonomy-provider-v5-artifact.mjs" +PATCH_ID = "20991231-998" +CANONICAL_ARTIFACT = ( + PLATFORM_ROOT + / "infra/deploy-artifacts/nodedc-engine-mcp-autonomy-provider-v5-20260720-004.tgz" +) +CANONICAL_SHA256 = "3400954cdce078892a06b04b9bfd85a90f6ea775b1a0caf99eba1f880ef6601c" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_engine_mcp_autonomy_provider_v5", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineMcpAutonomyProviderV5Test(unittest.TestCase): + def build(self, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT) + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER), PATCH_ID], + cwd=PLATFORM_ROOT, + env=environment, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + def test_canonical_artifact_digest_and_members_are_pinned(self): + self.assertEqual( + hashlib.sha256(CANONICAL_ARTIFACT.read_bytes()).hexdigest(), + CANONICAL_SHA256, + ) + self.assertEqual(CANONICAL_ARTIFACT.read_bytes()[4:8], b"\0\0\0\0") + with tarfile.open(CANONICAL_ARTIFACT, "r:gz") as archive: + for member in archive: + self.assertTrue(member.isfile() or member.isdir()) + self.assertFalse(Path(member.name).name.startswith("._")) + + def test_builder_is_reproducible_and_slice_is_backend_only(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-") as directory: + root = Path(directory) + first = self.build(root / "first") + second = self.build(root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual( + first["artifactSha256"], + hashlib.sha256(first_artifact.read_bytes()).hexdigest(), + ) + self.assertEqual(first["services"], ["nodedc-backend"]) + self.assertEqual( + first["authority"], + "mcp-capability-intersect-user-objective", + ) + + extract = root / "extract" + with tarfile.open(first_artifact, "r:gz") as archive: + archive.extractall(extract, filter="data") + loaded = root / "loaded" + loaded.mkdir() + manifest, entries, payload = RUNNER.load_artifact(first_artifact, loaded) + descriptor = RUNNER.validate_engine_mcp_autonomy_provider_v5_payload( + payload, + entries, + ) + self.assertEqual(manifest["component"], "engine") + self.assertEqual( + tuple(entries), + RUNNER.ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES, + ) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + self.assertEqual(RUNNER.component_builds("engine", entries), ()) + self.assertEqual( + descriptor["source"]["gatewaySha256"], + RUNNER.ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[ + RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL + ], + ) + + def test_payload_pins_authority_policy_archive_and_catalog_lineage(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-policy-") as directory: + result = self.build(Path(directory) / "artifact") + extract = Path(directory) / "extract" + with tarfile.open(result["artifact"], "r:gz") as archive: + archive.extractall(extract, filter="data") + payload = extract / "payload" + installer = ( + payload + / "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs" + ).read_text(encoding="utf-8") + catalog = json.loads(( + payload / "nodedc-source/server/assets/provider-packages/v1/catalog.json" + ).read_text(encoding="utf-8")) + self.assertIn("MCP tool availability establishes capability authority", installer) + self.assertIn("machine safety barrier, not a permission ceremony", installer) + self.assertIn("Three identical failures with no new evidence", installer) + self.assertNotIn("only with explicit user confirmation", installer) + self.assertEqual( + [item["id"] for item in catalog["packages"]], + ["gelios.provider.v4", "gelios.provider.v5"], + ) + self.assertTrue(all( + capability["dataProductIds"] == ["fleet.positions.current.v4"] + for package in catalog["packages"] if package["id"] == "gelios.provider.v5" + for capability in package["capabilities"] + )) + + def test_payload_tampering_is_rejected(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-tamper-") as directory: + result = self.build(Path(directory) / "artifact") + extract = Path(directory) / "extract" + RUNNER.safe_extract(Path(result["artifact"]), extract) + entries = RUNNER.parse_files_list(extract / "files.txt") + package = extract / "payload/nodedc-source/server/assets/engine-agent-npm/package.json" + package.write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(RUNNER.DeployError, "target sha256 mismatch"): + RUNNER.validate_engine_mcp_autonomy_provider_v5_payload( + extract / "payload", + entries, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_engine_mcp_ontology_sdk.py b/infra/deploy-runner/test_engine_mcp_ontology_sdk.py index 4ea28ba..83f2800 100644 --- a/infra/deploy-runner/test_engine_mcp_ontology_sdk.py +++ b/infra/deploy-runner/test_engine_mcp_ontology_sdk.py @@ -13,6 +13,7 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent PLATFORM_ROOT = SCRIPT_DIR.parent.parent +ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA" RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" ENGINE_BUILDER = SCRIPT_DIR / "build-engine-mcp-ontology-sdk-artifact.mjs" PLATFORM_BUILDER = SCRIPT_DIR / "build-platform-gelios-provider-v2-artifact.mjs" @@ -53,6 +54,12 @@ class EngineMcpOntologySdkTest(unittest.TestCase): self.assertFalse(Path(member.name).name.startswith("._")) def test_engine_builder_is_byte_reproducible(self): + current_sha256 = { + relative_path: hashlib.sha256((ENGINE_ROOT / relative_path).read_bytes()).hexdigest() + for relative_path in RUNNER.ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256 + } + if current_sha256 != RUNNER.ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256: + self.skipTest("historical Ontology/SDK builder source has advanced to its exact successor") outputs = [] with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-ontology-sdk-") as directory: for index in range(2): diff --git a/infra/deploy-runner/test_engine_provider_authority_diagnostics.py b/infra/deploy-runner/test_engine_provider_authority_diagnostics.py new file mode 100644 index 0000000..06bcccf --- /dev/null +++ b/infra/deploy-runner/test_engine_provider_authority_diagnostics.py @@ -0,0 +1,145 @@ +import hashlib +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +BUILDER_PATH = SCRIPT_DIR / "build-engine-provider-authority-diagnostics-artifact.mjs" +ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA" +PATCH_ID = "engine-provider-authority-diagnostics-20991231-999" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_engine_provider_authority_diagnostics", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineProviderAuthorityDiagnosticsTest(unittest.TestCase): + def build(self, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT) + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER_PATH), PATCH_ID], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(completed.stdout) + + def test_builder_emits_exact_deterministic_one_file_slice(self): + with tempfile.TemporaryDirectory(prefix="nodedc-provider-authority-") as directory: + root = Path(directory) + first = self.build(root / "first") + second = self.build(root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest()) + self.assertEqual(first["services"], ["nodedc-backend"]) + self.assertEqual( + tuple(first["entries"]), + RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES, + ) + + extract = root / "extract" + with tarfile.open(first_artifact, "r:gz") as archive: + archive.extractall(extract, filter="data") + entries = tuple((extract / "files.txt").read_text(encoding="utf-8").splitlines()) + payload = extract / "payload" + self.assertTrue(RUNNER.is_engine_provider_authority_diagnostics_slice("engine", entries)) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + self.assertEqual( + RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)), + ("http://127.0.0.1:3001/health",), + ) + RUNNER.validate_engine_provider_authority_diagnostics_slice(payload, entries) + + def test_preflight_requires_the_exact_deployed_predecessor(self): + with tempfile.TemporaryDirectory(prefix="nodedc-provider-authority-preflight-") as directory: + root = Path(directory) + relative_path = RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES[0] + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("predecessor\n", encoding="utf-8") + expected = RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_PREDECESSOR_SHA256[relative_path] + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", return_value=expected), + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ), + ): + result = RUNNER.preflight_engine_provider_authority_diagnostics_predecessor() + self.assertEqual(result["mode"], "exact-provider-authority-reason-codes") + + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", return_value="0" * 64), + ): + with self.assertRaises(RUNNER.DeployError): + RUNNER.preflight_engine_provider_authority_diagnostics_predecessor() + + def test_healthchecks_dispatch_authority_diagnostics_acceptance(self): + entries = RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES + root = Path("/engine") + target_hash = next(iter(RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256.values())) + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", return_value=target_hash), + mock.patch.object(RUNNER, "healthcheck_compose_service"), + mock.patch.object(RUNNER, "component_healthchecks", return_value=()), + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ), + mock.patch.object( + RUNNER, + "accept_engine_provider_authority_diagnostics_runtime", + return_value={"live": "accepted"}, + ) as acceptance, + mock.patch.object(RUNNER, "healthcheck_container"), + ): + RUNNER.run_healthchecks("engine", entries, ("nodedc-backend",)) + acceptance.assert_called_once_with() + + def test_live_acceptance_uses_exact_reason_code_contract(self): + expected_live = "engine-provider-authority-diagnostics:exact-reason-codes:v1" + with ( + mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT), + mock.patch.object(RUNNER, "engine_backend_container_id", return_value="backend"), + mock.patch.object( + RUNNER, + "run_engine_backend_probe", + return_value=expected_live, + ) as backend_probe, + ): + result = RUNNER.accept_engine_provider_authority_diagnostics_runtime() + self.assertEqual(result["live"], expected_live) + self.assertEqual(backend_probe.call_args.args[1], "Engine provider authority diagnostics") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_engine_provider_rotating_slot.py b/infra/deploy-runner/test_engine_provider_rotating_slot.py new file mode 100644 index 0000000..edb7030 --- /dev/null +++ b/infra/deploy-runner/test_engine_provider_rotating_slot.py @@ -0,0 +1,193 @@ +import hashlib +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +BUILDER_PATH = SCRIPT_DIR / "build-engine-provider-rotating-slot-artifact.mjs" +ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA" +PATCH_ID = "engine-provider-rotating-slot-20991231-999" +AUTHORITY_SUCCESSOR_SHA256 = "1a14299167ebe17efd677fa3c59b84c80e12d6846bac6f40f9bcae0729ab22c6" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_engine_provider_rotating_slot", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineProviderRotatingSlotTest(unittest.TestCase): + def source_has_exact_successor(self): + path = ENGINE_ROOT / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js" + return hashlib.sha256(path.read_bytes()).hexdigest() == AUTHORITY_SUCCESSOR_SHA256 + + def build(self, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT) + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER_PATH), PATCH_ID], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(completed.stdout) + + def test_builder_emits_exact_deterministic_two_file_slice(self): + if self.source_has_exact_successor(): + self.skipTest("historical rotating-slot builder source has advanced to its exact successor") + with tempfile.TemporaryDirectory(prefix="nodedc-provider-rotating-slot-") as directory: + root = Path(directory) + first = self.build(root / "first") + second = self.build(root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual( + first["sha256"], + hashlib.sha256(first_artifact.read_bytes()).hexdigest(), + ) + self.assertEqual(first["services"], ["nodedc-backend"]) + self.assertEqual(first["credentialSlot"], "ndcProviderRotatingAccessApi") + self.assertEqual( + tuple(first["entries"]), + RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES, + ) + + extract = root / "extract" + with tarfile.open(first_artifact, "r:gz") as archive: + archive.extractall(extract, filter="data") + entries = tuple( + (extract / "files.txt").read_text(encoding="utf-8").splitlines() + ) + payload = extract / "payload" + self.assertTrue(RUNNER.is_engine_provider_rotating_slot_slice("engine", entries)) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + self.assertEqual( + RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)), + ("http://127.0.0.1:3001/health",), + ) + RUNNER.validate_engine_provider_rotating_slot_slice(payload, entries) + + def test_preflight_is_exact_and_rejects_any_drift(self): + with tempfile.TemporaryDirectory(prefix="nodedc-provider-rotating-preflight-") as directory: + root = Path(directory) + for relative_path in RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES: + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("predecessor\n", encoding="utf-8") + + def exact_sha(path): + relative_path = Path(path).relative_to(root).as_posix() + return RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256[relative_path] + + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", side_effect=exact_sha), + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ), + ): + result = RUNNER.preflight_engine_provider_rotating_slot_predecessor() + self.assertEqual(result["mode"], "exact-gelios-v4-credential-slot-alignment") + self.assertEqual( + result["predecessor_sha256"], + RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256, + ) + + drift_path = root / RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES[0] + + def drift_sha(path): + if Path(path) == drift_path: + return "0" * 64 + return exact_sha(path) + + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", side_effect=drift_sha), + ): + with self.assertRaises(RUNNER.DeployError): + RUNNER.preflight_engine_provider_rotating_slot_predecessor() + + def test_healthchecks_dispatch_exact_rotating_slot_acceptance(self): + entries = RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES + root = Path("/engine") + + def installed_sha(path): + relative_path = Path(path).relative_to(root).as_posix() + return RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256[relative_path] + + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", side_effect=installed_sha), + mock.patch.object(RUNNER, "healthcheck_compose_service"), + mock.patch.object(RUNNER, "component_healthchecks", return_value=()), + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ), + mock.patch.object( + RUNNER, + "accept_engine_provider_rotating_slot_runtime", + return_value={"live": "accepted"}, + ) as rotating_acceptance, + mock.patch.object(RUNNER, "accept_engine_composite_provider_runtime") as composite_acceptance, + mock.patch.object(RUNNER, "healthcheck_container"), + ): + RUNNER.run_healthchecks("engine", entries, ("nodedc-backend",)) + + rotating_acceptance.assert_called_once_with() + composite_acceptance.assert_not_called() + + def test_live_acceptance_uses_the_rotating_slot_target_contract(self): + if self.source_has_exact_successor(): + self.skipTest("historical rotating-slot live fixture source has advanced to its exact successor") + expected_live = ( + "engine-provider-rotating-slot:gelios.provider.v4:" + "ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units" + ) + with ( + mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT), + mock.patch.object(RUNNER, "engine_backend_container_id", return_value="backend"), + mock.patch.object( + RUNNER, + "run_engine_backend_probe", + return_value=expected_live, + ) as backend_probe, + ): + result = RUNNER.accept_engine_provider_rotating_slot_runtime() + + self.assertEqual(result["live"], expected_live) + self.assertEqual( + result["target_sha256"], + RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256, + ) + self.assertEqual( + backend_probe.call_args.args[1], + "Engine provider rotating slot authority", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_engine_provider_security_catalog.py b/infra/deploy-runner/test_engine_provider_security_catalog.py new file mode 100644 index 0000000..aeff02b --- /dev/null +++ b/infra/deploy-runner/test_engine_provider_security_catalog.py @@ -0,0 +1,90 @@ +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +BUILDER_PATH = SCRIPT_DIR / "build-engine-provider-security-catalog-artifact.mjs" +ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA" +CATALOG_REL = "nodedc-source/server/assets/provider-packages/v1/catalog.json" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader("nodedc_engine_provider_catalog", str(RUNNER_PATH)) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineProviderSecurityCatalogTest(unittest.TestCase): + def test_builder_emits_exact_backend_only_slice(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-provider-catalog-") as directory: + artifact_dir = Path(directory) / "artifacts" + env = os.environ.copy() + env["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT) + env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER_PATH), "20991231-999"], + check=True, + capture_output=True, + text=True, + env=env, + ) + result = json.loads(completed.stdout) + artifact = Path(result["artifact"]) + self.assertEqual(result["services"], ["nodedc-backend"]) + with tarfile.open(artifact, "r:gz") as archive: + archive.extractall(Path(directory) / "extract", filter="data") + root = Path(directory) / "extract" + entries = tuple((root / "files.txt").read_text(encoding="utf-8").splitlines()) + payload = root / "payload" + self.assertTrue(RUNNER.is_engine_provider_security_catalog_slice("engine", entries)) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + self.assertEqual( + RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)), + ("http://127.0.0.1:3001/health",), + ) + catalog = RUNNER.validate_engine_provider_security_catalog_payload(payload, entries) + self.assertEqual(catalog["packages"][0]["id"], "gelios.provider.v3") + + def test_preflight_requires_exact_v1_predecessor_and_immutable_backend(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-provider-predecessor-") as directory: + root = Path(directory) + target = root / CATALOG_REL + target.parent.mkdir(parents=True) + source = subprocess.run( + ["git", "show", "HEAD:nodedc-source/server/assets/provider-packages/v1/catalog.json"], + cwd=ENGINE_ROOT, + check=True, + capture_output=True, + ).stdout + target.write_bytes(source) + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ), + ): + result = RUNNER.preflight_engine_provider_security_catalog_predecessor() + self.assertEqual( + result["catalog_sha256"], + RUNNER.ENGINE_PROVIDER_SECURITY_CATALOG_PREDECESSOR_SHA256, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/infra/deploy-runner/test_engine_provider_target_host_policy.py b/infra/deploy-runner/test_engine_provider_target_host_policy.py new file mode 100644 index 0000000..19f4c4c --- /dev/null +++ b/infra/deploy-runner/test_engine_provider_target_host_policy.py @@ -0,0 +1,145 @@ +import hashlib +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +BUILDER_PATH = SCRIPT_DIR / "build-engine-provider-target-host-policy-artifact.mjs" +ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA" +PATCH_ID = "engine-provider-target-host-policy-20991231-999" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_engine_provider_target_host_policy", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineProviderTargetHostPolicyTest(unittest.TestCase): + def build(self, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT) + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER_PATH), PATCH_ID], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(completed.stdout) + + def test_builder_emits_exact_deterministic_one_file_slice(self): + with tempfile.TemporaryDirectory(prefix="nodedc-provider-target-host-") as directory: + root = Path(directory) + first = self.build(root / "first") + second = self.build(root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest()) + self.assertEqual(first["services"], ["nodedc-backend"]) + self.assertEqual( + tuple(first["entries"]), + RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES, + ) + + extract = root / "extract" + with tarfile.open(first_artifact, "r:gz") as archive: + archive.extractall(extract, filter="data") + entries = tuple((extract / "files.txt").read_text(encoding="utf-8").splitlines()) + payload = extract / "payload" + self.assertTrue(RUNNER.is_engine_provider_target_host_policy_slice("engine", entries)) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + self.assertEqual( + RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)), + ("http://127.0.0.1:3001/health",), + ) + RUNNER.validate_engine_provider_target_host_policy_slice(payload, entries) + + def test_preflight_requires_the_exact_deployed_predecessor(self): + with tempfile.TemporaryDirectory(prefix="nodedc-provider-target-host-preflight-") as directory: + root = Path(directory) + relative_path = RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES[0] + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("predecessor\n", encoding="utf-8") + expected = RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_PREDECESSOR_SHA256[relative_path] + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", return_value=expected), + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ), + ): + result = RUNNER.preflight_engine_provider_target_host_policy_predecessor() + self.assertEqual(result["mode"], "exact-provider-literal-target-host") + + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", return_value="0" * 64), + ): + with self.assertRaises(RUNNER.DeployError): + RUNNER.preflight_engine_provider_target_host_policy_predecessor() + + def test_healthchecks_dispatch_target_host_policy_acceptance(self): + entries = RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES + root = Path("/engine") + target_hash = next(iter(RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256.values())) + with ( + mock.patch.object(RUNNER, "component_root", return_value=root), + mock.patch.object(RUNNER, "sha256_file", return_value=target_hash), + mock.patch.object(RUNNER, "healthcheck_compose_service"), + mock.patch.object(RUNNER, "component_healthchecks", return_value=()), + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ), + mock.patch.object( + RUNNER, + "accept_engine_provider_target_host_policy_runtime", + return_value={"live": "accepted"}, + ) as acceptance, + mock.patch.object(RUNNER, "healthcheck_container"), + ): + RUNNER.run_healthchecks("engine", entries, ("nodedc-backend",)) + acceptance.assert_called_once_with() + + def test_live_acceptance_uses_exact_literal_host_contract(self): + expected_live = "engine-provider-target-host-policy:exact-literal-host:v1" + with ( + mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT), + mock.patch.object(RUNNER, "engine_backend_container_id", return_value="backend"), + mock.patch.object( + RUNNER, + "run_engine_backend_probe", + return_value=expected_live, + ) as backend_probe, + ): + result = RUNNER.accept_engine_provider_target_host_policy_runtime() + self.assertEqual(result["live"], expected_live) + self.assertEqual(backend_probe.call_args.args[1], "Engine provider target host policy") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_engine_publish_grant_artifact.py b/infra/deploy-runner/test_engine_publish_grant_artifact.py index 8066d10..bab0e63 100644 --- a/infra/deploy-runner/test_engine_publish_grant_artifact.py +++ b/infra/deploy-runner/test_engine_publish_grant_artifact.py @@ -11,6 +11,10 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent BUILDER = SCRIPT_DIR / "build-engine-data-product-publish-grant-artifact.mjs" +ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA" +HISTORICAL_PROVIDER_CATALOG_SHA256 = ( + "9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a" +) EXPECTED_ENTRIES = [ "nodedc-source/server/assets/provider-packages/v1/catalog.json", "nodedc-source/server/dataProductPublishGrant", @@ -26,6 +30,13 @@ SUCCESSFUL_CREDENTIAL_SINK_INDEX_SHA256 = ( class EnginePublishGrantArtifactTest(unittest.TestCase): + def historical_source_is_current(self): + catalog = ( + ENGINE_ROOT + / "nodedc-source/server/assets/provider-packages/v1/catalog.json" + ) + return hashlib.sha256(catalog.read_bytes()).hexdigest() == HISTORICAL_PROVIDER_CATALOG_SHA256 + def build(self, artifact_dir, patch_id): environment = os.environ.copy() environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) @@ -39,6 +50,8 @@ class EnginePublishGrantArtifactTest(unittest.TestCase): return json.loads(result.stdout) def test_artifact_is_narrow_secret_free_and_deterministic(self): + if not self.historical_source_is_current(): + self.skipTest("historical Publish-grant builder source has advanced to its exact successor") with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-artifact-") as directory: root = Path(directory) first_dir = root / "first" @@ -70,6 +83,18 @@ class EnginePublishGrantArtifactTest(unittest.TestCase): "payload/nodedc-source/services/backend/data-product-publish-grant/" "docker-compose.immutable-runtime.yml" ).read().decode("utf-8") + catalog = json.loads(archive.extractfile( + "payload/nodedc-source/server/assets/provider-packages/v1/catalog.json" + ).read()) + provider_catalog_source = archive.extractfile( + "payload/nodedc-source/server/dataProductPublishGrant/providerCatalog.js" + ).read().decode("utf-8") + service_source = archive.extractfile( + "payload/nodedc-source/server/dataProductPublishGrant/service.js" + ).read().decode("utf-8") + store_source = archive.extractfile( + "payload/nodedc-source/server/dataProductPublishGrant/store.js" + ).read().decode("utf-8") self.assertEqual(files, EXPECTED_ENTRIES) self.assertEqual( @@ -115,6 +140,29 @@ class EnginePublishGrantArtifactTest(unittest.TestCase): ) self.assertIn("read_only: true", runtime_override) self.assertEqual(runtime_override.count("create_host_path: false"), 2) + provider = catalog["packages"][0] + self.assertEqual(first["providerPackage"], "gelios.provider.v4") + self.assertEqual(first["dataProductId"], "fleet.positions.current.v3") + self.assertEqual(first["credentialValues"], "preserved") + self.assertEqual(provider["id"], "gelios.provider.v4") + self.assertEqual( + [capability["id"] for capability in provider["capabilities"]], + [ + "gelios.monitoring_config.current.read", + "gelios.units.current.read", + ], + ) + self.assertEqual( + [capability["request"]["url"] for capability in provider["capabilities"]], + [ + "https://api.geliospro.com/api/v1/users/me/monitoring-config", + "https://api.geliospro.com/api/v1/units?incltrip=true", + ], + ) + self.assertIn("function capabilityRequests(capability)", provider_catalog_source) + self.assertIn("exactHttpRequestUrl(n8n)", provider_catalog_source) + self.assertIn("providerRequestNodeIds", service_source) + self.assertIn("providerRequestNodeIds", store_source) def test_builder_requires_a_fresh_never_issued_patch_id(self): with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-id-") as directory: @@ -140,6 +188,8 @@ class EnginePublishGrantArtifactTest(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn(expected, result.stderr) + if not self.historical_source_is_current(): + return self.build(artifact_dir, PATCH_ID) duplicate = subprocess.run( ["node", str(BUILDER), PATCH_ID], diff --git a/infra/deploy-runner/test_external_data_plane_artifact.py b/infra/deploy-runner/test_external_data_plane_artifact.py index b75a630..2b4e65b 100644 --- a/infra/deploy-runner/test_external_data_plane_artifact.py +++ b/infra/deploy-runner/test_external_data_plane_artifact.py @@ -20,7 +20,9 @@ EXPECTED_ENTRIES = [ "platform/packages/external-provider-contract/src/data-product.mjs", "platform/packages/external-provider-contract/src/intake-batch.mjs", "platform/packages/external-provider-contract/src/index.mjs", + "platform/packages/external-provider-contract/src/provider-package.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs", + "platform/packages/external-provider-contract/providers/gelios", ] diff --git a/infra/deploy-runner/test_module_foundry_consumer_policy_artifact.py b/infra/deploy-runner/test_module_foundry_consumer_policy_artifact.py new file mode 100644 index 0000000..4167c1a --- /dev/null +++ b/infra/deploy-runner/test_module_foundry_consumer_policy_artifact.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +PLATFORM_ROOT = SCRIPT_DIR.parent.parent +BUILDER = SCRIPT_DIR / "build-module-foundry-consumer-policy-artifact.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +PATCH_ID = "module-foundry-consumer-policy-v4-unit-001" +EXPECTED_FILES = ( + "registry/data-product-consumer-policies.json", + "scripts/validate-registry.mjs", + "server/foundry-data-product-consumer.mjs", + "server/foundry-data-product-consumer.test.mjs", +) + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_module_foundry_consumer_policy_artifact", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class ModuleFoundryConsumerPolicyArtifactTest(unittest.TestCase): + def build(self, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER), PATCH_ID], + cwd=PLATFORM_ROOT, + env=environment, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + def test_builder_is_deterministic_exact_scope_and_runner_compatible(self): + with tempfile.TemporaryDirectory(prefix="nodedc-foundry-policy-v4-") as directory: + root = Path(directory) + first = self.build(root / "first") + second = self.build(root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest()) + self.assertEqual(tuple(first["files"]), EXPECTED_FILES) + + extracted = root / "loaded" + extracted.mkdir() + manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted) + self.assertEqual(manifest["component"], "module-foundry") + self.assertEqual(tuple(entries), EXPECTED_FILES) + self.assertEqual(RUNNER.component_services("module-foundry", entries), ("nodedc-module-foundry",)) + + registry = json.loads((payload / EXPECTED_FILES[0]).read_text(encoding="utf-8")) + policies = [ + policy for policy in registry["policies"] + if policy["dataProductId"] == "fleet.positions.current.v4" + and policy["productVersion"] == "4.0.0" + ] + self.assertEqual(len(policies), 1) + policy = policies[0] + self.assertEqual(policy["id"], "map-moving-object-current-v4") + self.assertEqual(policy["statusContract"]["attribute"], "signal_state") + self.assertEqual(policy["statusContract"]["allowedValues"], ["active", "inactive"]) + self.assertEqual(policy["statusContract"]["freshness"], "none") + self.assertIsNone(policy["staleAfterMs"]) + + with tarfile.open(first_artifact, "r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + regular_payloads = [ + archive.extractfile(member).read() + for member in members + if member.isfile() + ] + self.assertFalse(any(Path(name).name.startswith("._") for name in names)) + self.assertFalse(any("/.git/" in name or "/node_modules/" in name or "/runtime-data/" in name for name in names)) + self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(regular_payloads)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_module_foundry_filter_toggle_artifact.py b/infra/deploy-runner/test_module_foundry_filter_toggle_artifact.py new file mode 100644 index 0000000..7e865d1 --- /dev/null +++ b/infra/deploy-runner/test_module_foundry_filter_toggle_artifact.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 + +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +PLATFORM_ROOT = SCRIPT_DIR.parent.parent +BUILDER = SCRIPT_DIR / "build-module-foundry-filter-toggle-artifact.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +PATCH_ID = "module-foundry-filter-toggle-unit-001" +EXPECTED_FILES = ( + "apps/catalog/src/MapFixturePreview.tsx", + "apps/catalog/src/mapPresentationProfile.ts", + "scripts/map-presentation-filters.test.mjs", +) + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_module_foundry_filter_toggle_artifact", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class ModuleFoundryFilterToggleArtifactTest(unittest.TestCase): + def build(self, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER), PATCH_ID], + cwd=PLATFORM_ROOT, + env=environment, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + def test_builder_is_deterministic_exact_scope_and_runner_compatible(self): + with tempfile.TemporaryDirectory(prefix="nodedc-foundry-filter-toggle-") as directory: + root = Path(directory) + first = self.build(root / "first") + second = self.build(root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest()) + self.assertEqual(tuple(first["files"]), EXPECTED_FILES) + + extracted = root / "loaded" + extracted.mkdir() + manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted) + self.assertEqual(manifest["component"], "module-foundry") + self.assertEqual(tuple(entries), EXPECTED_FILES) + self.assertEqual(RUNNER.component_services("module-foundry", entries), ("nodedc-module-foundry",)) + + helper = (payload / EXPECTED_FILES[1]).read_text(encoding="utf-8") + preview = (payload / EXPECTED_FILES[0]).read_text(encoding="utf-8") + regression = (payload / EXPECTED_FILES[2]).read_text(encoding="utf-8") + self.assertIn("toggleMapPresentationFacetSelection", helper) + self.assertIn("return unconstrained", helper) + self.assertIn("filters: toggleMapPresentationFacetSelection(filters, field, value)", preview) + self.assertIn("interactive deselect of the last chip removes the facet constraint", regression) + + with tarfile.open(first_artifact, "r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + regular_payloads = [ + archive.extractfile(member).read() + for member in members + if member.isfile() + ] + self.assertFalse(any(Path(name).name.startswith("._") for name in names)) + self.assertFalse(any("/.git/" in name or "/node_modules/" in name or "/runtime-data/" in name for name in names)) + self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(regular_payloads)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_platform_registry.py b/infra/deploy-runner/test_platform_registry.py index c0a6b2d..9a3fa5f 100644 --- a/infra/deploy-runner/test_platform_registry.py +++ b/infra/deploy-runner/test_platform_registry.py @@ -372,8 +372,10 @@ class CanonicalPlatformRegistryTest(unittest.TestCase): original_root = RUNNER.COMPONENTS["engine"]["payload_root"] original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 + original_installed_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 RUNNER.COMPONENTS["engine"]["payload_root"] = root RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected try: self.assertEqual( RUNNER.preflight_engine_data_product_publish_grant_predecessor(), @@ -391,6 +393,7 @@ class CanonicalPlatformRegistryTest(unittest.TestCase): RUNNER.preflight_engine_data_product_publish_grant_predecessor() finally: RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = original_installed_hashes RUNNER.COMPONENTS["engine"]["payload_root"] = original_root def test_installed_publish_update_preserves_foundation_and_allows_only_grant_source_changes(self): @@ -441,8 +444,10 @@ class CanonicalPlatformRegistryTest(unittest.TestCase): original_root = RUNNER.COMPONENTS["engine"]["payload_root"] original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 + original_installed_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 RUNNER.COMPONENTS["engine"]["payload_root"] = root RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected try: self.assertEqual( RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload), @@ -470,6 +475,99 @@ class CanonicalPlatformRegistryTest(unittest.TestCase): RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload) finally: RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = original_installed_hashes + RUNNER.COMPONENTS["engine"]["payload_root"] = original_root + + def test_composite_provider_update_requires_exact_v3_to_v4_catalog_and_four_paths(self): + engine_source = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA" + current_target_sha256 = { + rel: hashlib.sha256((engine_source / rel).read_bytes()).hexdigest() + for rel in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES + } + if current_target_sha256 != RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256: + self.skipTest("historical v3-to-v4 fixture source has advanced to its exact successor") + with tempfile.TemporaryDirectory(prefix="nodedc-composite-provider-update-") as directory: + base = Path(directory) + root = base / "installed" + payload = base / "payload" + root.mkdir() + payload.mkdir() + + v3_catalog = { + "schemaVersion": "nodedc.engine.provider-security-catalog/v1", + "packages": [{ + "id": "gelios.provider.v3", + "version": "3.0.0", + "providerId": "gelios", + "providerCredential": { + "authModeId": "gelios.rest-rotating-bearer.v3", + "credentialType": "httpBearerAuth", + }, + "capabilities": [{ + "id": "gelios.units.current.read", + "classification": "read", + "status": "implemented", + "request": { + "method": "GET", + "url": "https://api.geliospro.com/api/v1/units", + }, + "dataProductIds": ["fleet.positions.current.v2"], + }], + "publisher": { + "nodeType": "n8n-nodes-ndc.ndcDataProductPublish", + "credentialType": "ndcDataProductWriterApi", + }, + }], + } + installed_catalog = f"{json.dumps(v3_catalog, indent=2)}\n" + self.assertEqual( + hashlib.sha256(installed_catalog.encode()).hexdigest(), + RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256, + ) + + for rel in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES: + if rel == "nodedc-source/server/dataProductPublishGrant": + continue + for destination in (root, payload): + path = destination / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"shared:{rel}\n", encoding="utf-8") + for name in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES: + rel = f"nodedc-source/server/dataProductPublishGrant/{name}" + for destination in (root, payload): + path = destination / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"installed:{name}\n", encoding="utf-8") + + catalog_rel = RUNNER.ENGINE_PROVIDER_SECURITY_CATALOG_REL + (root / catalog_rel).write_text(installed_catalog, encoding="utf-8") + for rel in RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS: + source = engine_source / rel + (payload / rel).write_bytes(source.read_bytes()) + + original_root = RUNNER.COMPONENTS["engine"]["payload_root"] + RUNNER.COMPONENTS["engine"]["payload_root"] = root + try: + with mock.patch.object( + RUNNER, + "validate_installed_engine_data_product_publish_grant_foundation", + return_value="compose-sha256", + ): + self.assertEqual( + RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload), + { + "mode": "installed-composite-provider-update", + "compose_sha256": "compose-sha256", + "changed_paths": RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS, + }, + ) + (root / catalog_rel).write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex( + RUNNER.DeployError, + "composite provider catalog transition mismatch", + ): + RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload) + finally: RUNNER.COMPONENTS["engine"]["payload_root"] = original_root def test_edp_healthcheck_requires_database_and_managed_provisioning(self): diff --git a/packages/external-provider-contract/README.md b/packages/external-provider-contract/README.md index 93bc06f..9d92f50 100644 --- a/packages/external-provider-contract/README.md +++ b/packages/external-provider-contract/README.md @@ -48,14 +48,18 @@ read-capability передаёт все entities, которые provider воз boundary запрещены; сортировка и видимость принадлежат Data Product consumer и Foundry. -Первый production-shaped package — `providers/gelios/v1`. Он фиксирует реальный -Gelios REST `GET /api/v1/units` transport contract и точный output -`fleet.positions.current.v1@1.0.0` с revision -`ontology.map.moving_object.v1`. Все Data Product fields используют snake_case. -Его optional `tokenLifecycle` точно описывает два provider-issued artifacts — -`access` и `refresh`: request использует `access`, а refresh пока имеет режим -`operator_managed`. Это metadata без secret values и без заявления о -реализованном автоматическом refresh. +Текущий production-shaped package — `providers/gelios/v5`. Он сохраняет два +официальных Gelios REST safe-read: `GET /api/v1/users/me/monitoring-config` и +`GET /api/v1/units?incltrip=true`, а также точный immutable output +`fleet.positions.current.v4@4.0.0` с revision +`ontology.map.moving_object.v3`. Единственные monitoring-state fields — +`signal_state` (`active|inactive`) и `movement_state` (`moving|stopped`), +полученные из Ontology Gelios `v1.1.0`. Все Data Product fields используют +snake_case. `fieldContracts` фиксирует тип и обязательность каждого поля, а +закрытые state values проверяются и в provider mapping, и на каждом publish в +External Data Plane. Native rotating credential использует `access`, а +`refresh` остаётся внутри NDC L2 Credentials. Предыдущие Data Product versions +не переписываются и остаются legacy-compatible. ## Проверяемые v1 contracts @@ -64,8 +68,11 @@ Gelios REST `GET /api/v1/units` transport contract и точный output запрещены; publisher представлен system-managed declaration/status без ref. - `Collection Profile` — явная policy сбора. `manual` не может скрыто содержать polling interval; `realtime` требует interval не чаще одного раза в секунду. -- `Data Product` — нормализованный versioned output с semantic types, полями и - внутренней аудиторией. +- `Data Product` — нормализованный versioned output с semantic types, полями, + optional `fieldContracts` и внутренней аудиторией. Если `fieldContracts` + задан, он обязан покрывать точный набор fields; каждый contract задаёт + `type`, `required`, optional closed `enum` и числовые bounds. Отсутствие + contracts допустимо только для опубликованных legacy versions. - `Intake Batch` — canonical **scoped** record, который External Data Plane валидирует и сохраняет: source, contract revision, idempotency, restricted raw envelope и canonical facts. Его `source` содержит `providerId`, @@ -153,10 +160,11 @@ Provider auth и внутренние workload capabilities используют credential domains. После сохранения ядро владеет secret, а graph и MCP используют только opaque reference. -Gelios выдаёт ровно access token и refresh token. Текущий credential type -`httpBearerAuth` использует access token для HTTP request. Автоматический обмен -refresh → access в текущем runtime не доказан и поэтому не заявлен: package -фиксирует `refreshMode: operator_managed`. Название credential с текстом вроде +Gelios выдаёт ровно access token и refresh token. Production credential type +`ndcProviderRotatingAccessApi` использует access token для HTTP request и выполняет +refresh → access в native n8n `preAuthentication`; provider package v3 фиксирует +`refreshMode: runtime_managed`. Оба secret artifact остаются в native Credentials и не +попадают в graph, package или trace. Название credential с текстом вроде `read access` является лишь локальной меткой; read-классификацию задают разрешённые endpoint/method в capability catalog и workflow policy, а не scope самого access token. В deployed Engine exact method/path policy ещё не diff --git a/packages/external-provider-contract/providers/gelios/v3/README.md b/packages/external-provider-contract/providers/gelios/v3/README.md new file mode 100644 index 0000000..f10f6d2 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v3/README.md @@ -0,0 +1,20 @@ +# Gelios provider package v3 + +Version 3 describes the production REST transport used by the L2 workflow: +`GET https://api.geliospro.com/api/v1/units`. The provider credential keeps the +Gelios access/refresh pair inside native NDC L2 Credentials; n8n refreshes the +access token at request time and exposes neither secret to the graph. The +normalized output is the immutable provider-neutral Data Product +`fleet.positions.current.v2@2.0.0`. + +The product adds four orthogonal state facets owned by L2 semantic mapping: + +- `availability_state`: `online`, `offline`, `unknown`; +- `motion_state`: `moving`, `stationary`, `unknown`; +- `position_state`: `valid`, `low_quality`, `missing`; +- `freshness_state`: `fresh`, `stale`. + +`state_policy_version` identifies the mapping policy that produced the facets. +`operational_status` remains in the product as a backwards-compatible coarse +state; presentation classes, colours, pin proportions and label geometry remain +Foundry-owned and are never part of this provider package. diff --git a/packages/external-provider-contract/providers/gelios/v3/index.mjs b/packages/external-provider-contract/providers/gelios/v3/index.mjs new file mode 100644 index 0000000..9cc403a --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v3/index.mjs @@ -0,0 +1,9 @@ +export { + GELIOS_POSITIONS_DATA_PRODUCT_ID, + GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + GELIOS_POSITIONS_ONTOLOGY_REVISION, + GELIOS_PROVIDER_PACKAGE_ID, + GELIOS_PROVIDER_PACKAGE_VERSION, + GELIOS_UNIT_SOURCE_ID_PREFIX, + geliosProviderPackageV3, +} from "./package.mjs"; diff --git a/packages/external-provider-contract/providers/gelios/v3/package.mjs b/packages/external-provider-contract/providers/gelios/v3/package.mjs new file mode 100644 index 0000000..e09d8f6 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v3/package.mjs @@ -0,0 +1,185 @@ +import { geliosProviderPackageV1 } from "../v1/package.mjs"; + +export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v3"; +export const GELIOS_PROVIDER_PACKAGE_VERSION = "3.0.0"; +export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v2"; +export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "2.0.0"; +export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v2"; +export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-"; + +const FIELD_POLICY_ID = "gelios.positions.current.fields.v2"; +const AUTH_MODE_ID = "gelios.rest-rotating-bearer.v3"; +const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v3"; +const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v3"; +const MAPPING_ID = "gelios.units.to.fleet.positions.current.v3"; +const TEMPLATE_ID = "gelios.positions.current.l2.v3"; +const STATE_POLICY_VERSION = "map-moving-object-state/v1"; + +const targetFields = Object.freeze([ + "availability_state", + "course_degrees", + "display_name", + "elevation_meters", + "freshness_state", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "motion_state", + "object_kind", + "operational_status", + "position_source", + "position_state", + "position_valid", + "quality_flags", + "satellite_count", + "speed_kph", + "state_policy_version", +]); + +const value = structuredClone(geliosProviderPackageV1); +value.id = GELIOS_PROVIDER_PACKAGE_ID; +value.version = GELIOS_PROVIDER_PACKAGE_VERSION; +value.manifest = { + ...value.manifest, + id: "gelios.provider.manifest.v3", + version: GELIOS_PROVIDER_PACKAGE_VERSION, + authModeIds: [AUTH_MODE_ID], + fieldPolicyIds: [FIELD_POLICY_ID], + collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID], + dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID], + mappingContractIds: [MAPPING_ID], + l2TemplateIds: [TEMPLATE_ID], +}; +value.authModes = [{ + ...value.authModes[0], + id: AUTH_MODE_ID, + tokenLifecycle: { + artifacts: ["access", "refresh"], + requestArtifact: "access", + refreshMode: "runtime_managed", + }, +}]; +value.capabilities = [{ + ...value.capabilities[0], + authModeId: AUTH_MODE_ID, +}]; +value.fieldPolicies = [{ + ...value.fieldPolicies[0], + id: FIELD_POLICY_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + targetFields: [...targetFields], +}]; +value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({ + ...profile, + id: index === 0 ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + mappingContractId: MAPPING_ID, + fieldPolicyId: FIELD_POLICY_ID, + l2TemplateId: TEMPLATE_ID, +})); +value.dataProducts = [{ + id: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: [...targetFields], + history: { + mode: "sampled", + intervalMs: 60000, + strategy: "latest-per-entity-per-bucket", + retentionDays: 90, + }, +}]; +value.mappingContracts = [{ + ...value.mappingContracts[0], + id: MAPPING_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + fieldPolicyId: FIELD_POLICY_ID, + target: { + ...value.mappingContracts[0].target, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + }, + derivations: { + ...value.mappingContracts[0].derivations, + availability_state: { + kind: "ordered_rules", + rules: [ + "observed_age_gt_limit.offline", + "otherwise.online", + ], + default: "unknown", + parameters: { staleAfterMs: 60000 }, + }, + freshness_state: { + kind: "ordered_rules", + rules: [ + "observed_age_gt_limit.stale", + "otherwise.fresh", + ], + default: "stale", + parameters: { staleAfterMs: 60000 }, + }, + motion_state: { + kind: "ordered_rules", + rules: [ + "speed_kph_gte_threshold.moving", + "speed_kph_lt_threshold.stationary", + "otherwise.unknown", + ], + default: "unknown", + parameters: { movingThresholdKph: 1 }, + }, + position_state: { + kind: "ordered_rules", + rules: [ + "position_invalid.missing", + "position_quality_below_threshold.low_quality", + "otherwise.valid", + ], + default: "missing", + parameters: { + minSatelliteCount: 4, + maxHdop: 5, + maxHorizontalAccuracyMeters: 100, + }, + }, + }, + fact: { + ...value.mappingContracts[0].fact, + attributes: { + ...value.mappingContracts[0].fact.attributes, + availability_state: { derive: "availability_state" }, + freshness_state: { derive: "freshness_state" }, + motion_state: { derive: "motion_state" }, + position_state: { derive: "position_state" }, + state_policy_version: { constant: STATE_POLICY_VERSION }, + }, + }, +}]; +value.l2Templates = [{ + ...value.l2Templates[0], + id: TEMPLATE_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + credentialBindings: value.l2Templates[0].credentialBindings.map((binding) => ( + binding.role === "provider" ? { ...binding, authModeId: AUTH_MODE_ID } : binding + )), + steps: value.l2Templates[0].steps.map((step) => { + if (step.kind === "semantic_mapping") return { ...step, mappingContractId: MAPPING_ID }; + if (step.kind === "data_product_publish") return { ...step, dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID }; + return step; + }), +}]; + +export const geliosProviderPackageV3 = deepFreeze(value); + +function deepFreeze(input) { + if (!input || typeof input !== "object" || Object.isFrozen(input)) return input; + Object.freeze(input); + for (const child of Object.values(input)) deepFreeze(child); + return input; +} diff --git a/packages/external-provider-contract/providers/gelios/v4/README.md b/packages/external-provider-contract/providers/gelios/v4/README.md new file mode 100644 index 0000000..7c31eba --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v4/README.md @@ -0,0 +1,32 @@ +# Gelios provider package v4 + +Version 4 is the first provider package whose monitoring states are derived +strictly from the Gelios Ontology `v1.1.0` value contracts. + +The collection uses two official safe-read capabilities with the same native +rotating Gelios credential: + +- `GET /api/v1/users/me/monitoring-config` supplies the account/user + `signalActiveDuration` and `signalSomewhatInactiveDuration` values used by + the official monitoring client; +- `GET /api/v1/units?incltrip=true` supplies the complete credential-visible + unit set, last-message time, speed and position facts. + +The immutable provider-neutral output is +`fleet.positions.current.v3@3.0.0`. Its only monitoring-state fields are: + +- `signal_state`: `active` or `inactive`; +- `movement_state`: `moving` or `stopped`. + +The realtime collection profile resolves the signal threshold in one explicit +order: a positive `signalSomewhatInactiveDuration`, then a positive +`signalActiveDuration`, then the Robot2B profile fallback of `120` seconds. +The fallback is profile metadata with observable provenance; it is never a +silent mapper default. If no live threshold and no positive profile fallback +exist, publication fails closed instead of manufacturing `inactive` facts. + +There is no unknown, freshness, GPS-quality, position-quality, parked, +no-position or aggregate operational-status state. Missing geometry remains an +absent geometry fact and never becomes a status. Labels, counters, colours and +layout remain Foundry presentation metadata, but Foundry may only use the exact +values declared by Ontology. diff --git a/packages/external-provider-contract/providers/gelios/v4/index.mjs b/packages/external-provider-contract/providers/gelios/v4/index.mjs new file mode 100644 index 0000000..0f8a328 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v4/index.mjs @@ -0,0 +1,9 @@ +export { + GELIOS_POSITIONS_DATA_PRODUCT_ID, + GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + GELIOS_POSITIONS_ONTOLOGY_REVISION, + GELIOS_PROVIDER_PACKAGE_ID, + GELIOS_PROVIDER_PACKAGE_VERSION, + GELIOS_UNIT_SOURCE_ID_PREFIX, + geliosProviderPackageV4, +} from "./package.mjs"; diff --git a/packages/external-provider-contract/providers/gelios/v4/package.mjs b/packages/external-provider-contract/providers/gelios/v4/package.mjs new file mode 100644 index 0000000..53031c6 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v4/package.mjs @@ -0,0 +1,214 @@ +import { geliosProviderPackageV3 } from "../v3/package.mjs"; + +export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v4"; +export const GELIOS_PROVIDER_PACKAGE_VERSION = "4.0.0"; +export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v3"; +export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "3.0.0"; +export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v3"; +export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-"; + +const UNIT_CAPABILITY_ID = "gelios.units.current.read"; +const MONITORING_CONFIG_CAPABILITY_ID = "gelios.monitoring_config.current.read"; +const FIELD_POLICY_ID = "gelios.positions.current.fields.v3"; +const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v4"; +const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v4"; +const MAPPING_ID = "gelios.units.to.fleet.positions.current.v4"; +const TEMPLATE_ID = "gelios.positions.current.l2.v4"; + +const targetFields = Object.freeze([ + "course_degrees", + "display_name", + "elevation_meters", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "movement_state", + "object_kind", + "position_source", + "satellite_count", + "signal_state", + "speed_kph", +]); + +const value = structuredClone(geliosProviderPackageV3); +const unitCapability = value.capabilities.find((capability) => capability.id === UNIT_CAPABILITY_ID); +const baseMapping = value.mappingContracts[0]; +const baseAttributes = baseMapping.fact.attributes; +const authModeId = value.authModes[0].id; + +value.id = GELIOS_PROVIDER_PACKAGE_ID; +value.version = GELIOS_PROVIDER_PACKAGE_VERSION; +value.manifest = { + ...value.manifest, + id: "gelios.provider.manifest.v4", + version: GELIOS_PROVIDER_PACKAGE_VERSION, + ontology: { + packageId: "gelios", + revision: "ontology.gelios.v1_1", + }, + capabilityIds: [MONITORING_CONFIG_CAPABILITY_ID, UNIT_CAPABILITY_ID], + fieldPolicyIds: [FIELD_POLICY_ID], + collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID], + dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID], + mappingContractIds: [MAPPING_ID], + l2TemplateIds: [TEMPLATE_ID], +}; +value.capabilities = [{ + id: MONITORING_CONFIG_CAPABILITY_ID, + classification: "read", + status: "implemented", + authModeId, + request: { + method: "GET", + baseUrl: "https://api.geliospro.com", + path: "/api/v1/users/me/monitoring-config", + query: {}, + response: { + collectionPaths: ["$", "data"], + pagination: "single_bounded_response", + }, + }, + entityScope: { + mode: "all_visible_to_credential", + refresh: "each_collection_run", + businessEntityFilter: "forbidden", + }, +}, { + ...unitCapability, + request: { + ...unitCapability.request, + query: { incltrip: "true" }, + }, +}]; +value.fieldPolicies = [{ + ...value.fieldPolicies[0], + id: FIELD_POLICY_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + targetFields: [...targetFields], +}]; +value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({ + ...profile, + id: index === 0 ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + capabilityIds: [MONITORING_CONFIG_CAPABILITY_ID, UNIT_CAPABILITY_ID], + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + mappingContractId: MAPPING_ID, + fieldPolicyId: FIELD_POLICY_ID, + l2TemplateId: TEMPLATE_ID, +})); +value.dataProducts = [{ + id: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: [...targetFields], + history: { + mode: "sampled", + intervalMs: 60000, + strategy: "latest-per-entity-per-bucket", + retentionDays: 90, + }, +}]; +value.mappingContracts = [{ + ...baseMapping, + id: MAPPING_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + sourceCapabilityId: UNIT_CAPABILITY_ID, + fieldPolicyId: FIELD_POLICY_ID, + target: { + ...baseMapping.target, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + }, + derivations: { + signal_state: { + kind: "ordered_rules", + rules: [ + "last_message_missing.inactive", + "last_message_age_lt_resolved_monitoring_limit.active", + "otherwise.inactive", + ], + default: "inactive", + parameters: { + monitoringConfigCapability: MONITORING_CONFIG_CAPABILITY_ID, + activeDurationPath: "signalActiveDuration", + somewhatInactiveDurationPath: "signalSomewhatInactiveDuration", + inactiveDurationPath: "signalSomewhatInactiveDuration", + collectionProfileFallbackSeconds: 120, + thresholdResolution: "somewhatInactive_then_active_then_profileFallback", + lastMessageTimePath: "lastMsg.time", + }, + }, + movement_state: { + kind: "ordered_rules", + rules: [ + "integer_speed_gt_threshold.moving", + "otherwise.stopped", + ], + default: "stopped", + parameters: { + speedPath: "lastMsg.speed", + movingThresholdKph: 2, + }, + }, + }, + fact: { + ...baseMapping.fact, + attributes: { + course_degrees: baseAttributes.course_degrees, + display_name: baseAttributes.display_name, + elevation_meters: baseAttributes.elevation_meters, + hdop: baseAttributes.hdop, + horizontal_accuracy_meters: baseAttributes.horizontal_accuracy_meters, + movement_state: { derive: "movement_state" }, + object_kind: baseAttributes.object_kind, + position_source: baseAttributes.position_source, + satellite_count: baseAttributes.satellite_count, + signal_state: { derive: "signal_state" }, + speed_kph: baseAttributes.speed_kph, + }, + }, +}]; +value.l2Templates = [{ + ...value.l2Templates[0], + id: TEMPLATE_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + steps: [{ + id: "collection.trigger", + kind: "collection_trigger", + collectionProfileDriven: true, + }, { + id: "provider.fetch-monitoring-config", + kind: "provider_request", + capabilityId: MONITORING_CONFIG_CAPABILITY_ID, + }, { + id: "provider.fetch-units", + kind: "provider_request", + capabilityId: UNIT_CAPABILITY_ID, + }, { + id: "provider.extract-units", + kind: "extract_items", + capabilityId: UNIT_CAPABILITY_ID, + }, { + id: "ontology.map", + kind: "semantic_mapping", + mappingContractId: MAPPING_ID, + }, { + id: "data-product.publish", + kind: "data_product_publish", + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + nodeType: "n8n-nodes-ndc.ndcDataProductPublish", + }], +}]; + +export const geliosProviderPackageV4 = deepFreeze(value); + +function deepFreeze(input) { + if (!input || typeof input !== "object" || Object.isFrozen(input)) return input; + Object.freeze(input); + for (const child of Object.values(input)) deepFreeze(child); + return input; +} diff --git a/packages/external-provider-contract/providers/gelios/v5/README.md b/packages/external-provider-contract/providers/gelios/v5/README.md new file mode 100644 index 0000000..cdbbf57 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v5/README.md @@ -0,0 +1,20 @@ +# Gelios provider package v5 + +Version 5 preserves the Ontology-exact monitoring logic from provider package +v4 and publishes it through the immutable `fleet.positions.current.v4@4.0.0` +contract. + +The Data Product now declares a machine-enforced contract for every published +field. In particular: + +- `signal_state` is required and accepts only `active` or `inactive`; +- `movement_state` is required and accepts only `moving` or `stopped`; +- identity/presentation attributes are typed and required where the mapping + must always produce them; +- optional telemetry is type-checked and bounded where the physical domain has + a stable lower or upper limit; +- geometry remains optional, but when present it must be a valid GeoJSON Point. + +The provider mapping is checked against the same contract before it can be +packaged, and External Data Plane checks every publish at runtime. Existing +v1-v3 products remain immutable and backward compatible. diff --git a/packages/external-provider-contract/providers/gelios/v5/index.mjs b/packages/external-provider-contract/providers/gelios/v5/index.mjs new file mode 100644 index 0000000..07739f8 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v5/index.mjs @@ -0,0 +1,9 @@ +export { + GELIOS_POSITIONS_DATA_PRODUCT_ID, + GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + GELIOS_POSITIONS_ONTOLOGY_REVISION, + GELIOS_PROVIDER_PACKAGE_ID, + GELIOS_PROVIDER_PACKAGE_VERSION, + GELIOS_UNIT_SOURCE_ID_PREFIX, + geliosProviderPackageV5, +} from "./package.mjs"; diff --git a/packages/external-provider-contract/providers/gelios/v5/package.mjs b/packages/external-provider-contract/providers/gelios/v5/package.mjs new file mode 100644 index 0000000..dff740e --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v5/package.mjs @@ -0,0 +1,105 @@ +import { geliosProviderPackageV4 } from "../v4/package.mjs"; + +export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v5"; +export const GELIOS_PROVIDER_PACKAGE_VERSION = "5.0.0"; +export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v4"; +export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "4.0.0"; +export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v3"; +export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-"; + +const FIELD_POLICY_ID = "gelios.positions.current.fields.v4"; +const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v5"; +const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v5"; +const MAPPING_ID = "gelios.units.to.fleet.positions.current.v5"; +const TEMPLATE_ID = "gelios.positions.current.l2.v5"; + +const value = structuredClone(geliosProviderPackageV4); +const dataProduct = { + id: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: [...value.dataProducts[0].fields], + fieldContracts: { + course_degrees: { type: "number", required: false, minimum: 0, maximum: 360 }, + display_name: { type: "string", required: true }, + elevation_meters: { type: "number", required: false }, + geometry: { type: "point", required: false }, + hdop: { type: "number", required: false, minimum: 0 }, + horizontal_accuracy_meters: { type: "number", required: false, minimum: 0 }, + movement_state: { type: "string", required: true, enum: ["moving", "stopped"] }, + object_kind: { type: "string", required: true }, + position_source: { type: "string", required: true }, + satellite_count: { type: "number", required: false, minimum: 0 }, + signal_state: { type: "string", required: true, enum: ["active", "inactive"] }, + speed_kph: { type: "number", required: false, minimum: 0 }, + }, + history: { ...value.dataProducts[0].history }, +}; + +value.id = GELIOS_PROVIDER_PACKAGE_ID; +value.version = GELIOS_PROVIDER_PACKAGE_VERSION; +value.manifest = { + ...value.manifest, + id: "gelios.provider.manifest.v5", + version: GELIOS_PROVIDER_PACKAGE_VERSION, + fieldPolicyIds: [FIELD_POLICY_ID], + collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID], + dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID], + mappingContractIds: [MAPPING_ID], + l2TemplateIds: [TEMPLATE_ID], +}; +value.fieldPolicies = value.fieldPolicies.map((policy) => ({ + ...policy, + id: FIELD_POLICY_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, +})); +value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({ + ...profile, + id: index === 0 ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + mappingContractId: MAPPING_ID, + fieldPolicyId: FIELD_POLICY_ID, + l2TemplateId: TEMPLATE_ID, +})); +value.dataProducts = [dataProduct]; +value.mappingContracts = value.mappingContracts.map((mapping) => ({ + ...mapping, + id: MAPPING_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + fieldPolicyId: FIELD_POLICY_ID, + target: { + ...mapping.target, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + }, +})); +const attributes = value.mappingContracts[0].fact.attributes; +Object.assign(attributes.course_degrees, { minimum: 0, maximum: 360, omitIfInvalid: true }); +Object.assign(attributes.hdop, { minimum: 0, omitIfInvalid: true }); +Object.assign(attributes.horizontal_accuracy_meters, { minimum: 0, omitIfInvalid: true }); +Object.assign(attributes.satellite_count, { minimum: 0, omitIfInvalid: true }); +Object.assign(attributes.speed_kph, { minimum: 0, omitIfInvalid: true }); +value.l2Templates = value.l2Templates.map((template) => ({ + ...template, + id: TEMPLATE_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + steps: template.steps.map((step) => { + if (step.kind === "semantic_mapping") return { ...step, mappingContractId: MAPPING_ID }; + if (step.kind === "data_product_publish") return { ...step, dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID }; + return step; + }), +})); + +export const geliosProviderPackageV5 = deepFreeze(value); + +function deepFreeze(input) { + if (!input || typeof input !== "object" || Object.isFrozen(input)) return input; + Object.freeze(input); + for (const child of Object.values(input)) deepFreeze(child); + return input; +} diff --git a/packages/external-provider-contract/src/provider-package.mjs b/packages/external-provider-contract/src/provider-package.mjs index bd0570f..6b0b68c 100644 --- a/packages/external-provider-contract/src/provider-package.mjs +++ b/packages/external-provider-contract/src/provider-package.mjs @@ -20,6 +20,7 @@ const TOKEN_REFRESH_MODES = new Set(["not_applicable", "operator_managed", "runt const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]); const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const HISTORY_MODES = new Set(["none", "all", "sampled"]); +const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point"]); const L2_STEP_KINDS = new Set([ "collection_trigger", "provider_request", @@ -115,8 +116,10 @@ const DATA_PRODUCT_KEYS = new Set([ "deliveryMode", "semanticTypes", "fields", + "fieldContracts", "history", ]); +const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]); const HISTORY_KEYS = new Set(["mode", "intervalMs", "strategy", "retentionDays"]); const MAPPING_KEYS = new Set([ "schemaVersion", @@ -130,7 +133,10 @@ const MAPPING_KEYS = new Set([ ]); const MAPPING_TARGET_KEYS = new Set(["dataProductId", "version", "ontologyRevision", "semanticType"]); const FACT_KEYS = new Set(["sourceId", "semanticType", "observedAt", "geometry", "attributes"]); -const EXPRESSION_KEYS = new Set(["strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive", "omitIfMissing"]); +const EXPRESSION_KEYS = new Set([ + "strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive", + "omitIfMissing", "omitIfInvalid", "minimum", "maximum", +]); const GEOMETRY_KEYS = new Set(["type", "longitude", "latitude", "omitIfInvalid"]); const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]); const TEMPLATE_KEYS = new Set([ @@ -226,9 +232,6 @@ export function validateProviderPackage(value) { } for (const profile of collectionProfiles) { const selectedCapabilityIds = Array.isArray(profile.capabilityIds) ? profile.capabilityIds : []; - if (selectedCapabilityIds.length !== 1) { - errors.push(`collectionProfile.${profile.id}.capabilityIds_must_select_one_v1_read`); - } selectedCapabilityIds.forEach((id) => { assertReference(id, capabilityIds, `collectionProfile.${profile.id}.capabilityIds`, errors); const capability = capabilities.find((item) => item.id === id); @@ -247,10 +250,6 @@ export function validateProviderPackage(value) { if (!selectedCapabilityIds.includes(mapping.sourceCapabilityId)) { errors.push(`collectionProfile.${profile.id}.mapping_source_capability_must_be_selected`); } - if (selectedCapabilityIds.length !== 1 - || selectedCapabilityIds[0] !== mapping.sourceCapabilityId) { - errors.push(`collectionProfile.${profile.id}.capability_chain_must_be_exact_singleton`); - } if (mapping.fieldPolicyId !== profile.fieldPolicyId) errors.push(`collectionProfile.${profile.id}.mapping_field_policy_mismatch`); if (mapping.target?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.mapping_data_product_mismatch`); } @@ -258,12 +257,19 @@ export function validateProviderPackage(value) { errors.push(`collectionProfile.${profile.id}.field_policy_data_product_mismatch`); } if (template) { - const [, requestStep, extractStep, mappingStep, publishStep] = Array.isArray(template.steps) ? template.steps : []; - if (!selectedCapabilityIds.includes(requestStep?.capabilityId) || extractStep?.capabilityId !== requestStep?.capabilityId) { + const steps = Array.isArray(template.steps) ? template.steps : []; + const requestSteps = steps.filter((step) => step?.kind === "provider_request"); + const extractSteps = steps.filter((step) => step?.kind === "extract_items"); + const mappingSteps = steps.filter((step) => step?.kind === "semantic_mapping"); + const publishSteps = steps.filter((step) => step?.kind === "data_product_publish"); + const executedCapabilityIds = requestSteps.map((step) => step.capabilityId); + if (!sameSet(selectedCapabilityIds, executedCapabilityIds) + || extractSteps.length !== 1 + || extractSteps[0]?.capabilityId !== mapping?.sourceCapabilityId) { errors.push(`collectionProfile.${profile.id}.template_capability_chain_mismatch`); } - if (mappingStep?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`); - if (publishStep?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.template_data_product_mismatch`); + if (mappingSteps.length !== 1 || mappingSteps[0]?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`); + if (publishSteps.length !== 1 || publishSteps[0]?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.template_data_product_mismatch`); } } for (const mapping of mappingContracts) { @@ -315,13 +321,15 @@ export function validateProviderPackage(value) { const providerBinding = Array.isArray(template.credentialBindings) ? template.credentialBindings.find((binding) => binding?.role === "provider") : undefined; - const providerRequest = Array.isArray(template.steps) - ? template.steps.find((step) => step?.kind === "provider_request") - : undefined; - const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId); - if (providerBinding && requestCapability - && providerBinding.authModeId !== requestCapability.authModeId) { - errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`); + const providerRequests = Array.isArray(template.steps) + ? template.steps.filter((step) => step?.kind === "provider_request") + : []; + for (const providerRequest of providerRequests) { + const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId); + if (providerBinding && requestCapability + && providerBinding.authModeId !== requestCapability.authModeId) { + errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`); + } } } @@ -557,6 +565,7 @@ function validateDataProductDefinition(value, path, errors) { if (Array.isArray(value.fields) && value.fields.some((field) => SECRET_FIELD_NAME.test(String(field)))) { errors.push(`${path}.fields_must_not_contain_secret_fields`); } + validateFieldContracts(value.fieldContracts, value.fields, `${path}.fieldContracts`, errors); if (!isPlainObject(value.history)) { errors.push(`${path}.history_must_be_object`); } else { @@ -688,8 +697,8 @@ function validateL2Template(value, path, errors) { }); if (!roles.has("provider") || !roles.has("publisher")) errors.push(`${path}.credentialBindings_roles_invalid`); } - if (!Array.isArray(value.steps) || value.steps.length !== 5) { - errors.push(`${path}.steps_must_define_five_boundary_steps`); + if (!Array.isArray(value.steps) || value.steps.length < 5) { + errors.push(`${path}.steps_must_define_complete_boundary`); } else { const stepIds = new Set(); value.steps.forEach((step, index) => { @@ -701,26 +710,41 @@ function validateL2Template(value, path, errors) { stepIds.add(step.id); if (!L2_STEP_KINDS.has(step.kind)) errors.push(`${stepPath}.kind_invalid`); }); - const expectedKinds = [ - "collection_trigger", - "provider_request", - "extract_items", - "semantic_mapping", - "data_product_publish", - ]; - if (!value.steps.every((step, index) => step?.kind === expectedKinds[index])) errors.push(`${path}.steps_sequence_invalid`); + const requestSteps = value.steps.filter((step) => step?.kind === "provider_request"); + const extractSteps = value.steps.filter((step) => step?.kind === "extract_items"); + const mappingSteps = value.steps.filter((step) => step?.kind === "semantic_mapping"); + const publishSteps = value.steps.filter((step) => step?.kind === "data_product_publish"); + const requestStart = 1; + const extractIndex = value.steps.findIndex((step) => step?.kind === "extract_items"); + const expectedSequence = ( + value.steps[0]?.kind === "collection_trigger" + && requestSteps.length >= 1 + && value.steps.slice(requestStart, extractIndex).every((step) => step?.kind === "provider_request") + && extractSteps.length === 1 + && mappingSteps.length === 1 + && publishSteps.length === 1 + && value.steps.at(-3)?.kind === "extract_items" + && value.steps.at(-2)?.kind === "semantic_mapping" + && value.steps.at(-1)?.kind === "data_product_publish" + ); + if (!expectedSequence) errors.push(`${path}.steps_sequence_invalid`); if (value.steps[0]?.collectionProfileDriven !== true) errors.push(`${path}.collection_trigger_must_be_profile_driven`); - if (!value.steps[1]?.capabilityId || value.steps[1]?.mappingContractId !== undefined || value.steps[1]?.dataProductId !== undefined) { - errors.push(`${path}.provider_request_shape_invalid`); + for (const step of requestSteps) { + if (!step?.capabilityId || step?.mappingContractId !== undefined || step?.dataProductId !== undefined) { + errors.push(`${path}.provider_request_shape_invalid`); + } } - if (!value.steps[2]?.capabilityId || value.steps[2]?.mappingContractId !== undefined || value.steps[2]?.dataProductId !== undefined) { + const extractStep = extractSteps[0]; + if (!extractStep?.capabilityId || extractStep?.mappingContractId !== undefined || extractStep?.dataProductId !== undefined) { errors.push(`${path}.extract_items_shape_invalid`); } - if (value.steps[1]?.capabilityId !== value.steps[2]?.capabilityId) errors.push(`${path}.request_extract_capability_mismatch`); - if (!value.steps[3]?.mappingContractId || value.steps[3]?.capabilityId !== undefined || value.steps[3]?.dataProductId !== undefined) { + if (!requestSteps.some((step) => step.capabilityId === extractStep?.capabilityId)) errors.push(`${path}.request_extract_capability_mismatch`); + const mappingStep = mappingSteps[0]; + if (!mappingStep?.mappingContractId || mappingStep?.capabilityId !== undefined || mappingStep?.dataProductId !== undefined) { errors.push(`${path}.semantic_mapping_shape_invalid`); } - if (!value.steps[4]?.dataProductId || value.steps[4]?.nodeType !== "n8n-nodes-ndc.ndcDataProductPublish") { + const publishStep = publishSteps[0]; + if (!publishStep?.dataProductId || publishStep?.nodeType !== "n8n-nodes-ndc.ndcDataProductPublish") { errors.push(`${path}.data_product_publish_shape_invalid`); } } @@ -745,6 +769,122 @@ function validateMappingAgainstProduct(mapping, product, errors) { } const mappedFields = [...Object.keys(mapping.fact?.attributes || {}), ...(mapping.fact?.geometry ? ["geometry"] : [])]; if (!sameSet(mappedFields, product.fields)) errors.push(`${path}.fact_fields_must_match_data_product_fields`); + const contracts = isPlainObject(product.fieldContracts) ? product.fieldContracts : {}; + for (const [field, contract] of Object.entries(contracts)) { + if (!isPlainObject(contract)) continue; + if (field === "geometry") { + if (contract.type !== "point") errors.push(`${path}.fact.geometry_contract_must_be_point`); + if (contract.required === true && mapping.fact?.geometry?.omitIfInvalid === true) { + errors.push(`${path}.fact.geometry_required_but_mapping_can_omit`); + } + continue; + } + const expression = mapping.fact?.attributes?.[field]; + if (!isPlainObject(expression)) continue; + validateExpressionAgainstFieldContract(expression, contract, mapping.derivations, `${path}.fact.attributes.${field}`, errors); + } +} + +function validateFieldContracts(value, fields, path, errors) { + if (value === undefined) return; + if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`); + const names = Object.keys(value); + if (names.length && !sameSet(names, fields)) errors.push(`${path}_must_exactly_match_fields`); + for (const [field, contract] of Object.entries(value)) { + requiredIdentifier(field, `${path}.${field}`, errors); + if (SECRET_FIELD_NAME.test(field)) errors.push(`${path}.${field}_secret_field_not_allowed`); + validateFieldContract(contract, `${path}.${field}`, errors); + } +} + +function validateFieldContract(value, path, errors) { + if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`); + rejectUnknownKeys(value, FIELD_CONTRACT_KEYS, path, errors); + if (!FIELD_CONTRACT_TYPES.has(value.type)) errors.push(`${path}.type_invalid`); + if (typeof value.required !== "boolean") errors.push(`${path}.required_must_be_boolean`); + if (value.enum !== undefined) { + if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length) { + errors.push(`${path}.enum_must_be_nonempty_unique_array`); + } else if (new Set(["point", "string_array"]).has(value.type) + || value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) { + errors.push(`${path}.enum_value_type_invalid`); + } + } + if (value.minimum !== undefined || value.maximum !== undefined) { + if (value.type !== "number") errors.push(`${path}.range_only_allowed_for_number`); + if (value.minimum !== undefined && !Number.isFinite(value.minimum)) errors.push(`${path}.minimum_invalid`); + if (value.maximum !== undefined && !Number.isFinite(value.maximum)) errors.push(`${path}.maximum_invalid`); + if (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum) { + errors.push(`${path}.range_invalid`); + } + if (Array.isArray(value.enum) && value.enum.some((item) => ( + typeof item === "number" + && ((Number.isFinite(value.minimum) && item < value.minimum) + || (Number.isFinite(value.maximum) && item > value.maximum)) + ))) errors.push(`${path}.enum_value_out_of_range`); + } +} + +function validateExpressionAgainstFieldContract(expression, contract, derivations, path, errors) { + if (contract.required === true && expression.omitIfMissing === true) { + errors.push(`${path}_required_but_mapping_can_omit`); + } + if (contract.required === true && expression.omitIfInvalid === true) { + errors.push(`${path}_required_but_mapping_can_omit_invalid`); + } + if (expression.paths !== undefined) { + const expectedCoerce = new Map([["string", "string"], ["number", "number"], ["boolean", "boolean"]]).get(contract.type); + if (expectedCoerce && expression.coerce !== expectedCoerce) errors.push(`${path}.coerce_must_match_field_contract`); + if (Array.isArray(contract.enum)) errors.push(`${path}.enum_field_must_use_constant_or_derivation`); + if (contract.type === "number") { + if (!Object.is(expression.minimum, contract.minimum)) errors.push(`${path}.minimum_must_match_field_contract`); + if (!Object.is(expression.maximum, contract.maximum)) errors.push(`${path}.maximum_must_match_field_contract`); + if ((contract.minimum !== undefined || contract.maximum !== undefined) && expression.omitIfInvalid !== true) { + errors.push(`${path}.bounded_number_must_omit_invalid`); + } + } + } + if (expression.constant !== undefined) { + validateMappedValueAgainstFieldContract(expression.constant, contract, `${path}.constant`, errors); + } + if (typeof expression.derive === "string") { + const derivation = isPlainObject(derivations) ? derivations[expression.derive] : undefined; + if (!isPlainObject(derivation)) return; + validateMappedValueAgainstFieldContract(derivation.default, contract, `${path}.derivation.default`, errors); + if (Array.isArray(contract.enum) && Array.isArray(derivation.rules)) { + for (const [index, rule] of derivation.rules.entries()) { + const output = typeof rule === "string" ? rule.slice(rule.lastIndexOf(".") + 1) : undefined; + validateMappedValueAgainstFieldContract(output, contract, `${path}.derivation.rules[${index}]`, errors); + } + } + } +} + +function validateMappedValueAgainstFieldContract(value, contract, path, errors) { + if (!fieldContractValueMatchesType(value, contract.type)) errors.push(`${path}_type_mismatch`); + if (Array.isArray(contract.enum) && !contract.enum.some((allowed) => Object.is(allowed, value))) { + errors.push(`${path}_not_in_field_contract_enum`); + } + if (typeof value === "number") { + if (Number.isFinite(contract.minimum) && value < contract.minimum) errors.push(`${path}_below_field_contract_minimum`); + if (Number.isFinite(contract.maximum) && value > contract.maximum) errors.push(`${path}_above_field_contract_maximum`); + } +} + +function fieldContractValueMatchesType(value, type) { + if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); + if (type === "point") { + return isPlainObject(value) + && value.type === "Point" + && Array.isArray(value.coordinates) + && value.coordinates.length === 2 + && value.coordinates.every(Number.isFinite); + } + return typeof value === type && (type !== "number" || Number.isFinite(value)); +} + +function stableLiteral(value) { + return `${typeof value}:${JSON.stringify(value)}`; } function validateExpression(value, path, errors) { @@ -769,6 +909,18 @@ function validateExpression(value, path, errors) { if (value.fallback !== undefined && value.fallback !== "collection_received_at") errors.push(`${path}.fallback_invalid`); if (value.derive !== undefined) requiredIdentifier(value.derive, `${path}.derive`, errors); if (value.omitIfMissing !== undefined && typeof value.omitIfMissing !== "boolean") errors.push(`${path}.omitIfMissing_must_be_boolean`); + if (value.omitIfInvalid !== undefined && typeof value.omitIfInvalid !== "boolean") errors.push(`${path}.omitIfInvalid_must_be_boolean`); + if (value.minimum !== undefined || value.maximum !== undefined) { + if (value.coerce !== "number") errors.push(`${path}.range_requires_number_coercion`); + if (value.minimum !== undefined && !Number.isFinite(value.minimum)) errors.push(`${path}.minimum_invalid`); + if (value.maximum !== undefined && !Number.isFinite(value.maximum)) errors.push(`${path}.maximum_invalid`); + if (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum) { + errors.push(`${path}.range_invalid`); + } + if (value.omitIfInvalid !== true) errors.push(`${path}.range_requires_omitIfInvalid`); + } else if (value.omitIfInvalid !== undefined) { + errors.push(`${path}.omitIfInvalid_requires_range`); + } } function validateDerivation(value, path, errors) { diff --git a/packages/external-provider-contract/test/provider-package.test.mjs b/packages/external-provider-contract/test/provider-package.test.mjs index bd76ea1..277280c 100644 --- a/packages/external-provider-contract/test/provider-package.test.mjs +++ b/packages/external-provider-contract/test/provider-package.test.mjs @@ -15,6 +15,9 @@ import { geliosUnitsCurrentFixtureV1, } from "../providers/gelios/v1/index.mjs"; import { geliosProviderPackageV2 } from "../providers/gelios/v2/index.mjs"; +import { geliosProviderPackageV3 } from "../providers/gelios/v3/index.mjs"; +import { geliosProviderPackageV4 } from "../providers/gelios/v4/index.mjs"; +import { geliosProviderPackageV5 } from "../providers/gelios/v5/index.mjs"; import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs"; const expectedFields = [ @@ -35,6 +38,197 @@ const expectedFields = [ assert.deepEqual(validateProviderPackage(geliosProviderPackageV1), { ok: true, errors: [] }); assert.deepEqual(validateProviderPackage(geliosProviderPackageV2), { ok: true, errors: [] }); +assert.deepEqual(validateProviderPackage(geliosProviderPackageV3), { ok: true, errors: [] }); +assert.deepEqual(validateProviderPackage(geliosProviderPackageV4), { ok: true, errors: [] }); +assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] }); + +const strictProduct = geliosProviderPackageV5.dataProducts[0]; +const registeredStrictProduct = JSON.parse(await readFile(new URL( + "../../../services/external-data-plane/definitions/fleet.positions.current.v4.json", + import.meta.url, +), "utf8")); +const geliosOntologyEntities = JSON.parse(await readFile(new URL( + "../../../services/ontology-core/catalog/domain-packages/gelios/entities.json", + import.meta.url, +), "utf8")); +assert.equal(geliosProviderPackageV5.id, "gelios.provider.v5"); +assert.equal(strictProduct.id, "fleet.positions.current.v4"); +assert.deepEqual(strictProduct, registeredStrictProduct); +assert.deepEqual(strictProduct.fieldContracts.signal_state.enum, ["active", "inactive"]); +assert.deepEqual(strictProduct.fieldContracts.movement_state.enum, ["moving", "stopped"]); +for (const ontologyEntityId of ["gelios.signal_state", "gelios.movement_state"]) { + const valueContract = geliosOntologyEntities.entities.find((entity) => entity.id === ontologyEntityId)?.valueContract; + assert.ok(valueContract, `${ontologyEntityId} must expose an Ontology valueContract`); + assert.deepEqual( + strictProduct.fieldContracts[valueContract.field].enum, + valueContract.values.map(({ value: state }) => state), + `${valueContract.field} Data Product enum must equal the Ontology value contract`, + ); +} + +const withInvalidClosedStateDefault = structuredClone(geliosProviderPackageV5); +withInvalidClosedStateDefault.mappingContracts[0].derivations.signal_state.default = "invented"; +assert.equal( + validateProviderPackage(withInvalidClosedStateDefault).errors.includes( + "mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.signal_state.derivation.default_not_in_field_contract_enum", + ), + true, +); + +const withInvalidClosedStateRule = structuredClone(geliosProviderPackageV5); +withInvalidClosedStateRule.mappingContracts[0].derivations.movement_state.rules[0] = "integer_speed_gt_threshold.teleporting"; +assert.equal( + validateProviderPackage(withInvalidClosedStateRule).errors.includes( + "mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.movement_state.derivation.rules[0]_not_in_field_contract_enum", + ), + true, +); + +const withOptionalRequiredState = structuredClone(geliosProviderPackageV5); +withOptionalRequiredState.mappingContracts[0].fact.attributes.signal_state.omitIfMissing = true; +assert.equal( + validateProviderPackage(withOptionalRequiredState).errors.includes( + "mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.signal_state_required_but_mapping_can_omit", + ), + true, +); + +const withMalformedFieldContract = structuredClone(geliosProviderPackageV5); +withMalformedFieldContract.dataProducts[0].fieldContracts.signal_state = null; +assert.doesNotThrow(() => validateProviderPackage(withMalformedFieldContract)); +assert.equal(validateProviderPackage(withMalformedFieldContract).ok, false); + +const withUnboundedNumericMapping = structuredClone(geliosProviderPackageV5); +delete withUnboundedNumericMapping.mappingContracts[0].fact.attributes.speed_kph.minimum; +assert.equal( + validateProviderPackage(withUnboundedNumericMapping).errors.includes( + "mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.speed_kph.minimum_must_match_field_contract", + ), + true, +); + +const withNonOmittingBoundedMapping = structuredClone(geliosProviderPackageV5); +withNonOmittingBoundedMapping.mappingContracts[0].fact.attributes.course_degrees.omitIfInvalid = false; +assert.equal( + validateProviderPackage(withNonOmittingBoundedMapping).errors.includes( + "mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.course_degrees.bounded_number_must_omit_invalid", + ), + true, +); + +const canonicalMonitoringProduct = geliosProviderPackageV4.dataProducts[0]; +const canonicalMonitoringMapping = geliosProviderPackageV4.mappingContracts[0]; +const registeredCanonicalMonitoringProduct = JSON.parse(await readFile(new URL( + "../../../services/external-data-plane/definitions/fleet.positions.current.v3.json", + import.meta.url, +), "utf8")); +assert.equal(geliosProviderPackageV4.id, "gelios.provider.v4"); +assert.equal(geliosProviderPackageV4.version, "4.0.0"); +assert.deepEqual(geliosProviderPackageV4.manifest.ontology, { + packageId: "gelios", + revision: "ontology.gelios.v1_1", +}); +assert.deepEqual(geliosProviderPackageV4.collectionProfiles[0].capabilityIds, [ + "gelios.monitoring_config.current.read", + "gelios.units.current.read", +]); +assert.equal( + geliosProviderPackageV4.capabilities.find((item) => item.id === "gelios.monitoring_config.current.read").request.path, + "/api/v1/users/me/monitoring-config", +); +assert.equal( + geliosProviderPackageV4.capabilities.find((item) => item.id === "gelios.units.current.read").request.query.incltrip, + "true", +); +assert.deepEqual(canonicalMonitoringProduct, registeredCanonicalMonitoringProduct); +assert.deepEqual(canonicalMonitoringProduct.fields.filter((field) => field.endsWith("_state")), [ + "movement_state", + "signal_state", +]); +for (const forbiddenField of [ + "availability_state", + "freshness_state", + "operational_status", + "position_state", + "state_policy_version", +]) { + assert.equal(canonicalMonitoringProduct.fields.includes(forbiddenField), false); +} +assert.deepEqual(canonicalMonitoringMapping.derivations.signal_state.rules, [ + "last_message_missing.inactive", + "last_message_age_lt_resolved_monitoring_limit.active", + "otherwise.inactive", +]); +assert.equal(canonicalMonitoringMapping.derivations.signal_state.default, "inactive"); +assert.equal(canonicalMonitoringMapping.derivations.signal_state.parameters.activeDurationPath, "signalActiveDuration"); +assert.equal( + canonicalMonitoringMapping.derivations.signal_state.parameters.somewhatInactiveDurationPath, + "signalSomewhatInactiveDuration", +); +assert.equal(canonicalMonitoringMapping.derivations.signal_state.parameters.collectionProfileFallbackSeconds, 120); +assert.equal( + canonicalMonitoringMapping.derivations.signal_state.parameters.thresholdResolution, + "somewhatInactive_then_active_then_profileFallback", +); +assert.equal(canonicalMonitoringMapping.derivations.movement_state.parameters.movingThresholdKph, 2); +assert.equal(canonicalMonitoringMapping.derivations.movement_state.default, "stopped"); +assert.deepEqual( + geliosProviderPackageV4.l2Templates[0].steps.map((step) => step.kind), + [ + "collection_trigger", + "provider_request", + "provider_request", + "extract_items", + "semantic_mapping", + "data_product_publish", + ], +); + +const stateAwareProduct = geliosProviderPackageV3.dataProducts[0]; +const stateAwareMapping = geliosProviderPackageV3.mappingContracts[0]; +const registeredStateAwareProduct = JSON.parse(await readFile(new URL( + "../../../services/external-data-plane/definitions/fleet.positions.current.v2.json", + import.meta.url, +), "utf8")); +assert.equal(geliosProviderPackageV3.id, "gelios.provider.v3"); +assert.equal(geliosProviderPackageV3.version, "3.0.0"); +assert.equal(geliosProviderPackageV3.authModes[0].id, "gelios.rest-rotating-bearer.v3"); +assert.deepEqual(geliosProviderPackageV3.authModes[0].transport, { + placement: "header", + name: "Authorization", +}); +assert.deepEqual(geliosProviderPackageV3.authModes[0].tokenLifecycle, { + artifacts: ["access", "refresh"], + requestArtifact: "access", + refreshMode: "runtime_managed", +}); +assert.equal(geliosProviderPackageV3.capabilities[0].request.baseUrl, "https://api.geliospro.com"); +assert.equal(geliosProviderPackageV3.capabilities[0].request.path, "/api/v1/units"); +assert.deepEqual(geliosProviderPackageV3.capabilities[0].request.query, {}); +assert.equal( + geliosProviderPackageV3.l2Templates[0].credentialBindings.find((binding) => binding.role === "provider").authModeId, + "gelios.rest-rotating-bearer.v3", +); +assert.equal(stateAwareProduct.id, "fleet.positions.current.v2"); +assert.equal(stateAwareProduct.version, "2.0.0"); +assert.equal(stateAwareProduct.ontologyRevision, "ontology.map.moving_object.v2"); +assert.deepEqual(stateAwareProduct, registeredStateAwareProduct); +assert.deepEqual( + ["availability_state", "freshness_state", "motion_state", "position_state"].map((field) => ( + stateAwareMapping.fact.attributes[field] + )), + [ + { derive: "availability_state" }, + { derive: "freshness_state" }, + { derive: "motion_state" }, + { derive: "position_state" }, + ], +); +assert.equal(stateAwareMapping.fact.attributes.state_policy_version.constant, "map-moving-object-state/v1"); +assert.equal(stateAwareMapping.derivations.motion_state.parameters.movingThresholdKph, 1); +assert.equal(stateAwareMapping.derivations.position_state.parameters.minSatelliteCount, 4); +assert.equal(stateAwareMapping.derivations.position_state.parameters.maxHdop, 5); +assert.equal(stateAwareMapping.derivations.position_state.parameters.maxHorizontalAccuracyMeters, 100); const geliosSdkAuth = geliosProviderPackageV2.authModes[0]; const geliosSdkUnitsRead = geliosProviderPackageV2.capabilities[0]; @@ -318,7 +512,7 @@ for (const profile of withUnexecutedSecondCapability.collectionProfiles) { } assert.equal( validateProviderPackage(withUnexecutedSecondCapability).errors.includes( - "collectionProfile.gelios.positions.current.realtime.v1.capabilityIds_must_select_one_v1_read", + "collectionProfile.gelios.positions.current.realtime.v1.template_capability_chain_mismatch", ), true, ); diff --git a/services/external-data-plane/definitions/fleet.positions.current.v2.json b/services/external-data-plane/definitions/fleet.positions.current.v2.json new file mode 100644 index 0000000..936b4d6 --- /dev/null +++ b/services/external-data-plane/definitions/fleet.positions.current.v2.json @@ -0,0 +1,35 @@ +{ + "id": "fleet.positions.current.v2", + "version": "2.0.0", + "ontologyRevision": "ontology.map.moving_object.v2", + "deliveryMode": "snapshot+patch", + "semanticTypes": [ + "map.moving_object" + ], + "fields": [ + "availability_state", + "course_degrees", + "display_name", + "elevation_meters", + "freshness_state", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "motion_state", + "object_kind", + "operational_status", + "position_source", + "position_state", + "position_valid", + "quality_flags", + "satellite_count", + "speed_kph", + "state_policy_version" + ], + "history": { + "mode": "sampled", + "intervalMs": 60000, + "strategy": "latest-per-entity-per-bucket", + "retentionDays": 90 + } +} diff --git a/services/external-data-plane/definitions/fleet.positions.current.v3.json b/services/external-data-plane/definitions/fleet.positions.current.v3.json new file mode 100644 index 0000000..da4888c --- /dev/null +++ b/services/external-data-plane/definitions/fleet.positions.current.v3.json @@ -0,0 +1,29 @@ +{ + "id": "fleet.positions.current.v3", + "version": "3.0.0", + "ontologyRevision": "ontology.map.moving_object.v3", + "deliveryMode": "snapshot+patch", + "semanticTypes": [ + "map.moving_object" + ], + "fields": [ + "course_degrees", + "display_name", + "elevation_meters", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "movement_state", + "object_kind", + "position_source", + "satellite_count", + "signal_state", + "speed_kph" + ], + "history": { + "mode": "sampled", + "intervalMs": 60000, + "strategy": "latest-per-entity-per-bucket", + "retentionDays": 90 + } +} diff --git a/services/external-data-plane/definitions/fleet.positions.current.v4.json b/services/external-data-plane/definitions/fleet.positions.current.v4.json new file mode 100644 index 0000000..437c6c2 --- /dev/null +++ b/services/external-data-plane/definitions/fleet.positions.current.v4.json @@ -0,0 +1,93 @@ +{ + "id": "fleet.positions.current.v4", + "version": "4.0.0", + "ontologyRevision": "ontology.map.moving_object.v3", + "deliveryMode": "snapshot+patch", + "semanticTypes": [ + "map.moving_object" + ], + "fields": [ + "course_degrees", + "display_name", + "elevation_meters", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "movement_state", + "object_kind", + "position_source", + "satellite_count", + "signal_state", + "speed_kph" + ], + "fieldContracts": { + "course_degrees": { + "type": "number", + "required": false, + "minimum": 0, + "maximum": 360 + }, + "display_name": { + "type": "string", + "required": true + }, + "elevation_meters": { + "type": "number", + "required": false + }, + "geometry": { + "type": "point", + "required": false + }, + "hdop": { + "type": "number", + "required": false, + "minimum": 0 + }, + "horizontal_accuracy_meters": { + "type": "number", + "required": false, + "minimum": 0 + }, + "movement_state": { + "type": "string", + "required": true, + "enum": [ + "moving", + "stopped" + ] + }, + "object_kind": { + "type": "string", + "required": true + }, + "position_source": { + "type": "string", + "required": true + }, + "satellite_count": { + "type": "number", + "required": false, + "minimum": 0 + }, + "signal_state": { + "type": "string", + "required": true, + "enum": [ + "active", + "inactive" + ] + }, + "speed_kph": { + "type": "number", + "required": false, + "minimum": 0 + } + }, + "history": { + "mode": "sampled", + "intervalMs": 60000, + "strategy": "latest-per-entity-per-bucket", + "retentionDays": 90 + } +} diff --git a/services/external-data-plane/package.json b/services/external-data-plane/package.json index 415f018..803a3e5 100644 --- a/services/external-data-plane/package.json +++ b/services/external-data-plane/package.json @@ -7,7 +7,7 @@ "start": "node src/server.mjs", "dev": "node --watch src/server.mjs", "check": "node --check src/server.mjs && node --check src/schema.mjs && node --check src/config.mjs && node --check src/intake-policy.mjs && node --check src/writer-binding.mjs && node --check src/reader-binding.mjs && node --check src/reader-source-scope.mjs && node --check src/data-product-policy.mjs && node --check src/data-product-delivery.mjs && node --check src/definitions.mjs && node --check src/managed-provisioner-auth.mjs", - "test": "node test/config.test.mjs && node test/managed-provisioner-auth.test.mjs && node test/intake-policy.test.mjs && node test/writer-binding.test.mjs && node test/reader-binding.test.mjs && node test/reader-source-scope.test.mjs && node test/data-product-policy.test.mjs && node test/definitions.test.mjs", + "test": "node test/config.test.mjs && node test/managed-provisioner-auth.test.mjs && node test/intake-policy.test.mjs && node test/writer-binding.test.mjs && node test/reader-binding.test.mjs && node test/reader-source-scope.test.mjs && node test/data-product-policy.test.mjs && node test/data-product-delivery.test.mjs && node test/definitions.test.mjs", "test:integration": "node test/data-product-delivery.integration.test.mjs && node test/api.integration.test.mjs", "test:all": "npm test && npm run test:integration" }, diff --git a/services/external-data-plane/src/data-product-delivery.mjs b/services/external-data-plane/src/data-product-delivery.mjs index 0c71e5b..b478312 100644 --- a/services/external-data-plane/src/data-product-delivery.mjs +++ b/services/external-data-plane/src/data-product-delivery.mjs @@ -13,7 +13,7 @@ export async function loadDataProductDefinition(db, dataProductId, { activeOnly const result = await db.query( `select id, version, ontology_revision as "ontologyRevision", delivery_mode as "deliveryMode", semantic_types as "semanticTypes", - fields, history_policy as "historyPolicy", active, + fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active, created_at as "createdAt", updated_at as "updatedAt" from external_data_plane_products where id = $1 ${activeOnly ? "and active = true" : ""}`, @@ -25,8 +25,8 @@ export async function loadDataProductDefinition(db, dataProductId, { activeOnly export async function persistDataProductDefinition(db, definition) { const result = await db.query( `insert into external_data_plane_products ( - id, version, ontology_revision, delivery_mode, semantic_types, fields, history_policy - ) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb) + id, version, ontology_revision, delivery_mode, semantic_types, fields, field_contracts, history_policy + ) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb) on conflict (id) do update set active = true, updated_at = now() @@ -35,10 +35,11 @@ export async function persistDataProductDefinition(db, definition) { and external_data_plane_products.delivery_mode = excluded.delivery_mode and external_data_plane_products.semantic_types = excluded.semantic_types and external_data_plane_products.fields = excluded.fields + and external_data_plane_products.field_contracts = excluded.field_contracts and external_data_plane_products.history_policy = excluded.history_policy returning id, version, ontology_revision as "ontologyRevision", delivery_mode as "deliveryMode", semantic_types as "semanticTypes", - fields, history_policy as "historyPolicy", active, + fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active, created_at as "createdAt", updated_at as "updatedAt"`, [ definition.id, @@ -47,6 +48,7 @@ export async function persistDataProductDefinition(db, definition) { definition.deliveryMode, JSON.stringify(definition.semanticTypes), JSON.stringify(definition.fields), + JSON.stringify(definition.fieldContracts || {}), JSON.stringify(definition.history), ], ); @@ -658,6 +660,8 @@ function publishFingerprint(batch) { export function assertPublishMatchesDefinition(batch, definition) { const semanticTypes = new Set(Array.isArray(definition?.semanticTypes) ? definition.semanticTypes : []); const fields = new Set(Array.isArray(definition?.fields) ? definition.fields : []); + const fieldContracts = definition?.fieldContracts && typeof definition.fieldContracts === "object" + && !Array.isArray(definition.fieldContracts) ? definition.fieldContracts : {}; const entityKeys = new Set(); for (const fact of batch?.facts || []) { if (!semanticTypes.has(fact.semanticType)) throw deliveryError("data_product_semantic_type_forbidden", 422); @@ -672,9 +676,55 @@ export function assertPublishMatchesDefinition(batch, definition) { if (fact.geometry !== undefined && !geometryDeclared(fields)) { throw deliveryError("data_product_geometry_forbidden", 422); } + for (const [field, contract] of Object.entries(fieldContracts)) { + const resolved = resolveContractField(fact, field); + if (!resolved.present) { + if (contract.required === true) throw deliveryError("data_product_field_required", 422); + continue; + } + assertFieldContractValue(resolved.value, contract); + } } } +function resolveContractField(fact, field) { + if (field === "geometry") return { present: fact.geometry !== undefined, value: fact.geometry }; + if (field === "source_id") return { present: fact.sourceId !== undefined, value: fact.sourceId }; + if (field === "semantic_type") return { present: fact.semanticType !== undefined, value: fact.semanticType }; + if (field === "observed_at") return { present: fact.observedAt !== undefined, value: fact.observedAt }; + const attribute = field.startsWith("attributes.") ? field.slice("attributes.".length) : field; + const attributes = fact.attributes || {}; + return { present: Object.hasOwn(attributes, attribute), value: attributes[attribute] }; +} + +function assertFieldContractValue(value, contract) { + if (!fieldContractValueMatchesType(value, contract.type)) { + throw deliveryError("data_product_field_type_invalid", 422); + } + if (Array.isArray(contract.enum) && !contract.enum.some((allowed) => Object.is(allowed, value))) { + throw deliveryError("data_product_field_value_forbidden", 422); + } + if (typeof value === "number" && ( + (Number.isFinite(contract.minimum) && value < contract.minimum) + || (Number.isFinite(contract.maximum) && value > contract.maximum) + )) throw deliveryError("data_product_field_value_out_of_range", 422); +} + +function fieldContractValueMatchesType(value, type) { + if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); + if (type === "point") { + return value?.type === "Point" + && Array.isArray(value.coordinates) + && value.coordinates.length === 2 + && value.coordinates.every(Number.isFinite) + && value.coordinates[0] >= -180 + && value.coordinates[0] <= 180 + && value.coordinates[1] >= -90 + && value.coordinates[1] <= 90; + } + return typeof value === type && (type !== "number" || Number.isFinite(value)); +} + function geometryDeclared(fields) { return fields.has("geometry") || fields.has("coordinates") diff --git a/services/external-data-plane/src/data-product-policy.mjs b/services/external-data-plane/src/data-product-policy.mjs index 928c0e0..9c13a34 100644 --- a/services/external-data-plane/src/data-product-policy.mjs +++ b/services/external-data-plane/src/data-product-policy.mjs @@ -2,10 +2,12 @@ const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const HISTORY_MODES = new Set(["none", "all", "sampled"]); +const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point"]); const DEFINITION_KEYS = new Set([ - "id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "history", + "id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "fieldContracts", "history", ]); const HISTORY_KEYS = new Set(["mode", "intervalMs", "retentionDays", "strategy"]); +const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]); export function normalizeDataProductDefinition(value) { if (!isPlainObject(value) || !hasOnlyKeys(value, DEFINITION_KEYS)) { @@ -30,8 +32,58 @@ export function normalizeDataProductDefinition(value) { } if (!semanticTypes.length || !fields.length) throw policyError("data_product_definition_shape_invalid"); + const fieldContracts = normalizeFieldContracts(value.fieldContracts, fields); const history = normalizeHistoryPolicy(value.history); - return Object.freeze({ id, version, ontologyRevision, deliveryMode, semanticTypes, fields, history }); + return Object.freeze({ id, version, ontologyRevision, deliveryMode, semanticTypes, fields, fieldContracts, history }); +} + +export function normalizeFieldContracts(value, fields) { + if (value === undefined) return Object.freeze({}); + if (!isPlainObject(value)) throw policyError("data_product_field_contracts_invalid"); + const names = Object.keys(value); + if (names.length === 0) return Object.freeze({}); + if (names.length !== fields.length || fields.some((field) => !Object.hasOwn(value, field))) { + throw policyError("data_product_field_contracts_must_match_fields"); + } + + const normalized = {}; + for (const field of fields) normalized[field] = normalizeFieldContract(value[field]); + return Object.freeze(normalized); +} + +function normalizeFieldContract(value) { + if (!isPlainObject(value) || !hasOnlyKeys(value, FIELD_CONTRACT_KEYS)) { + throw policyError("data_product_field_contract_invalid"); + } + const type = string(value.type); + if (!FIELD_CONTRACT_TYPES.has(type) || typeof value.required !== "boolean") { + throw policyError("data_product_field_contract_shape_invalid"); + } + const contract = { type, required: value.required }; + if (value.enum !== undefined) { + if (!Array.isArray(value.enum) || value.enum.length === 0 + || new Set(value.enum.map(stableLiteral)).size !== value.enum.length + || new Set(["point", "string_array"]).has(type) + || value.enum.some((item) => !fieldContractValueMatchesType(item, type))) { + throw policyError("data_product_field_contract_enum_invalid"); + } + contract.enum = Object.freeze([...value.enum].sort((left, right) => stableLiteral(left).localeCompare(stableLiteral(right)))); + } + if (value.minimum !== undefined || value.maximum !== undefined) { + if (type !== "number" + || (value.minimum !== undefined && !Number.isFinite(value.minimum)) + || (value.maximum !== undefined && !Number.isFinite(value.maximum)) + || (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum)) { + throw policyError("data_product_field_contract_range_invalid"); + } + if (value.minimum !== undefined) contract.minimum = value.minimum; + if (value.maximum !== undefined) contract.maximum = value.maximum; + if (contract.enum?.some((item) => ( + (contract.minimum !== undefined && item < contract.minimum) + || (contract.maximum !== undefined && item > contract.maximum) + ))) throw policyError("data_product_field_contract_enum_out_of_range"); + } + return Object.freeze(contract); } export function normalizeHistoryPolicy(value = { mode: "none" }) { @@ -62,6 +114,7 @@ export function safeDataProductDefinition(row) { deliveryMode: row.deliveryMode, semanticTypes: array(row.semanticTypes), fields: array(row.fields), + fieldContracts: isPlainObject(row.fieldContracts) ? row.fieldContracts : {}, history: isPlainObject(row.historyPolicy) ? row.historyPolicy : {}, active: row.active === true, createdAt: iso(row.createdAt), @@ -116,3 +169,11 @@ function isPlainObject(value) { function hasOnlyKeys(value, allowed) { return Object.keys(value).every((key) => allowed.has(key)); } + +function fieldContractValueMatchesType(value, type) { + return typeof value === type && (type !== "number" || Number.isFinite(value)); +} + +function stableLiteral(value) { + return `${typeof value}:${JSON.stringify(value)}`; +} diff --git a/services/external-data-plane/src/schema.mjs b/services/external-data-plane/src/schema.mjs index 7fff0ac..ffb3d66 100644 --- a/services/external-data-plane/src/schema.mjs +++ b/services/external-data-plane/src/schema.mjs @@ -12,14 +12,33 @@ export async function migrate(pool) { delivery_mode text not null, semantic_types jsonb not null, fields jsonb not null, + field_contracts jsonb not null default '{}'::jsonb, history_policy jsonb not null, active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), check (jsonb_typeof(semantic_types) = 'array' and jsonb_array_length(semantic_types) > 0), - check (jsonb_typeof(fields) = 'array' and jsonb_array_length(fields) > 0) + check (jsonb_typeof(fields) = 'array' and jsonb_array_length(fields) > 0), + constraint external_data_plane_products_field_contracts_object_ck + check (jsonb_typeof(field_contracts) = 'object') ) `); + await pool.query("alter table external_data_plane_products add column if not exists field_contracts jsonb not null default '{}'::jsonb"); + await pool.query(` + do $$ + begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'external_data_plane_products'::regclass + and conname = 'external_data_plane_products_field_contracts_object_ck' + ) then + alter table external_data_plane_products + add constraint external_data_plane_products_field_contracts_object_ck + check (jsonb_typeof(field_contracts) = 'object'); + end if; + end + $$ + `); await pool.query(` create table if not exists external_data_plane_batches ( diff --git a/services/external-data-plane/src/server.mjs b/services/external-data-plane/src/server.mjs index e1332f0..2754156 100644 --- a/services/external-data-plane/src/server.mjs +++ b/services/external-data-plane/src/server.mjs @@ -1179,7 +1179,7 @@ async function listGrantedProducts(binding) { const result = await pool.query( `select id, version, ontology_revision as "ontologyRevision", delivery_mode as "deliveryMode", semantic_types as "semanticTypes", - fields, history_policy as "historyPolicy", active, + fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active, created_at as "createdAt", updated_at as "updatedAt" from external_data_plane_products where active = true and id = any($1::text[]) diff --git a/services/external-data-plane/test/data-product-delivery.integration.test.mjs b/services/external-data-plane/test/data-product-delivery.integration.test.mjs index f174754..a131f76 100644 --- a/services/external-data-plane/test/data-product-delivery.integration.test.mjs +++ b/services/external-data-plane/test/data-product-delivery.integration.test.mjs @@ -38,6 +38,12 @@ try { deliveryMode: "snapshot+patch", semanticTypes: ["map.moving_object"], fields: ["source_id", "observed_at", "geometry", "status"], + fieldContracts: { + source_id: { type: "string", required: true }, + observed_at: { type: "string", required: true }, + geometry: { type: "point", required: true }, + status: { type: "string", required: true }, + }, history: { mode: "sampled", intervalMs: 60_000, @@ -47,6 +53,7 @@ try { }); await persistDataProductDefinition(pool, definition); const storedDefinition = await loadDataProductDefinition(pool, definition.id); + assert.deepEqual(storedDefinition.fieldContracts, definition.fieldContracts); const binding = { id: "reader-binding-test", bindingKey: "managed-reader-binding-test", diff --git a/services/external-data-plane/test/data-product-delivery.test.mjs b/services/external-data-plane/test/data-product-delivery.test.mjs new file mode 100644 index 0000000..2c8b47e --- /dev/null +++ b/services/external-data-plane/test/data-product-delivery.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; + +import { assertPublishMatchesDefinition } from "../src/data-product-delivery.mjs"; +import { normalizeDataProductDefinition } from "../src/data-product-policy.mjs"; + +const definition = normalizeDataProductDefinition({ + id: "test.positions.current.v2", + version: "2.0.0", + ontologyRevision: "ontology.test.positions.v1", + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: ["display_name", "geometry", "movement_state", "signal_state", "speed_kph"], + fieldContracts: { + display_name: { type: "string", required: true }, + geometry: { type: "point", required: false }, + movement_state: { type: "string", required: true, enum: ["moving", "stopped"] }, + signal_state: { type: "string", required: true, enum: ["active", "inactive"] }, + speed_kph: { type: "number", required: false, minimum: 0 }, + }, + history: { mode: "none", retentionDays: 1 }, +}); + +const fact = { + sourceId: "unit-1", + semanticType: "map.moving_object", + observedAt: "2026-07-20T10:00:00.000Z", + geometry: { type: "Point", coordinates: [37.6173, 55.7558] }, + attributes: { + display_name: "Unit 1", + movement_state: "moving", + signal_state: "active", + speed_kph: 12, + }, +}; +const batch = { facts: [fact] }; +assert.doesNotThrow(() => assertPublishMatchesDefinition(batch, definition)); + +assert.throws( + () => assertPublishMatchesDefinition({ + facts: [{ ...fact, attributes: { ...fact.attributes, movement_state: "teleporting" } }], + }, definition), + (error) => error?.status === 422 && error?.code === "data_product_field_value_forbidden", +); +assert.throws( + () => assertPublishMatchesDefinition({ + facts: [{ ...fact, attributes: { ...fact.attributes, signal_state: "invented" } }], + }, definition), + (error) => error?.status === 422 && error?.code === "data_product_field_value_forbidden", +); +assert.throws( + () => assertPublishMatchesDefinition({ + facts: [{ + ...fact, + attributes: Object.fromEntries(Object.entries(fact.attributes).filter(([field]) => field !== "signal_state")), + }], + }, definition), + (error) => error?.status === 422 && error?.code === "data_product_field_required", +); +assert.throws( + () => assertPublishMatchesDefinition({ + facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: "12" } }], + }, definition), + (error) => error?.status === 422 && error?.code === "data_product_field_type_invalid", +); +assert.throws( + () => assertPublishMatchesDefinition({ + facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: -1 } }], + }, definition), + (error) => error?.status === 422 && error?.code === "data_product_field_value_out_of_range", +); +assert.throws( + () => assertPublishMatchesDefinition({ + facts: [{ ...fact, geometry: { type: "Point", coordinates: [181, 55.7558] } }], + }, definition), + (error) => error?.status === 422 && error?.code === "data_product_field_type_invalid", +); + +const legacyDefinition = normalizeDataProductDefinition({ + id: "test.positions.current.v1", + version: "1.0.0", + ontologyRevision: "ontology.test.positions.v1", + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: ["movement_state", "signal_state"], + history: { mode: "none", retentionDays: 1 }, +}); +assert.doesNotThrow(() => assertPublishMatchesDefinition({ + facts: [{ + sourceId: "unit-legacy", + semanticType: "map.moving_object", + attributes: { movement_state: "teleporting", signal_state: "invented" }, + }], +}, legacyDefinition)); + +console.log("external-data-plane data product delivery policy: ok"); diff --git a/services/external-data-plane/test/data-product-policy.test.mjs b/services/external-data-plane/test/data-product-policy.test.mjs index 5eb8a4f..a21b8bf 100644 --- a/services/external-data-plane/test/data-product-policy.test.mjs +++ b/services/external-data-plane/test/data-product-policy.test.mjs @@ -24,6 +24,8 @@ assert.deepEqual(input.semanticTypes, ["vehicle.trike", "map.moving_object"]); assert.deepEqual(input.fields, ["status", "source_id", "observed_at", "geometry"]); assert.equal(Object.isFrozen(definition.semanticTypes), true); assert.equal(Object.isFrozen(definition.fields), true); +assert.deepEqual(definition.fieldContracts, {}); +assert.equal(Object.isFrozen(definition.fieldContracts), true); const reordered = normalizeDataProductDefinition({ ...input, @@ -78,4 +80,49 @@ assert.throws( /data_product_definition_fields_duplicate/, ); +const strict = normalizeDataProductDefinition({ + ...input, + fields: ["geometry", "movement_state", "signal_state", "speed_kph"], + fieldContracts: { + geometry: { type: "point", required: false }, + movement_state: { type: "string", required: true, enum: ["stopped", "moving"] }, + signal_state: { type: "string", required: true, enum: ["inactive", "active"] }, + speed_kph: { type: "number", required: false, minimum: 0 }, + }, +}); +assert.deepEqual(strict.fieldContracts.movement_state.enum, ["moving", "stopped"]); +assert.equal(Object.isFrozen(strict.fieldContracts), true); +assert.equal(Object.isFrozen(strict.fieldContracts.signal_state), true); +assert.equal(Object.isFrozen(strict.fieldContracts.signal_state.enum), true); + +assert.throws( + () => normalizeDataProductDefinition({ + ...input, + fieldContracts: { status: { type: "string", required: true } }, + }), + /data_product_field_contracts_must_match_fields/, +); +assert.throws( + () => normalizeDataProductDefinition({ + ...input, + fieldContracts: Object.fromEntries(input.fields.map((field) => [ + field, + field === "status" + ? { type: "string", required: true, enum: ["active", 1] } + : { type: field === "geometry" ? "point" : "string", required: false }, + ])), + }), + /data_product_field_contract_enum_invalid/, +); +assert.throws( + () => normalizeDataProductDefinition({ + ...strict, + fieldContracts: { + ...strict.fieldContracts, + speed_kph: { type: "number", required: false, minimum: 10, maximum: 1 }, + }, + }), + /data_product_field_contract_range_invalid/, +); + console.log("external-data-plane data product policy: ok"); diff --git a/services/external-data-plane/test/definitions.test.mjs b/services/external-data-plane/test/definitions.test.mjs index 33e8a4f..e0b0874 100644 --- a/services/external-data-plane/test/definitions.test.mjs +++ b/services/external-data-plane/test/definitions.test.mjs @@ -6,7 +6,12 @@ import { join } from "node:path"; import { loadDataProductDefinitions } from "../src/definitions.mjs"; const bundled = await loadDataProductDefinitions(); -assert.deepEqual(bundled.map((definition) => definition.id), ["fleet.positions.current.v1"]); +assert.deepEqual(bundled.map((definition) => definition.id), [ + "fleet.positions.current.v1", + "fleet.positions.current.v2", + "fleet.positions.current.v3", + "fleet.positions.current.v4", +]); assert.deepEqual(bundled[0].semanticTypes, ["map.moving_object"]); assert.equal(bundled[0].ontologyRevision, "ontology.map.moving_object.v1"); assert.equal(bundled[0].history.mode, "sampled"); @@ -15,6 +20,32 @@ assert.equal(bundled[0].history.retentionDays, 90); assert.equal(bundled[0].fields.includes("geometry"), true); assert.equal(bundled[0].fields.includes("providerUnitId"), false); assert.equal(bundled[0].fields.includes("provider_unit_id"), false); +assert.equal(bundled[1].version, "2.0.0"); +assert.equal(bundled[1].ontologyRevision, "ontology.map.moving_object.v2"); +assert.deepEqual( + bundled[1].fields.filter((field) => field.endsWith("_state")), + ["availability_state", "freshness_state", "motion_state", "position_state"], +); +assert.equal(bundled[2].version, "3.0.0"); +assert.equal(bundled[2].ontologyRevision, "ontology.map.moving_object.v3"); +assert.deepEqual( + bundled[2].fields.filter((field) => field.endsWith("_state")), + ["movement_state", "signal_state"], +); +assert.equal(bundled[2].fields.includes("operational_status"), false); +assert.equal(bundled[3].version, "4.0.0"); +assert.equal(bundled[3].ontologyRevision, "ontology.map.moving_object.v3"); +assert.deepEqual(bundled[3].fieldContracts.signal_state, { + type: "string", + required: true, + enum: ["active", "inactive"], +}); +assert.deepEqual(bundled[3].fieldContracts.movement_state, { + type: "string", + required: true, + enum: ["moving", "stopped"], +}); +assert.equal(Object.keys(bundled[3].fieldContracts).length, bundled[3].fields.length); const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-")); try { diff --git a/services/ontology-core/catalog/domain-packages/gelios/aliases.json b/services/ontology-core/catalog/domain-packages/gelios/aliases.json index 2bc4b85..13afbf3 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/aliases.json +++ b/services/ontology-core/catalog/domain-packages/gelios/aliases.json @@ -1,6 +1,6 @@ { - "version": "1.0.0", - "updatedAt": "2026-07-16", + "version": "1.1.0", + "updatedAt": "2026-07-20", "aliases": [ { "alias": "gelios", "canonicalId": "gelios.integration" }, { "alias": "gelius", "canonicalId": "gelios.integration" }, @@ -13,7 +13,12 @@ { "alias": "последняя телеметрия гелиос", "canonicalId": "gelios.telemetry_snapshot" }, { "alias": "сырая телеметрия гелиос", "canonicalId": "gelios.raw_telemetry_message" }, { "alias": "позиция объекта гелиос", "canonicalId": "gelios.position_fix" }, - { "alias": "статус объекта гелиос", "canonicalId": "gelios.operational_status" }, + { "alias": "статус связи гелиос", "canonicalId": "gelios.signal_state" }, + { "alias": "на связи гелиос", "canonicalId": "gelios.signal_state" }, + { "alias": "не на связи гелиос", "canonicalId": "gelios.signal_state" }, + { "alias": "статус движения гелиос", "canonicalId": "gelios.movement_state" }, + { "alias": "в движении гелиос", "canonicalId": "gelios.movement_state" }, + { "alias": "неподвижные гелиос", "canonicalId": "gelios.movement_state" }, { "alias": "датчик гелиос", "canonicalId": "gelios.sensor_definition" }, { "alias": "показание датчика", "canonicalId": "gelios.sensor_reading" }, { "alias": "калибровка датчика", "canonicalId": "gelios.sensor_conversion" }, diff --git a/services/ontology-core/catalog/domain-packages/gelios/entities.json b/services/ontology-core/catalog/domain-packages/gelios/entities.json index 7fe4ae4..e953810 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/entities.json +++ b/services/ontology-core/catalog/domain-packages/gelios/entities.json @@ -1,6 +1,6 @@ { - "version": "1.0.0", - "updatedAt": "2026-07-16", + "version": "1.1.0", + "updatedAt": "2026-07-20", "entities": [ { "id": "gelios.integration", "name": "Gelios Integration", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "NDC L2 connection", "summary": "One tenant-scoped Gelios provider connection backed by one opaque NDC L2 credential reference. It owns no UI, renderer, secret value or provider-specific Platform service." }, { "id": "gelios.access_scope", "name": "Gelios Access Scope", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "Gelios credential visibility + NDC L2 connection policy", "summary": "Dynamic collection boundary: selected safe-read capabilities include every entity visible to the bound credential on each run. Entity allowlists and provider-group business filters are not part of collection scope." }, @@ -11,7 +11,9 @@ { "id": "gelios.raw_telemetry_message", "name": "Raw Telemetry Message", "surface": "telemetry", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Provider hardware message preserved only behind restricted raw-data policy; it is not the default Studio or analytics contract." }, { "id": "gelios.position_fix", "name": "Position Fix", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Time-qualified geographic position with latitude, longitude, height, course, speed, satellite and quality attributes for one unit." }, { "id": "gelios.telemetry_parameter", "name": "Telemetry Parameter", "surface": "telemetry", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Name/value parameter received with a message. Parameters are dynamic and require a namespaced registry and whitelist before product exposure." }, - { "id": "gelios.operational_status", "name": "Operational Status", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Derived unit state such as active, stale, moving, parked, no-position or low-GPS; distinct from raw provider message fields." }, + { "id": "gelios.signal_state", "name": "Gelios Signal State", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "Gelios monitoring client + monitoring-config", "summary": "Official Gelios unit connection filter. Field signal_state has exactly active (На связи / Active units) and inactive (Не на связи / Inactive units). The client resolves its active window from signalActiveDuration and signalSomewhatInactiveDuration; a missing message is inactive. A connection profile may provide one explicit positive fallback when both account values are empty, otherwise publication fails closed.", "valueContract": { "field": "signal_state", "values": [{ "value": "active", "label": "На связи", "sourceLabel": "Active units" }, { "value": "inactive", "label": "Не на связи", "sourceLabel": "Inactive units" }], "sourcePaths": ["lastMsg.time", "monitoringConfig.signalActiveDuration", "monitoringConfig.signalSomewhatInactiveDuration"], "rules": ["missing_last_message.inactive", "last_message_age_lt_resolved_monitoring_limit.active", "otherwise.inactive"] } }, + { "id": "gelios.movement_state", "name": "Gelios Movement State", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "Gelios monitoring client", "summary": "Official Gelios unit movement filter. Field movement_state has exactly moving (В движении / Moving units) and stopped (Неподвижные / Stopped units). The monitoring client classifies integer last-message speed greater than 2 as moving; a missing message or speed not greater than 2 is stopped.", "valueContract": { "field": "movement_state", "values": [{ "value": "moving", "label": "В движении", "sourceLabel": "Moving units" }, { "value": "stopped", "label": "Неподвижные", "sourceLabel": "Stopped units" }], "sourcePaths": ["lastMsg.speed"], "rules": ["integer_speed_gt_2.moving", "otherwise.stopped"] } }, + { "id": "gelios.operational_status", "name": "Legacy NDC Operational Status", "surface": "telemetry", "status": ["tech-debt-noncanonical"], "authority": "Legacy NDC mapping only", "summary": "Noncanonical legacy aggregate retained only to identify and retire old v1/v2 contracts. It is not a Gelios status, must not enter new Data Products and must not drive Foundry filters, counters, colours or classes." }, { "id": "gelios.sensor_definition", "name": "Sensor Definition", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Declared sensor on a unit with message parameter, type, unit of measure, visibility and optional fuel semantics." }, { "id": "gelios.sensor_reading", "name": "Sensor Reading", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Time-qualified normalized reading of a defined sensor, retaining both numeric value and human-readable representation only when permitted by a versioned field policy." }, { "id": "gelios.sensor_conversion", "name": "Sensor Conversion", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Calibration/conversion rule or row for translating raw sensor values into operational measures." }, diff --git a/services/ontology-core/catalog/domain-packages/gelios/evidence.json b/services/ontology-core/catalog/domain-packages/gelios/evidence.json index 36a91d6..d47aae6 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/evidence.json +++ b/services/ontology-core/catalog/domain-packages/gelios/evidence.json @@ -1,11 +1,21 @@ { - "version": "1.0.0", - "updatedAt": "2026-07-16", + "version": "1.1.0", + "updatedAt": "2026-07-20", "sourceRoots": [ { "surface": "gelios-rest", - "path": "https://api.geliospro.com/docs", - "mode": "OpenAPI inspection and safe read-only runtime audit; no write routes executed" + "path": "https://api.geliospro.com/openapi.json", + "mode": "Official OpenAPI inspection for GET /api/v1/units and GET /api/v1/users/me/monitoring-config; no write routes executed" + }, + { + "surface": "gelios-monitoring-client", + "path": "https://geliospro.com/js/app.js", + "mode": "Official public monitoring-client inspection for active/inactive and moving/stopped derivation rules" + }, + { + "surface": "gelios-monitoring-localization", + "path": "https://geliospro.com/tmp/ru-RU.js", + "mode": "Official public Russian labels for Active units, Inactive units, Moving units and Stopped units" }, { "surface": "ndc-l2-legacy-evidence", @@ -28,6 +38,8 @@ "gelios.unit", "gelios.telemetry_snapshot", "gelios.position_fix", + "gelios.signal_state", + "gelios.movement_state", "gelios.sensor_definition", "gelios.sensor_reading", "gelios.geozone", @@ -42,6 +54,7 @@ "restrictions": [ "Do not copy access/refresh tokens, passwords, hardware decrypt keys or runtime snapshots into Ontology Core.", "Do not make a current NDC L2 workflow node, renderer entities or provider group names canonical domain identities.", + "Do not promote operational_status, freshness, GPS quality, missing position or unknown fallback buckets to Gelios monitoring states. The official filter value contracts are signal_state active/inactive and movement_state moving/stopped only.", "Do not invoke command send, create, update, delete or purge routes as ontology evidence.", "Robot2B pilot counts (107 credential-visible units and 95 legacy snapshot units) are historical evidence only; canonical collection scope is dynamically all entities visible to the bound credential.", "Do not bulk-download history, media or full geozone geometry before collection policy and storage architecture are approved." diff --git a/services/ontology-core/catalog/domain-packages/gelios/guardrails.json b/services/ontology-core/catalog/domain-packages/gelios/guardrails.json index efea77b..0b5b329 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/guardrails.json +++ b/services/ontology-core/catalog/domain-packages/gelios/guardrails.json @@ -1,6 +1,6 @@ { - "version": "1.0.0", - "updatedAt": "2026-07-16", + "version": "1.1.0", + "updatedAt": "2026-07-20", "rules": [ { "id": "guardrail.gelios.ontology_not_runtime_store", @@ -32,6 +32,12 @@ "summary": "Cesium pins and labels must consume stable gelios.unit and normalized gelios.position_fix through map.moving_object. They must not use raw message IDs, transient Cesium entity IDs or provider credentials as map identity.", "entityIds": ["gelios.unit", "gelios.position_fix", "gelios.telemetry_snapshot", "map.moving_object", "map.pin", "map.label"] }, + { + "id": "guardrail.gelios.monitoring_states_are_closed", + "severity": "error", + "summary": "Gelios monitoring state is a closed source-evidenced contract: signal_state is active or inactive; movement_state is moving or stopped. NDC L2, Data Products and Foundry must not add unknown, stale, freshness, GPS, position-quality, parked, no-position or aggregate operational-status values.", + "entityIds": ["gelios.telemetry_snapshot", "gelios.signal_state", "gelios.movement_state", "gelios.operational_status", "map.state_facet", "map.style_profile"] + }, { "id": "guardrail.gelios.raw_and_pii_are_restricted", "severity": "warning", @@ -56,6 +62,9 @@ ["gelios.unit", "gelios.tracker_device"], ["gelios.telemetry_snapshot", "gelios.raw_telemetry_message"], ["gelios.position_fix", "map.moving_object"], + ["gelios.signal_state", "gelios.movement_state"], + ["gelios.signal_state", "gelios.operational_status"], + ["gelios.movement_state", "gelios.operational_status"], ["gelios.command_template", "gelios.command_dispatch"], ["gelios.command_dispatch", "gelios.command_delivery"], ["gelios.geozone", "map.zone"] diff --git a/services/ontology-core/catalog/domain-packages/gelios/package.json b/services/ontology-core/catalog/domain-packages/gelios/package.json index bb90411..bde57a5 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/package.json +++ b/services/ontology-core/catalog/domain-packages/gelios/package.json @@ -1,7 +1,7 @@ { "id": "gelios", - "version": "1.0.0", - "updatedAt": "2026-07-16", + "version": "1.1.0", + "updatedAt": "2026-07-20", "status": "product-required/source-evidenced", - "summary": "Provider-neutral Gelios Pro telemetry, fleet, spatial, reporting and command-domain ontology. It describes canonical meanings and contracts; it does not store credentials, account scope or runtime telemetry." + "summary": "Source-evidenced Gelios Pro telemetry, fleet, spatial, reporting and command-domain ontology. Unit monitoring exposes only the official signal and movement value contracts; consumers may not invent additional operational states." } diff --git a/services/ontology-core/catalog/domain-packages/gelios/relations.json b/services/ontology-core/catalog/domain-packages/gelios/relations.json index c952198..8584d44 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/relations.json +++ b/services/ontology-core/catalog/domain-packages/gelios/relations.json @@ -1,6 +1,6 @@ { - "version": "1.0.0", - "updatedAt": "2026-07-16", + "version": "1.1.0", + "updatedAt": "2026-07-20", "relations": [ { "id": "gelios.integration.is_provider_connection", "from": ["gelios.integration"], "to": ["integration.connection"], "status": "product-required", "summary": "A Gelios integration is a tenant-scoped instance of the common external provider connection contract." }, { "id": "gelios.access_scope.specializes_integration_scope", "from": ["gelios.access_scope"], "to": ["integration.access_scope"], "status": "product-required", "summary": "Gelios credential-visible capability scope specializes the common provider access-scope contract." }, @@ -17,7 +17,10 @@ { "id": "gelios.unit.has_telemetry_snapshot", "from": ["gelios.unit"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "The versioned NDC L2 mapping produces normalized current telemetry for every valid returned unit." }, { "id": "gelios.raw_message.normalizes_to_snapshot", "from": ["gelios.raw_telemetry_message"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "Restricted raw provider telemetry can be normalized into the stable snapshot contract." }, { "id": "gelios.telemetry_snapshot.has_position_fix", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.position_fix"], "status": "product-required", "summary": "A current telemetry snapshot can carry one time-qualified position fix." }, - { "id": "gelios.telemetry_snapshot.has_operational_status", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.operational_status"], "status": "product-required", "summary": "The semantic mapping derives normalized operational state from telemetry and data-quality policy." }, + { "id": "gelios.telemetry_snapshot.has_signal_state", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.signal_state"], "status": "product-required", "summary": "The current snapshot carries the exact Gelios active/inactive monitoring classification derived from official message-age settings." }, + { "id": "gelios.telemetry_snapshot.has_movement_state", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.movement_state"], "status": "product-required", "summary": "The current snapshot carries the exact Gelios moving/stopped monitoring classification derived from official last-message speed logic." }, + { "id": "gelios.signal_state.is_map_state_facet", "from": ["gelios.signal_state"], "to": ["map.state_facet"], "status": "product-required", "summary": "Foundry may expose the signal_state value contract as a generic map facet without changing its values or labels." }, + { "id": "gelios.movement_state.is_map_state_facet", "from": ["gelios.movement_state"], "to": ["map.state_facet"], "status": "product-required", "summary": "Foundry may expose the movement_state value contract as a generic map facet without changing its values or labels." }, { "id": "gelios.telemetry_snapshot.has_parameter", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.telemetry_parameter"], "status": "source-evidenced", "summary": "A provider snapshot can contain dynamic telemetry parameters." }, { "id": "gelios.unit.has_sensor", "from": ["gelios.unit"], "to": ["gelios.sensor_definition"], "status": "source-evidenced", "summary": "A unit exposes zero or more sensor definitions." }, { "id": "gelios.sensor_definition.has_conversion", "from": ["gelios.sensor_definition"], "to": ["gelios.sensor_conversion"], "status": "source-evidenced", "summary": "A sensor can define conversion/calibration rows." }, diff --git a/services/ontology-core/catalog/domain-packages/map/entities.json b/services/ontology-core/catalog/domain-packages/map/entities.json index ddfc8b2..df9bc3b 100644 --- a/services/ontology-core/catalog/domain-packages/map/entities.json +++ b/services/ontology-core/catalog/domain-packages/map/entities.json @@ -1,6 +1,6 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-12", + "version": "0.2.0", + "updatedAt": "2026-07-19", "entities": [ { "id": "map.view", "name": "Map View", "surface": "map", "status": ["product-required"], "authority": "NDC Module Studio + Ontology Core", "summary": "Provider-neutral map work view composed by an approved Map Page template." }, { "id": "map.viewport", "name": "Map Viewport", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Camera/view state including position, orientation, zoom or height, focus and saved view presets." }, @@ -9,6 +9,7 @@ { "id": "map.grid_layer", "name": "Map Grid Layer", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Planetary or situational grid with configurable modes, levels of detail, step, radius and visual primitives." }, { "id": "map.place_target", "name": "Map Place Target", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Geographic place target such as a city or territory with location, pulse, radius and distance-dependent presentation." }, { "id": "map.moving_object", "name": "Map Moving Object", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Dynamic spatial object such as transport, robot or vehicle with identity, position, movement and status." }, + { "id": "map.state_facet", "name": "Map State Facet", "surface": "map", "status": ["product-required"], "authority": "Bound Data Product + Ontology Core", "summary": "A state dimension explicitly declared by the bound Data Product and its ontology value contract. Map and Foundry have no default facets and may not add values, fallback buckets or classifications of their own." }, { "id": "map.pin", "name": "Map Pin", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Reusable provider-neutral elevated-spike presentation with ground anchor, stem, head, label anchor, semantic colour/status and camera-height visibility rules." }, { "id": "map.label", "name": "Map Label", "surface": "map", "status": ["source-evidenced", "product-required"], "authority": "NDC Module Studio", "summary": "Reusable information plate attached to a spatial subject with style, size variant, anchor, offset and visibility rules." }, { "id": "map.zone", "name": "Map Zone", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Polygon or multipolygon zone, sector or geofence with optional height, extrusion and level-dependent style." }, diff --git a/services/ontology-core/catalog/domain-packages/map/guardrails.json b/services/ontology-core/catalog/domain-packages/map/guardrails.json index fa936bc..d98f8b0 100644 --- a/services/ontology-core/catalog/domain-packages/map/guardrails.json +++ b/services/ontology-core/catalog/domain-packages/map/guardrails.json @@ -1,6 +1,6 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-12", + "version": "0.2.0", + "updatedAt": "2026-07-19", "rules": [ { "id": "guardrail.map.provider_neutral_domain", @@ -26,6 +26,12 @@ "summary": "Transport, station, city and other labels must use the shared Map Label contract with semantic variants instead of source-specific label implementations.", "entityIds": ["map.label", "map.moving_object", "map.station", "map.stop", "map.terminal", "map.place_target"] }, + { + "id": "guardrail.map.presentation_uses_normalized_state_facets", + "severity": "error", + "summary": "Foundry presentation classes, filters, counters and sorting must consume only state facets and exact values declared by the bound Data Product and Ontology value contract. Renderer and profile code must not classify raw values or add fallback states.", + "entityIds": ["map.moving_object", "map.state_facet", "map.style_profile", "map.renderer_adapter"] + }, { "id": "guardrail.map.mass_geometry_requires_strategy", "severity": "warning", @@ -42,6 +48,7 @@ "blockedConflations": [ ["map.view", "map.renderer_adapter"], ["map.moving_object", "map.pin"], + ["map.state_facet", "map.style_profile"], ["map.station", "map.label"], ["map.route", "map.track_segment"], ["map.visibility_rule", "map.provider_capability"], diff --git a/services/ontology-core/catalog/domain-packages/map/package.json b/services/ontology-core/catalog/domain-packages/map/package.json index 2834dac..f533597 100644 --- a/services/ontology-core/catalog/domain-packages/map/package.json +++ b/services/ontology-core/catalog/domain-packages/map/package.json @@ -1,7 +1,7 @@ { "id": "map", - "version": "0.1.0", - "updatedAt": "2026-07-12", + "version": "0.2.0", + "updatedAt": "2026-07-19", "status": "product-required/source-evidenced", - "summary": "Provider-neutral map domain ontology for NDC Module Studio views, spatial entities, transport layers, visibility rules, selection, and replaceable renderer adapters." + "summary": "Provider-neutral map domain ontology for NDC Module Studio views, spatial entities, orthogonal state facets, transport layers, visibility rules, selection, and replaceable renderer adapters." } diff --git a/services/ontology-core/catalog/domain-packages/map/relations.json b/services/ontology-core/catalog/domain-packages/map/relations.json index a527e28..db14b05 100644 --- a/services/ontology-core/catalog/domain-packages/map/relations.json +++ b/services/ontology-core/catalog/domain-packages/map/relations.json @@ -1,11 +1,13 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-12", + "version": "0.2.0", + "updatedAt": "2026-07-19", "relations": [ { "id": "map.view.uses_viewport", "from": ["map.view"], "to": ["map.viewport"], "status": "product-required", "summary": "A Map View owns provider-neutral viewport state." }, { "id": "map.view.contains_layer", "from": ["map.view"], "to": ["map.base_layer", "map.building_layer", "map.grid_layer"], "status": "product-required", "summary": "A Map View composes enabled spatial layers." }, { "id": "map.view.displays_subject", "from": ["map.view"], "to": ["map.place_target", "map.moving_object", "map.zone", "map.route", "map.track_segment", "map.station", "map.stop", "map.terminal"], "status": "product-required", "summary": "A Map View displays domain subjects through approved page slots." }, { "id": "map.moving_object.uses_pin", "from": ["map.moving_object"], "to": ["map.pin"], "status": "product-required", "summary": "A moving object can use the shared pin presentation." }, + { "id": "map.moving_object.has_state_facet", "from": ["map.moving_object"], "to": ["map.state_facet"], "status": "product-required", "summary": "A moving object exposes only the state dimensions explicitly declared by its bound Data Product and ontology value contracts." }, + { "id": "map.style_profile.classifies_state_facet", "from": ["map.style_profile"], "to": ["map.state_facet"], "status": "product-required", "summary": "A versioned Foundry style profile maps normalized state facets to renderer-neutral presentation classes, filter groups and sort order." }, { "id": "map.subject.has_label", "from": ["map.place_target", "map.moving_object", "map.station", "map.stop", "map.terminal"], "to": ["map.label"], "status": "product-required", "summary": "Spatial subjects can use the common label contract and semantic size variants." }, { "id": "map.zone.uses_style_profile", "from": ["map.zone"], "to": ["map.style_profile"], "status": "product-required", "summary": "Zones use semantic fill, outline, height and level-dependent styling." }, { "id": "map.route.contains_track_segment", "from": ["map.route"], "to": ["map.track_segment"], "status": "product-required", "summary": "A logical route is rendered from one or more track segments." }, diff --git a/services/ontology-core/docs/GELIOS_DOMAIN_ONTOLOGY.md b/services/ontology-core/docs/GELIOS_DOMAIN_ONTOLOGY.md index b467885..1ef5ec9 100644 --- a/services/ontology-core/docs/GELIOS_DOMAIN_ONTOLOGY.md +++ b/services/ontology-core/docs/GELIOS_DOMAIN_ONTOLOGY.md @@ -2,7 +2,7 @@ Package: `catalog/domain-packages/gelios` -Status: `v1.0.0`, source-evidenced and product-required. +Status: `v1.1.0`, source-evidenced and product-required. ## Purpose and boundary @@ -15,8 +15,8 @@ model. Runtime transport, collection cadence and semantic mapping belong to an isolated NDC L2 connection instance. External Data Plane persists and delivers provider-neutral Data Products. Ontology Core describes meaning only. -The matching contract/data artifact is -`platform/packages/external-provider-contract/providers/gelios/v1`. Adding a +The current matching contract/data artifact is +`platform/packages/external-provider-contract/providers/gelios/v4`. Adding a second account creates another connection instance with different opaque credential references; it does not create another ontology package, Platform service or custom node. @@ -24,14 +24,15 @@ service or custom node. ## Provider authentication boundary Gelios issues exactly two provider secret artifacts: an access token and a -refresh token. The current NDC L2 `httpBearerAuth` request binding uses the -access token. Automatic refresh has not been proven in the deployed runtime, so -the matching provider package records refresh as `operator_managed`; neither -token value belongs in Ontology, a workflow graph, MCP, Ops or a trace. +refresh token. The current rotating credential uses the access token for +requests and keeps refresh inside native NDC L2 Credentials; neither token +value belongs in Ontology, a workflow graph, MCP, Ops or a trace. A credential label such as `read access` is operator metadata, not a Gelios -token scope. `gelios.units.current.read` is classified as read because the -approved workflow transport is `GET /api/v1/units`. The label does not create a +token scope. `gelios.units.current.read` and +`gelios.monitoring_config.current.read` are classified as read because the +approved transports are `GET /api/v1/units` and +`GET /api/v1/users/me/monitoring-config`. The label does not create a separate read token or constrain other rights that Gelios may have granted to the same access token. The scoped Data Product writer credential later in the runtime path is an internal NDC/External Data Plane capability, not a third @@ -66,7 +67,7 @@ parsed or renumbered. A unit can use a expose sensor, fuel, maintenance and custom-field configurations. Hardware IDs, IMEI, phones, address, decrypt-related fields, raw `params` and -unclassified sensor payloads are excluded by the v1 field policy. New provider +unclassified sensor payloads are excluded by the current field policy. New provider fields remain dropped until they are evidenced, classified and introduced by a new package/ontology revision. @@ -76,12 +77,12 @@ new package/ontology revision. `gelios.position_fix` is its time-qualified spatial portion; it is not a pin or other renderer object. -The first approved output is exactly: +The current approved output is exactly: ```text -Data Product: fleet.positions.current.v1 -version: 1.0.0 -ontology revision: ontology.map.moving_object.v1 +Data Product: fleet.positions.current.v3 +version: 3.0.0 +ontology revision: ontology.map.moving_object.v3 semantic type: map.moving_object delivery: snapshot+patch history: sampled, latest entity per 60-second bucket, 90 days @@ -96,21 +97,51 @@ elevation_meters geometry hdop horizontal_accuracy_meters +movement_state object_kind -operational_status position_source -position_valid -quality_flags satellite_count +signal_state speed_kph ``` All attribute names are snake_case. GeoJSON `geometry` is omitted when valid -coordinates are unavailable, but the unit remains in the product with -`position_valid=false`, `operational_status=no_position` and appropriate -`quality_flags`. If the provider has no last-message timestamp, the collection -receive time becomes the explicit `observedAt` fallback so the credential-visible -unit is not silently dropped. +coordinates are unavailable, but the unit remains in the product. Missing +geometry is not converted into a Gelios monitoring status. If the provider has +no last-message timestamp, the collection receive time becomes the explicit +`observedAt` fallback so the credential-visible unit is not silently dropped. + +### Official monitoring states + +Gelios does not return a ready-made operational-status enum from +`GET /api/v1/units`. Its monitoring client derives two independent, closed +value contracts from official source facts and settings: + +```text +signal_state: + active -> На связи / Active units + inactive -> Не на связи / Inactive units + +movement_state: + moving -> В движении / Moving units + stopped -> Неподвижные / Stopped units +``` + +The official client first marks a unit active while the age of `lastMsg.time` +is below `monitoring-config.signalActiveDuration`, then keeps it active through +the positive `signalSomewhatInactiveDuration` window. A missing message is +inactive. When an account returns empty monitoring durations, the connection +profile may supply an explicit, versioned positive fallback; its provenance is +runtime policy and does not create another ontology state. Without a resolved +positive threshold publication must fail closed. `movement_state=moving` when +the integer `lastMsg.speed` is greater than `2`; otherwise it is `stopped`. +The values and Russian labels are the official Gelios monitoring-client +contract. + +There is no `unknown`, `fresh`, `stale`, GPS-quality, position-quality, +`parked`, `no_position` or aggregate `operational_status` value in this +contract. `gelios.operational_status` is retained in the catalog only as a +`tech-debt-noncanonical` marker for retiring old v1/v2 products. ### Sensors and operational semantics @@ -128,7 +159,7 @@ Gelios is a source domain. The provider-neutral relation is: ```text gelios.unit + gelios.position_fix -> map.moving_object - -> fleet.positions.current.v1 + -> fleet.positions.current.v3 -> Foundry Data Product binding -> map layers, selection and telemetry panel ``` @@ -148,7 +179,7 @@ concepts. `gelios.command_dispatch`, `gelios.command_delivery` and No collection run, workflow, map click or autonomous agent may create a dispatch. A future command path requires explicit human intent, confirmation, separate authorization, immutable audit and delivery reconciliation. This -ontology and the Gelios v1 provider package expose no command transport. +ontology and the Gelios v4 provider package expose no command transport. ## Canonical runtime flow @@ -182,6 +213,9 @@ physical storage. temporary credential issuance, configuration mutation and downloads remain excluded until separately classified. - Dynamic provider fields are dropped until evidenced and classified. +- Gelios monitoring filters use only the exact `signal_state` and + `movement_state` value contracts. L2, Data Products and Foundry cannot add + fallback state values. - Geozones and history require bounded loading, paging/cursors, retention and volume controls. - Command send and mutation capabilities remain red even when provider access diff --git a/services/ontology-core/examples/gelios-map-moving-object.fixture.json b/services/ontology-core/examples/gelios-map-moving-object.fixture.json index d73892d..38806ed 100644 --- a/services/ontology-core/examples/gelios-map-moving-object.fixture.json +++ b/services/ontology-core/examples/gelios-map-moving-object.fixture.json @@ -20,12 +20,11 @@ "elevation_meters": 0, "hdop": 0.8, "horizontal_accuracy_meters": 4.2, + "movement_state": "stopped", "object_kind": "tracked_unit", - "operational_status": "active", "position_source": "gelios", - "position_valid": true, - "quality_flags": [], "satellite_count": 11, + "signal_state": "active", "speed_kph": 0 } } diff --git a/services/ontology-core/src/mcp-server.mjs b/services/ontology-core/src/mcp-server.mjs index 80b4129..8453dec 100644 --- a/services/ontology-core/src/mcp-server.mjs +++ b/services/ontology-core/src/mcp-server.mjs @@ -393,6 +393,20 @@ function safeEntity(value) { status: stringList(value?.status), authority: cleanString(value?.authority, 240), summary: cleanString(value?.summary, 2000), + ...(value?.valueContract ? { valueContract: safeValueContract(value.valueContract) } : {}), + } +} + +function safeValueContract(value) { + return { + field: cleanString(value?.field, 120), + values: (Array.isArray(value?.values) ? value.values : []).map((item) => ({ + value: cleanString(item?.value, 120), + label: cleanString(item?.label, 240), + sourceLabel: cleanString(item?.sourceLabel, 240), + })), + sourcePaths: stringList(value?.sourcePaths), + rules: stringList(value?.rules), } } diff --git a/services/ontology-core/src/scripts/smoke-mcp.mjs b/services/ontology-core/src/scripts/smoke-mcp.mjs index 34684d8..376b85c 100644 --- a/services/ontology-core/src/scripts/smoke-mcp.mjs +++ b/services/ontology-core/src/scripts/smoke-mcp.mjs @@ -37,10 +37,10 @@ try { const search = await rpc(baseUrl, TOKEN, 3, 'tools/call', { name: 'ontology_search', - arguments: { query: 'трайк', limit: 10 }, + arguments: { query: 'юнит гелиос', limit: 10 }, }) assert.equal(search.result.isError, undefined) - assert.equal(search.result.structuredContent.query, 'трайк') + assert.equal(search.result.structuredContent.query, 'юнит гелиос') assert.equal(search.result.structuredContent.results.some((item) => item.entity?.id === 'gelios.unit'), true) const entity = await rpc(baseUrl, TOKEN, 4, 'tools/call', { @@ -50,6 +50,16 @@ try { assert.equal(entity.result.structuredContent.entity.id, 'gelios.integration') assert.equal(JSON.stringify(entity.result.structuredContent).includes('/Users/'), false) + const signalState = await rpc(baseUrl, TOKEN, 41, 'tools/call', { + name: 'ontology_get_entity', + arguments: { entityId: 'gelios.signal_state' }, + }) + assert.equal(signalState.result.structuredContent.entity.valueContract.field, 'signal_state') + assert.deepEqual( + signalState.result.structuredContent.entity.valueContract.values.map((item) => item.value), + ['active', 'inactive'], + ) + const guardrails = await rpc(baseUrl, TOKEN, 5, 'tools/call', { name: 'ontology_get_guardrails', arguments: { entityId: 'gelios.command_dispatch' }, @@ -70,6 +80,7 @@ try { 'mcp_initialize', 'read_only_tool_catalog', 'gelios_alias_resolution', + 'gelios_value_contract_visible', 'gelios_command_guardrail_visible', 'evidence_paths_not_exposed', 'internal_bearer_required', diff --git a/services/ontology-core/src/validate.mjs b/services/ontology-core/src/validate.mjs index f7d2d4b..75a8cb9 100644 --- a/services/ontology-core/src/validate.mjs +++ b/services/ontology-core/src/validate.mjs @@ -5,6 +5,7 @@ import { loadCatalog, serviceRoot } from './catalog.mjs' const ID_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/ const RULE_ID_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/ const ROLE_ID_RE = /^[a-z][a-z0-9_]*$/ +const FIELD_ID_RE = /^[a-z][a-z0-9_]*$/ const ALLOWED_RELATION_STATUSES = new Set([ 'source-confirmed', 'source-evidenced', @@ -92,6 +93,7 @@ export async function validateCatalog() { for (const status of entity.status || []) { assert(statusVocabulary.has(status), `entity ${entity.id} uses unknown status: ${status}`, errors) } + validateValueContract(entity.valueContract, entity.id, errors) } assertUnique(relations.relations.map((relation) => relation.id), 'relation', errors) @@ -354,6 +356,32 @@ export async function validateCatalog() { } } +function validateValueContract(valueContract, entityId, errors) { + if (valueContract === undefined) return + const label = `entity ${entityId}.valueContract` + assert(valueContract && typeof valueContract === 'object' && !Array.isArray(valueContract), `${label} must be object`, errors) + if (!valueContract || typeof valueContract !== 'object' || Array.isArray(valueContract)) return + assert(FIELD_ID_RE.test(valueContract.field || ''), `${label}.field invalid`, errors) + assert(Array.isArray(valueContract.values) && valueContract.values.length > 0, `${label}.values must be non-empty array`, errors) + const values = [] + for (const [index, item] of (valueContract.values || []).entries()) { + const itemLabel = `${label}.values[${index}]` + assert(item && typeof item === 'object' && !Array.isArray(item), `${itemLabel} must be object`, errors) + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + assert(FIELD_ID_RE.test(item.value || ''), `${itemLabel}.value invalid`, errors) + assert(typeof item.label === 'string' && item.label.trim(), `${itemLabel}.label required`, errors) + assert(typeof item.sourceLabel === 'string' && item.sourceLabel.trim(), `${itemLabel}.sourceLabel required`, errors) + values.push(item.value) + } + assert(new Set(values).size === values.length, `${label}.values duplicate value`, errors) + for (const key of ['sourcePaths', 'rules']) { + assert(Array.isArray(valueContract[key]) && valueContract[key].length > 0, `${label}.${key} must be non-empty array`, errors) + for (const item of valueContract[key] || []) { + assert(typeof item === 'string' && item.trim(), `${label}.${key} contains empty item`, errors) + } + } +} + if (import.meta.url === `file://${process.argv[1]}`) { const result = await validateCatalog() if (!result.ok) {