feat(platform): complete the Gelios external data loop

This commit is contained in:
Codex 2026-07-20 20:45:05 +03:00
parent def9a24e0d
commit 8a7465cf0e
66 changed files with 5730 additions and 123 deletions

View File

@ -172,6 +172,28 @@ runtime and descriptor equality. The ordinary overlay backup is the automatic
rollback source; rollback restores the predecessor gateway and descriptor rollback source; rollback restores the predecessor gateway and descriptor
together before recreating the same backend service. 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, For `platform` artifacts, the allowlist includes the versioned Ontology Core,
the frozen legacy Gelios compatibility service, and the provider-neutral the frozen legacy Gelios compatibility service, and the provider-neutral
External Data Plane sources. The Gelios service remains reproducible only to External Data Plane sources. The Gelios service remains reproducible only to

View File

@ -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 " +
"<engine-composite-provider-v4-YYYYMMDD-NNN>",
);
}
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}`);
}
}

View File

@ -17,6 +17,7 @@ const previouslyIssuedPatchIds = new Set([
'engine-data-product-publish-grant-20260717-001', 'engine-data-product-publish-grant-20260717-001',
'engine-data-product-publish-grant-20260717-002', 'engine-data-product-publish-grant-20260717-002',
]) ])
const compositeProviderCatalogTargetSha256 = '9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a'
if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) { 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 <fresh-patch-id>') throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs <fresh-patch-id>')
@ -109,6 +110,13 @@ try {
'nodedc-source/dist', 'nodedc-source/dist',
'nodedc-source/server/tests', '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), preservedPredecessor: Object.keys(credentialSinkPredecessorSha256),
}, null, 2)) }, null, 2))
} finally { } finally {
@ -116,10 +124,14 @@ try {
} }
async function assertSourceBoundary() { async function assertSourceBoundary() {
for (const [relativePath, expectedSha256] of Object.entries(credentialSinkPredecessorSha256)) { // These files are deliberately excluded from the artifact. Their exact
const bytes = await readFile(join(engineRoot, relativePath)) // installed predecessor is verified by the root-owned runner during plan
if (digest(bytes) !== expectedSha256) { // and apply; the local builder only proves that it cannot package a link or
throw new Error(`engine_credential_sink_predecessor_drift:${relativePath}`) // 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( const runtimeOverridePath = join(
@ -129,6 +141,13 @@ async function assertSourceBoundary() {
if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) { if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) {
throw new Error('engine_publish_grant_runtime_override_mismatch') 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') const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8')
if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) { if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) {
@ -175,6 +194,32 @@ async function assertSourceBoundary() {
if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) { if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) {
throw new Error('engine_publish_grant_source_set_mismatch') 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) { for (const entry of entries) {
const source = resolve(engineRoot, entry) const source = resolve(engineRoot, entry)
const info = await lstat(source) const info = await lstat(source)

View File

@ -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
}

View File

@ -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 " +
"<engine-provider-authority-diagnostics-YYYYMMDD-NNN>",
);
}
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}`);
}
}

View File

@ -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 " +
"<engine-provider-rotating-slot-YYYYMMDD-NNN>",
);
}
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}`);
}
}

View File

@ -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;
}

View File

@ -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 " +
"<engine-provider-target-host-policy-YYYYMMDD-NNN>",
);
}
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}`);
}
}

View File

@ -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/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/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/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/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 ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const ignoredDirectoryNames = new Set(["test"]); const ignoredDirectoryNames = new Set(["test"]);

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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}`);
}

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)

View File

@ -13,6 +13,7 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
PLATFORM_ROOT = SCRIPT_DIR.parent.parent PLATFORM_ROOT = SCRIPT_DIR.parent.parent
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
ENGINE_BUILDER = SCRIPT_DIR / "build-engine-mcp-ontology-sdk-artifact.mjs" ENGINE_BUILDER = SCRIPT_DIR / "build-engine-mcp-ontology-sdk-artifact.mjs"
PLATFORM_BUILDER = SCRIPT_DIR / "build-platform-gelios-provider-v2-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("._")) self.assertFalse(Path(member.name).name.startswith("._"))
def test_engine_builder_is_byte_reproducible(self): 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 = [] outputs = []
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-ontology-sdk-") as directory: with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-ontology-sdk-") as directory:
for index in range(2): for index in range(2):

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -11,6 +11,10 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
BUILDER = SCRIPT_DIR / "build-engine-data-product-publish-grant-artifact.mjs" 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 = [ EXPECTED_ENTRIES = [
"nodedc-source/server/assets/provider-packages/v1/catalog.json", "nodedc-source/server/assets/provider-packages/v1/catalog.json",
"nodedc-source/server/dataProductPublishGrant", "nodedc-source/server/dataProductPublishGrant",
@ -26,6 +30,13 @@ SUCCESSFUL_CREDENTIAL_SINK_INDEX_SHA256 = (
class EnginePublishGrantArtifactTest(unittest.TestCase): 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): def build(self, artifact_dir, patch_id):
environment = os.environ.copy() environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
@ -39,6 +50,8 @@ class EnginePublishGrantArtifactTest(unittest.TestCase):
return json.loads(result.stdout) return json.loads(result.stdout)
def test_artifact_is_narrow_secret_free_and_deterministic(self): 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: with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-artifact-") as directory:
root = Path(directory) root = Path(directory)
first_dir = root / "first" first_dir = root / "first"
@ -70,6 +83,18 @@ class EnginePublishGrantArtifactTest(unittest.TestCase):
"payload/nodedc-source/services/backend/data-product-publish-grant/" "payload/nodedc-source/services/backend/data-product-publish-grant/"
"docker-compose.immutable-runtime.yml" "docker-compose.immutable-runtime.yml"
).read().decode("utf-8") ).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(files, EXPECTED_ENTRIES)
self.assertEqual( self.assertEqual(
@ -115,6 +140,29 @@ class EnginePublishGrantArtifactTest(unittest.TestCase):
) )
self.assertIn("read_only: true", runtime_override) self.assertIn("read_only: true", runtime_override)
self.assertEqual(runtime_override.count("create_host_path: false"), 2) 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): def test_builder_requires_a_fresh_never_issued_patch_id(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-id-") as directory: with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-id-") as directory:
@ -140,6 +188,8 @@ class EnginePublishGrantArtifactTest(unittest.TestCase):
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertIn(expected, result.stderr) self.assertIn(expected, result.stderr)
if not self.historical_source_is_current():
return
self.build(artifact_dir, PATCH_ID) self.build(artifact_dir, PATCH_ID)
duplicate = subprocess.run( duplicate = subprocess.run(
["node", str(BUILDER), PATCH_ID], ["node", str(BUILDER), PATCH_ID],

View File

@ -20,7 +20,9 @@ EXPECTED_ENTRIES = [
"platform/packages/external-provider-contract/src/data-product.mjs", "platform/packages/external-provider-contract/src/data-product.mjs",
"platform/packages/external-provider-contract/src/intake-batch.mjs", "platform/packages/external-provider-contract/src/intake-batch.mjs",
"platform/packages/external-provider-contract/src/index.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/src/sensitive-field-policy.mjs",
"platform/packages/external-provider-contract/providers/gelios",
] ]

View File

@ -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)

View File

@ -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)

View File

@ -372,8 +372,10 @@ class CanonicalPlatformRegistryTest(unittest.TestCase):
original_root = RUNNER.COMPONENTS["engine"]["payload_root"] original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 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.COMPONENTS["engine"]["payload_root"] = root
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected
try: try:
self.assertEqual( self.assertEqual(
RUNNER.preflight_engine_data_product_publish_grant_predecessor(), RUNNER.preflight_engine_data_product_publish_grant_predecessor(),
@ -391,6 +393,7 @@ class CanonicalPlatformRegistryTest(unittest.TestCase):
RUNNER.preflight_engine_data_product_publish_grant_predecessor() RUNNER.preflight_engine_data_product_publish_grant_predecessor()
finally: finally:
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes 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 RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_installed_publish_update_preserves_foundation_and_allows_only_grant_source_changes(self): 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_root = RUNNER.COMPONENTS["engine"]["payload_root"]
original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 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.COMPONENTS["engine"]["payload_root"] = root
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected
try: try:
self.assertEqual( self.assertEqual(
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload), 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) RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
finally: finally:
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes 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 RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_edp_healthcheck_requires_database_and_managed_provisioning(self): def test_edp_healthcheck_requires_database_and_managed_provisioning(self):

View File

@ -48,14 +48,18 @@ read-capability передаёт все entities, которые provider воз
boundary запрещены; сортировка и видимость принадлежат Data Product consumer и boundary запрещены; сортировка и видимость принадлежат Data Product consumer и
Foundry. Foundry.
Первый production-shaped package — `providers/gelios/v1`. Он фиксирует реальный Текущий production-shaped package — `providers/gelios/v5`. Он сохраняет два
Gelios REST `GET /api/v1/units` transport contract и точный output официальных Gelios REST safe-read: `GET /api/v1/users/me/monitoring-config` и
`fleet.positions.current.v1@1.0.0` с revision `GET /api/v1/units?incltrip=true`, а также точный immutable output
`ontology.map.moving_object.v1`. Все Data Product fields используют snake_case. `fleet.positions.current.v4@4.0.0` с revision
Его optional `tokenLifecycle` точно описывает два provider-issued artifacts — `ontology.map.moving_object.v3`. Единственные monitoring-state fields —
`access` и `refresh`: request использует `access`, а refresh пока имеет режим `signal_state` (`active|inactive`) и `movement_state` (`moving|stopped`),
`operator_managed`. Это metadata без secret values и без заявления о полученные из Ontology Gelios `v1.1.0`. Все Data Product fields используют
реализованном автоматическом refresh. 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 ## Проверяемые v1 contracts
@ -64,8 +68,11 @@ Gelios REST `GET /api/v1/units` transport contract и точный output
запрещены; publisher представлен system-managed declaration/status без ref. запрещены; publisher представлен system-managed declaration/status без ref.
- `Collection Profile` — явная policy сбора. `manual` не может скрыто содержать - `Collection Profile` — явная policy сбора. `manual` не может скрыто содержать
polling interval; `realtime` требует interval не чаще одного раза в секунду. 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 - `Intake Batch` — canonical **scoped** record, который External Data Plane
валидирует и сохраняет: source, contract revision, idempotency, restricted валидирует и сохраняет: source, contract revision, idempotency, restricted
raw envelope и canonical facts. Его `source` содержит `providerId`, raw envelope и canonical facts. Его `source` содержит `providerId`,
@ -153,10 +160,11 @@ Provider auth и внутренние workload capabilities используют
credential domains. После сохранения ядро владеет secret, а graph и MCP credential domains. После сохранения ядро владеет secret, а graph и MCP
используют только opaque reference. используют только opaque reference.
Gelios выдаёт ровно access token и refresh token. Текущий credential type Gelios выдаёт ровно access token и refresh token. Production credential type
`httpBearerAuth` использует access token для HTTP request. Автоматический обмен `ndcProviderRotatingAccessApi` использует access token для HTTP request и выполняет
refresh → access в текущем runtime не доказан и поэтому не заявлен: package refresh → access в native n8n `preAuthentication`; provider package v3 фиксирует
фиксирует `refreshMode: operator_managed`. Название credential с текстом вроде `refreshMode: runtime_managed`. Оба secret artifact остаются в native Credentials и не
попадают в graph, package или trace. Название credential с текстом вроде
`read access` является лишь локальной меткой; read-классификацию задают `read access` является лишь локальной меткой; read-классификацию задают
разрешённые endpoint/method в capability catalog и workflow policy, а не scope разрешённые endpoint/method в capability catalog и workflow policy, а не scope
самого access token. В deployed Engine exact method/path policy ещё не самого access token. В deployed Engine exact method/path policy ещё не

View File

@ -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.

View File

@ -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";

View File

@ -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;
}

View File

@ -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.

View File

@ -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";

View File

@ -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;
}

View File

@ -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.

View File

@ -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";

View File

@ -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;
}

View File

@ -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 COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]);
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
const HISTORY_MODES = new Set(["none", "all", "sampled"]); 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([ const L2_STEP_KINDS = new Set([
"collection_trigger", "collection_trigger",
"provider_request", "provider_request",
@ -115,8 +116,10 @@ const DATA_PRODUCT_KEYS = new Set([
"deliveryMode", "deliveryMode",
"semanticTypes", "semanticTypes",
"fields", "fields",
"fieldContracts",
"history", "history",
]); ]);
const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]);
const HISTORY_KEYS = new Set(["mode", "intervalMs", "strategy", "retentionDays"]); const HISTORY_KEYS = new Set(["mode", "intervalMs", "strategy", "retentionDays"]);
const MAPPING_KEYS = new Set([ const MAPPING_KEYS = new Set([
"schemaVersion", "schemaVersion",
@ -130,7 +133,10 @@ const MAPPING_KEYS = new Set([
]); ]);
const MAPPING_TARGET_KEYS = new Set(["dataProductId", "version", "ontologyRevision", "semanticType"]); const MAPPING_TARGET_KEYS = new Set(["dataProductId", "version", "ontologyRevision", "semanticType"]);
const FACT_KEYS = new Set(["sourceId", "semanticType", "observedAt", "geometry", "attributes"]); 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 GEOMETRY_KEYS = new Set(["type", "longitude", "latitude", "omitIfInvalid"]);
const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]); const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]);
const TEMPLATE_KEYS = new Set([ const TEMPLATE_KEYS = new Set([
@ -226,9 +232,6 @@ export function validateProviderPackage(value) {
} }
for (const profile of collectionProfiles) { for (const profile of collectionProfiles) {
const selectedCapabilityIds = Array.isArray(profile.capabilityIds) ? profile.capabilityIds : []; 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) => { selectedCapabilityIds.forEach((id) => {
assertReference(id, capabilityIds, `collectionProfile.${profile.id}.capabilityIds`, errors); assertReference(id, capabilityIds, `collectionProfile.${profile.id}.capabilityIds`, errors);
const capability = capabilities.find((item) => item.id === id); const capability = capabilities.find((item) => item.id === id);
@ -247,10 +250,6 @@ export function validateProviderPackage(value) {
if (!selectedCapabilityIds.includes(mapping.sourceCapabilityId)) { if (!selectedCapabilityIds.includes(mapping.sourceCapabilityId)) {
errors.push(`collectionProfile.${profile.id}.mapping_source_capability_must_be_selected`); 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.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`); 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`); errors.push(`collectionProfile.${profile.id}.field_policy_data_product_mismatch`);
} }
if (template) { if (template) {
const [, requestStep, extractStep, mappingStep, publishStep] = Array.isArray(template.steps) ? template.steps : []; const steps = Array.isArray(template.steps) ? template.steps : [];
if (!selectedCapabilityIds.includes(requestStep?.capabilityId) || extractStep?.capabilityId !== requestStep?.capabilityId) { 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`); errors.push(`collectionProfile.${profile.id}.template_capability_chain_mismatch`);
} }
if (mappingStep?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`); if (mappingSteps.length !== 1 || mappingSteps[0]?.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 (publishSteps.length !== 1 || publishSteps[0]?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.template_data_product_mismatch`);
} }
} }
for (const mapping of mappingContracts) { for (const mapping of mappingContracts) {
@ -315,15 +321,17 @@ export function validateProviderPackage(value) {
const providerBinding = Array.isArray(template.credentialBindings) const providerBinding = Array.isArray(template.credentialBindings)
? template.credentialBindings.find((binding) => binding?.role === "provider") ? template.credentialBindings.find((binding) => binding?.role === "provider")
: undefined; : undefined;
const providerRequest = Array.isArray(template.steps) const providerRequests = Array.isArray(template.steps)
? template.steps.find((step) => step?.kind === "provider_request") ? template.steps.filter((step) => step?.kind === "provider_request")
: undefined; : [];
for (const providerRequest of providerRequests) {
const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId); const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId);
if (providerBinding && requestCapability if (providerBinding && requestCapability
&& providerBinding.authModeId !== requestCapability.authModeId) { && providerBinding.authModeId !== requestCapability.authModeId) {
errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`); errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`);
} }
} }
}
if (value.tenantId !== undefined || value.connectionId !== undefined || value.credentialRef !== undefined) { if (value.tenantId !== undefined || value.connectionId !== undefined || value.credentialRef !== undefined) {
errors.push("providerPackage_must_not_contain_connection_instance_state"); errors.push("providerPackage_must_not_contain_connection_instance_state");
@ -557,6 +565,7 @@ function validateDataProductDefinition(value, path, errors) {
if (Array.isArray(value.fields) && value.fields.some((field) => SECRET_FIELD_NAME.test(String(field)))) { if (Array.isArray(value.fields) && value.fields.some((field) => SECRET_FIELD_NAME.test(String(field)))) {
errors.push(`${path}.fields_must_not_contain_secret_fields`); errors.push(`${path}.fields_must_not_contain_secret_fields`);
} }
validateFieldContracts(value.fieldContracts, value.fields, `${path}.fieldContracts`, errors);
if (!isPlainObject(value.history)) { if (!isPlainObject(value.history)) {
errors.push(`${path}.history_must_be_object`); errors.push(`${path}.history_must_be_object`);
} else { } else {
@ -688,8 +697,8 @@ function validateL2Template(value, path, errors) {
}); });
if (!roles.has("provider") || !roles.has("publisher")) errors.push(`${path}.credentialBindings_roles_invalid`); if (!roles.has("provider") || !roles.has("publisher")) errors.push(`${path}.credentialBindings_roles_invalid`);
} }
if (!Array.isArray(value.steps) || value.steps.length !== 5) { if (!Array.isArray(value.steps) || value.steps.length < 5) {
errors.push(`${path}.steps_must_define_five_boundary_steps`); errors.push(`${path}.steps_must_define_complete_boundary`);
} else { } else {
const stepIds = new Set(); const stepIds = new Set();
value.steps.forEach((step, index) => { value.steps.forEach((step, index) => {
@ -701,26 +710,41 @@ function validateL2Template(value, path, errors) {
stepIds.add(step.id); stepIds.add(step.id);
if (!L2_STEP_KINDS.has(step.kind)) errors.push(`${stepPath}.kind_invalid`); if (!L2_STEP_KINDS.has(step.kind)) errors.push(`${stepPath}.kind_invalid`);
}); });
const expectedKinds = [ const requestSteps = value.steps.filter((step) => step?.kind === "provider_request");
"collection_trigger", const extractSteps = value.steps.filter((step) => step?.kind === "extract_items");
"provider_request", const mappingSteps = value.steps.filter((step) => step?.kind === "semantic_mapping");
"extract_items", const publishSteps = value.steps.filter((step) => step?.kind === "data_product_publish");
"semantic_mapping", const requestStart = 1;
"data_product_publish", const extractIndex = value.steps.findIndex((step) => step?.kind === "extract_items");
]; const expectedSequence = (
if (!value.steps.every((step, index) => step?.kind === expectedKinds[index])) errors.push(`${path}.steps_sequence_invalid`); 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[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) { for (const step of requestSteps) {
if (!step?.capabilityId || step?.mappingContractId !== undefined || step?.dataProductId !== undefined) {
errors.push(`${path}.provider_request_shape_invalid`); 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`); errors.push(`${path}.extract_items_shape_invalid`);
} }
if (value.steps[1]?.capabilityId !== value.steps[2]?.capabilityId) errors.push(`${path}.request_extract_capability_mismatch`); if (!requestSteps.some((step) => step.capabilityId === extractStep?.capabilityId)) errors.push(`${path}.request_extract_capability_mismatch`);
if (!value.steps[3]?.mappingContractId || value.steps[3]?.capabilityId !== undefined || value.steps[3]?.dataProductId !== undefined) { const mappingStep = mappingSteps[0];
if (!mappingStep?.mappingContractId || mappingStep?.capabilityId !== undefined || mappingStep?.dataProductId !== undefined) {
errors.push(`${path}.semantic_mapping_shape_invalid`); 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`); 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"] : [])]; 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`); 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) { 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.fallback !== undefined && value.fallback !== "collection_received_at") errors.push(`${path}.fallback_invalid`);
if (value.derive !== undefined) requiredIdentifier(value.derive, `${path}.derive`, errors); 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.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) { function validateDerivation(value, path, errors) {

View File

@ -15,6 +15,9 @@ import {
geliosUnitsCurrentFixtureV1, geliosUnitsCurrentFixtureV1,
} from "../providers/gelios/v1/index.mjs"; } from "../providers/gelios/v1/index.mjs";
import { geliosProviderPackageV2 } from "../providers/gelios/v2/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"; import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs";
const expectedFields = [ const expectedFields = [
@ -35,6 +38,197 @@ const expectedFields = [
assert.deepEqual(validateProviderPackage(geliosProviderPackageV1), { ok: true, errors: [] }); assert.deepEqual(validateProviderPackage(geliosProviderPackageV1), { ok: true, errors: [] });
assert.deepEqual(validateProviderPackage(geliosProviderPackageV2), { 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 geliosSdkAuth = geliosProviderPackageV2.authModes[0];
const geliosSdkUnitsRead = geliosProviderPackageV2.capabilities[0]; const geliosSdkUnitsRead = geliosProviderPackageV2.capabilities[0];
@ -318,7 +512,7 @@ for (const profile of withUnexecutedSecondCapability.collectionProfiles) {
} }
assert.equal( assert.equal(
validateProviderPackage(withUnexecutedSecondCapability).errors.includes( 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, true,
); );

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -7,7 +7,7 @@
"start": "node src/server.mjs", "start": "node src/server.mjs",
"dev": "node --watch 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", "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:integration": "node test/data-product-delivery.integration.test.mjs && node test/api.integration.test.mjs",
"test:all": "npm test && npm run test:integration" "test:all": "npm test && npm run test:integration"
}, },

View File

@ -13,7 +13,7 @@ export async function loadDataProductDefinition(db, dataProductId, { activeOnly
const result = await db.query( const result = await db.query(
`select id, version, ontology_revision as "ontologyRevision", `select id, version, ontology_revision as "ontologyRevision",
delivery_mode as "deliveryMode", semantic_types as "semanticTypes", 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" created_at as "createdAt", updated_at as "updatedAt"
from external_data_plane_products from external_data_plane_products
where id = $1 ${activeOnly ? "and active = true" : ""}`, where id = $1 ${activeOnly ? "and active = true" : ""}`,
@ -25,8 +25,8 @@ export async function loadDataProductDefinition(db, dataProductId, { activeOnly
export async function persistDataProductDefinition(db, definition) { export async function persistDataProductDefinition(db, definition) {
const result = await db.query( const result = await db.query(
`insert into external_data_plane_products ( `insert into external_data_plane_products (
id, version, ontology_revision, delivery_mode, semantic_types, fields, history_policy id, version, ontology_revision, delivery_mode, semantic_types, fields, field_contracts, history_policy
) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb) ) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb)
on conflict (id) do update set on conflict (id) do update set
active = true, active = true,
updated_at = now() 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.delivery_mode = excluded.delivery_mode
and external_data_plane_products.semantic_types = excluded.semantic_types and external_data_plane_products.semantic_types = excluded.semantic_types
and external_data_plane_products.fields = excluded.fields 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 and external_data_plane_products.history_policy = excluded.history_policy
returning id, version, ontology_revision as "ontologyRevision", returning id, version, ontology_revision as "ontologyRevision",
delivery_mode as "deliveryMode", semantic_types as "semanticTypes", 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"`, created_at as "createdAt", updated_at as "updatedAt"`,
[ [
definition.id, definition.id,
@ -47,6 +48,7 @@ export async function persistDataProductDefinition(db, definition) {
definition.deliveryMode, definition.deliveryMode,
JSON.stringify(definition.semanticTypes), JSON.stringify(definition.semanticTypes),
JSON.stringify(definition.fields), JSON.stringify(definition.fields),
JSON.stringify(definition.fieldContracts || {}),
JSON.stringify(definition.history), JSON.stringify(definition.history),
], ],
); );
@ -658,6 +660,8 @@ function publishFingerprint(batch) {
export function assertPublishMatchesDefinition(batch, definition) { export function assertPublishMatchesDefinition(batch, definition) {
const semanticTypes = new Set(Array.isArray(definition?.semanticTypes) ? definition.semanticTypes : []); const semanticTypes = new Set(Array.isArray(definition?.semanticTypes) ? definition.semanticTypes : []);
const fields = new Set(Array.isArray(definition?.fields) ? definition.fields : []); 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(); const entityKeys = new Set();
for (const fact of batch?.facts || []) { for (const fact of batch?.facts || []) {
if (!semanticTypes.has(fact.semanticType)) throw deliveryError("data_product_semantic_type_forbidden", 422); if (!semanticTypes.has(fact.semanticType)) throw deliveryError("data_product_semantic_type_forbidden", 422);
@ -672,7 +676,53 @@ export function assertPublishMatchesDefinition(batch, definition) {
if (fact.geometry !== undefined && !geometryDeclared(fields)) { if (fact.geometry !== undefined && !geometryDeclared(fields)) {
throw deliveryError("data_product_geometry_forbidden", 422); 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) { function geometryDeclared(fields) {

View File

@ -2,10 +2,12 @@ const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
const HISTORY_MODES = new Set(["none", "all", "sampled"]); 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([ 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 HISTORY_KEYS = new Set(["mode", "intervalMs", "retentionDays", "strategy"]);
const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]);
export function normalizeDataProductDefinition(value) { export function normalizeDataProductDefinition(value) {
if (!isPlainObject(value) || !hasOnlyKeys(value, DEFINITION_KEYS)) { 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"); if (!semanticTypes.length || !fields.length) throw policyError("data_product_definition_shape_invalid");
const fieldContracts = normalizeFieldContracts(value.fieldContracts, fields);
const history = normalizeHistoryPolicy(value.history); 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" }) { export function normalizeHistoryPolicy(value = { mode: "none" }) {
@ -62,6 +114,7 @@ export function safeDataProductDefinition(row) {
deliveryMode: row.deliveryMode, deliveryMode: row.deliveryMode,
semanticTypes: array(row.semanticTypes), semanticTypes: array(row.semanticTypes),
fields: array(row.fields), fields: array(row.fields),
fieldContracts: isPlainObject(row.fieldContracts) ? row.fieldContracts : {},
history: isPlainObject(row.historyPolicy) ? row.historyPolicy : {}, history: isPlainObject(row.historyPolicy) ? row.historyPolicy : {},
active: row.active === true, active: row.active === true,
createdAt: iso(row.createdAt), createdAt: iso(row.createdAt),
@ -116,3 +169,11 @@ function isPlainObject(value) {
function hasOnlyKeys(value, allowed) { function hasOnlyKeys(value, allowed) {
return Object.keys(value).every((key) => allowed.has(key)); 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)}`;
}

View File

@ -12,14 +12,33 @@ export async function migrate(pool) {
delivery_mode text not null, delivery_mode text not null,
semantic_types jsonb not null, semantic_types jsonb not null,
fields jsonb not null, fields jsonb not null,
field_contracts jsonb not null default '{}'::jsonb,
history_policy jsonb not null, history_policy jsonb not null,
active boolean not null default true, active boolean not null default true,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
updated_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(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(` await pool.query(`
create table if not exists external_data_plane_batches ( create table if not exists external_data_plane_batches (

View File

@ -1179,7 +1179,7 @@ async function listGrantedProducts(binding) {
const result = await pool.query( const result = await pool.query(
`select id, version, ontology_revision as "ontologyRevision", `select id, version, ontology_revision as "ontologyRevision",
delivery_mode as "deliveryMode", semantic_types as "semanticTypes", 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" created_at as "createdAt", updated_at as "updatedAt"
from external_data_plane_products from external_data_plane_products
where active = true and id = any($1::text[]) where active = true and id = any($1::text[])

View File

@ -38,6 +38,12 @@ try {
deliveryMode: "snapshot+patch", deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"], semanticTypes: ["map.moving_object"],
fields: ["source_id", "observed_at", "geometry", "status"], 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: { history: {
mode: "sampled", mode: "sampled",
intervalMs: 60_000, intervalMs: 60_000,
@ -47,6 +53,7 @@ try {
}); });
await persistDataProductDefinition(pool, definition); await persistDataProductDefinition(pool, definition);
const storedDefinition = await loadDataProductDefinition(pool, definition.id); const storedDefinition = await loadDataProductDefinition(pool, definition.id);
assert.deepEqual(storedDefinition.fieldContracts, definition.fieldContracts);
const binding = { const binding = {
id: "reader-binding-test", id: "reader-binding-test",
bindingKey: "managed-reader-binding-test", bindingKey: "managed-reader-binding-test",

View File

@ -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");

View File

@ -24,6 +24,8 @@ assert.deepEqual(input.semanticTypes, ["vehicle.trike", "map.moving_object"]);
assert.deepEqual(input.fields, ["status", "source_id", "observed_at", "geometry"]); assert.deepEqual(input.fields, ["status", "source_id", "observed_at", "geometry"]);
assert.equal(Object.isFrozen(definition.semanticTypes), true); assert.equal(Object.isFrozen(definition.semanticTypes), true);
assert.equal(Object.isFrozen(definition.fields), true); assert.equal(Object.isFrozen(definition.fields), true);
assert.deepEqual(definition.fieldContracts, {});
assert.equal(Object.isFrozen(definition.fieldContracts), true);
const reordered = normalizeDataProductDefinition({ const reordered = normalizeDataProductDefinition({
...input, ...input,
@ -78,4 +80,49 @@ assert.throws(
/data_product_definition_fields_duplicate/, /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"); console.log("external-data-plane data product policy: ok");

View File

@ -6,7 +6,12 @@ import { join } from "node:path";
import { loadDataProductDefinitions } from "../src/definitions.mjs"; import { loadDataProductDefinitions } from "../src/definitions.mjs";
const bundled = await loadDataProductDefinitions(); 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.deepEqual(bundled[0].semanticTypes, ["map.moving_object"]);
assert.equal(bundled[0].ontologyRevision, "ontology.map.moving_object.v1"); assert.equal(bundled[0].ontologyRevision, "ontology.map.moving_object.v1");
assert.equal(bundled[0].history.mode, "sampled"); 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("geometry"), true);
assert.equal(bundled[0].fields.includes("providerUnitId"), false); assert.equal(bundled[0].fields.includes("providerUnitId"), false);
assert.equal(bundled[0].fields.includes("provider_unit_id"), 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-")); const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-"));
try { try {

View File

@ -1,6 +1,6 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"updatedAt": "2026-07-16", "updatedAt": "2026-07-20",
"aliases": [ "aliases": [
{ "alias": "gelios", "canonicalId": "gelios.integration" }, { "alias": "gelios", "canonicalId": "gelios.integration" },
{ "alias": "gelius", "canonicalId": "gelios.integration" }, { "alias": "gelius", "canonicalId": "gelios.integration" },
@ -13,7 +13,12 @@
{ "alias": "последняя телеметрия гелиос", "canonicalId": "gelios.telemetry_snapshot" }, { "alias": "последняя телеметрия гелиос", "canonicalId": "gelios.telemetry_snapshot" },
{ "alias": "сырая телеметрия гелиос", "canonicalId": "gelios.raw_telemetry_message" }, { "alias": "сырая телеметрия гелиос", "canonicalId": "gelios.raw_telemetry_message" },
{ "alias": "позиция объекта гелиос", "canonicalId": "gelios.position_fix" }, { "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_definition" },
{ "alias": "показание датчика", "canonicalId": "gelios.sensor_reading" }, { "alias": "показание датчика", "canonicalId": "gelios.sensor_reading" },
{ "alias": "калибровка датчика", "canonicalId": "gelios.sensor_conversion" }, { "alias": "калибровка датчика", "canonicalId": "gelios.sensor_conversion" },

View File

@ -1,6 +1,6 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"updatedAt": "2026-07-16", "updatedAt": "2026-07-20",
"entities": [ "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.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." }, { "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.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.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.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_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_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." }, { "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." },

View File

@ -1,11 +1,21 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"updatedAt": "2026-07-16", "updatedAt": "2026-07-20",
"sourceRoots": [ "sourceRoots": [
{ {
"surface": "gelios-rest", "surface": "gelios-rest",
"path": "https://api.geliospro.com/docs", "path": "https://api.geliospro.com/openapi.json",
"mode": "OpenAPI inspection and safe read-only runtime audit; no write routes executed" "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", "surface": "ndc-l2-legacy-evidence",
@ -28,6 +38,8 @@
"gelios.unit", "gelios.unit",
"gelios.telemetry_snapshot", "gelios.telemetry_snapshot",
"gelios.position_fix", "gelios.position_fix",
"gelios.signal_state",
"gelios.movement_state",
"gelios.sensor_definition", "gelios.sensor_definition",
"gelios.sensor_reading", "gelios.sensor_reading",
"gelios.geozone", "gelios.geozone",
@ -42,6 +54,7 @@
"restrictions": [ "restrictions": [
"Do not copy access/refresh tokens, passwords, hardware decrypt keys or runtime snapshots into Ontology Core.", "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 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.", "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.", "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." "Do not bulk-download history, media or full geozone geometry before collection policy and storage architecture are approved."

View File

@ -1,6 +1,6 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"updatedAt": "2026-07-16", "updatedAt": "2026-07-20",
"rules": [ "rules": [
{ {
"id": "guardrail.gelios.ontology_not_runtime_store", "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.", "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"] "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", "id": "guardrail.gelios.raw_and_pii_are_restricted",
"severity": "warning", "severity": "warning",
@ -56,6 +62,9 @@
["gelios.unit", "gelios.tracker_device"], ["gelios.unit", "gelios.tracker_device"],
["gelios.telemetry_snapshot", "gelios.raw_telemetry_message"], ["gelios.telemetry_snapshot", "gelios.raw_telemetry_message"],
["gelios.position_fix", "map.moving_object"], ["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_template", "gelios.command_dispatch"],
["gelios.command_dispatch", "gelios.command_delivery"], ["gelios.command_dispatch", "gelios.command_delivery"],
["gelios.geozone", "map.zone"] ["gelios.geozone", "map.zone"]

View File

@ -1,7 +1,7 @@
{ {
"id": "gelios", "id": "gelios",
"version": "1.0.0", "version": "1.1.0",
"updatedAt": "2026-07-16", "updatedAt": "2026-07-20",
"status": "product-required/source-evidenced", "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."
} }

View File

@ -1,6 +1,6 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"updatedAt": "2026-07-16", "updatedAt": "2026-07-20",
"relations": [ "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.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." }, { "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.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.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_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.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.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." }, { "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." },

View File

@ -1,6 +1,6 @@
{ {
"version": "0.1.0", "version": "0.2.0",
"updatedAt": "2026-07-12", "updatedAt": "2026-07-19",
"entities": [ "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.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." }, { "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.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.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.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.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.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." }, { "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." },

View File

@ -1,6 +1,6 @@
{ {
"version": "0.1.0", "version": "0.2.0",
"updatedAt": "2026-07-12", "updatedAt": "2026-07-19",
"rules": [ "rules": [
{ {
"id": "guardrail.map.provider_neutral_domain", "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.", "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"] "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", "id": "guardrail.map.mass_geometry_requires_strategy",
"severity": "warning", "severity": "warning",
@ -42,6 +48,7 @@
"blockedConflations": [ "blockedConflations": [
["map.view", "map.renderer_adapter"], ["map.view", "map.renderer_adapter"],
["map.moving_object", "map.pin"], ["map.moving_object", "map.pin"],
["map.state_facet", "map.style_profile"],
["map.station", "map.label"], ["map.station", "map.label"],
["map.route", "map.track_segment"], ["map.route", "map.track_segment"],
["map.visibility_rule", "map.provider_capability"], ["map.visibility_rule", "map.provider_capability"],

View File

@ -1,7 +1,7 @@
{ {
"id": "map", "id": "map",
"version": "0.1.0", "version": "0.2.0",
"updatedAt": "2026-07-12", "updatedAt": "2026-07-19",
"status": "product-required/source-evidenced", "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."
} }

View File

@ -1,11 +1,13 @@
{ {
"version": "0.1.0", "version": "0.2.0",
"updatedAt": "2026-07-12", "updatedAt": "2026-07-19",
"relations": [ "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.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.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.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.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.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.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." }, { "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." },

View File

@ -2,7 +2,7 @@
Package: `catalog/domain-packages/gelios` 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 ## 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 isolated NDC L2 connection instance. External Data Plane persists and delivers
provider-neutral Data Products. Ontology Core describes meaning only. provider-neutral Data Products. Ontology Core describes meaning only.
The matching contract/data artifact is The current matching contract/data artifact is
`platform/packages/external-provider-contract/providers/gelios/v1`. Adding a `platform/packages/external-provider-contract/providers/gelios/v4`. Adding a
second account creates another connection instance with different opaque second account creates another connection instance with different opaque
credential references; it does not create another ontology package, Platform credential references; it does not create another ontology package, Platform
service or custom node. service or custom node.
@ -24,14 +24,15 @@ service or custom node.
## Provider authentication boundary ## Provider authentication boundary
Gelios issues exactly two provider secret artifacts: an access token and a Gelios issues exactly two provider secret artifacts: an access token and a
refresh token. The current NDC L2 `httpBearerAuth` request binding uses the refresh token. The current rotating credential uses the access token for
access token. Automatic refresh has not been proven in the deployed runtime, so requests and keeps refresh inside native NDC L2 Credentials; neither token
the matching provider package records refresh as `operator_managed`; neither value belongs in Ontology, a workflow graph, MCP, Ops or a trace.
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 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 token scope. `gelios.units.current.read` and
approved workflow transport is `GET /api/v1/units`. The label does not create a `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 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 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 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. expose sensor, fuel, maintenance and custom-field configurations.
Hardware IDs, IMEI, phones, address, decrypt-related fields, raw `params` and 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 fields remain dropped until they are evidenced, classified and introduced by a
new package/ontology revision. 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 `gelios.position_fix` is its time-qualified spatial portion; it is not a pin or
other renderer object. other renderer object.
The first approved output is exactly: The current approved output is exactly:
```text ```text
Data Product: fleet.positions.current.v1 Data Product: fleet.positions.current.v3
version: 1.0.0 version: 3.0.0
ontology revision: ontology.map.moving_object.v1 ontology revision: ontology.map.moving_object.v3
semantic type: map.moving_object semantic type: map.moving_object
delivery: snapshot+patch delivery: snapshot+patch
history: sampled, latest entity per 60-second bucket, 90 days history: sampled, latest entity per 60-second bucket, 90 days
@ -96,21 +97,51 @@ elevation_meters
geometry geometry
hdop hdop
horizontal_accuracy_meters horizontal_accuracy_meters
movement_state
object_kind object_kind
operational_status
position_source position_source
position_valid
quality_flags
satellite_count satellite_count
signal_state
speed_kph speed_kph
``` ```
All attribute names are snake_case. GeoJSON `geometry` is omitted when valid All attribute names are snake_case. GeoJSON `geometry` is omitted when valid
coordinates are unavailable, but the unit remains in the product with coordinates are unavailable, but the unit remains in the product. Missing
`position_valid=false`, `operational_status=no_position` and appropriate geometry is not converted into a Gelios monitoring status. If the provider has
`quality_flags`. If the provider has no last-message timestamp, the collection no last-message timestamp, the collection receive time becomes the explicit
receive time becomes the explicit `observedAt` fallback so the credential-visible `observedAt` fallback so the credential-visible unit is not silently dropped.
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 ### Sensors and operational semantics
@ -128,7 +159,7 @@ Gelios is a source domain. The provider-neutral relation is:
```text ```text
gelios.unit + gelios.position_fix gelios.unit + gelios.position_fix
-> map.moving_object -> map.moving_object
-> fleet.positions.current.v1 -> fleet.positions.current.v3
-> Foundry Data Product binding -> Foundry Data Product binding
-> map layers, selection and telemetry panel -> 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 No collection run, workflow, map click or autonomous agent may create a
dispatch. A future command path requires explicit human intent, confirmation, dispatch. A future command path requires explicit human intent, confirmation,
separate authorization, immutable audit and delivery reconciliation. This 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 ## Canonical runtime flow
@ -182,6 +213,9 @@ physical storage.
temporary credential issuance, configuration mutation and downloads remain temporary credential issuance, configuration mutation and downloads remain
excluded until separately classified. excluded until separately classified.
- Dynamic provider fields are dropped until evidenced and 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 - Geozones and history require bounded loading, paging/cursors, retention and
volume controls. volume controls.
- Command send and mutation capabilities remain red even when provider access - Command send and mutation capabilities remain red even when provider access

View File

@ -20,12 +20,11 @@
"elevation_meters": 0, "elevation_meters": 0,
"hdop": 0.8, "hdop": 0.8,
"horizontal_accuracy_meters": 4.2, "horizontal_accuracy_meters": 4.2,
"movement_state": "stopped",
"object_kind": "tracked_unit", "object_kind": "tracked_unit",
"operational_status": "active",
"position_source": "gelios", "position_source": "gelios",
"position_valid": true,
"quality_flags": [],
"satellite_count": 11, "satellite_count": 11,
"signal_state": "active",
"speed_kph": 0 "speed_kph": 0
} }
} }

View File

@ -393,6 +393,20 @@ function safeEntity(value) {
status: stringList(value?.status), status: stringList(value?.status),
authority: cleanString(value?.authority, 240), authority: cleanString(value?.authority, 240),
summary: cleanString(value?.summary, 2000), 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),
} }
} }

View File

@ -37,10 +37,10 @@ try {
const search = await rpc(baseUrl, TOKEN, 3, 'tools/call', { const search = await rpc(baseUrl, TOKEN, 3, 'tools/call', {
name: 'ontology_search', name: 'ontology_search',
arguments: { query: 'трайк', limit: 10 }, arguments: { query: 'юнит гелиос', limit: 10 },
}) })
assert.equal(search.result.isError, undefined) 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) assert.equal(search.result.structuredContent.results.some((item) => item.entity?.id === 'gelios.unit'), true)
const entity = await rpc(baseUrl, TOKEN, 4, 'tools/call', { 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(entity.result.structuredContent.entity.id, 'gelios.integration')
assert.equal(JSON.stringify(entity.result.structuredContent).includes('/Users/'), false) 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', { const guardrails = await rpc(baseUrl, TOKEN, 5, 'tools/call', {
name: 'ontology_get_guardrails', name: 'ontology_get_guardrails',
arguments: { entityId: 'gelios.command_dispatch' }, arguments: { entityId: 'gelios.command_dispatch' },
@ -70,6 +80,7 @@ try {
'mcp_initialize', 'mcp_initialize',
'read_only_tool_catalog', 'read_only_tool_catalog',
'gelios_alias_resolution', 'gelios_alias_resolution',
'gelios_value_contract_visible',
'gelios_command_guardrail_visible', 'gelios_command_guardrail_visible',
'evidence_paths_not_exposed', 'evidence_paths_not_exposed',
'internal_bearer_required', 'internal_bearer_required',

View File

@ -5,6 +5,7 @@ import { loadCatalog, serviceRoot } from './catalog.mjs'
const ID_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/ 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 RULE_ID_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/
const ROLE_ID_RE = /^[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([ const ALLOWED_RELATION_STATUSES = new Set([
'source-confirmed', 'source-confirmed',
'source-evidenced', 'source-evidenced',
@ -92,6 +93,7 @@ export async function validateCatalog() {
for (const status of entity.status || []) { for (const status of entity.status || []) {
assert(statusVocabulary.has(status), `entity ${entity.id} uses unknown status: ${status}`, errors) 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) 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]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
const result = await validateCatalog() const result = await validateCatalog()
if (!result.ok) { if (!result.ok) {