feat(core): add ontology-backed asset and host runtime

This commit is contained in:
DCCONSTRUCTIONS
2026-08-22 14:47:44 +03:00
parent 6ed4414a97
commit b302b6ba1a
13 changed files with 1959 additions and 89 deletions
@@ -1,115 +1,132 @@
# Device Infrastructure Host ontology candidate
# Device Core asset and infrastructure ontology contract
Status: **canonical foundation implemented**
Status: **candidate, not canonical**
Date: 2026-08-22
## Why this is a candidate
Production ontology catalog hash: `229c61c02a790906`
The live read-only NODE.DC ontology catalog currently has no canonical generic
compute host, deployment, service instance or health-observation entities. Its
`integration.connection` entity models provider-account connections and must
not be reused for a VPS merely to unblock a screen.
## Authority
Device Core therefore does not mint local identifiers and present them as
official ontology. The first shipped projection is limited to the already
canonical product relationship that exists in Device Core today:
Device Core consumes the official read-only NODE.DC Ontology Core catalog. It
does not mint local ontology identifiers. The production catalog now publishes
the `asset`, `device`, `infrastructure` and `observation` packages required by
this runtime foundation.
Canonical entities used by Device Core:
| Entity | Runtime meaning |
| --- | --- |
| `asset.asset` | Stable business or physical object whose identity survives tracker replacement |
| `device.tracking_device` | A tracker registered through Device Core enrollment and claim |
| `device.asset_binding` | Temporal, evidenced Device-to-Asset association |
| `infrastructure.host` | Provider-neutral physical or virtual compute host |
| `infrastructure.endpoint` | Credential-free management, monitoring or service endpoint |
| `infrastructure.deployment` | Desired immutable artifact deployment on a host |
| `infrastructure.service_instance` | Runtime service realizing a deployment on a host |
| `observation.health_observation` | Time-bounded health evidence with source and freshness |
| `observation.position_observation` | Canonical position evidence for Foundry/map projections |
`infrastructure.host` is deliberately distinct from
`integration.connection`, `infrastructure.endpoint`,
`infrastructure.deployment` and Device Edge registration.
## Direct B2 onboarding
Direct B2 registration reuses the existing secure Device Core lifecycle:
```text
Device Project -> Route -> Edge registration -> pinned Core↔Edge channel
adapter + model profile + route
|
v
enrollment intent (IMEI -> HMAC digest + masked projection)
|
v
quarantine discovery -> claim -> device.tracking_device
|
+-> opaque deploymentRef
v
asset.asset <- temporal device.asset_binding
```
The UI calls this projection `VPS / Edge host` and derives current reachability
from the live channel supervisor. It does not expose channel endpoints, trust
material, certificates or credentials.
No raw IMEI, provider credential or device secret is stored in the ontology or
returned to the browser. The current Gelios flow remains a legacy integration;
new B2 trackers can be enrolled directly and attached to the same stable Asset
model without making Gelios the identity authority.
## Proposed canonical concepts
Closing a binding records `valid_to` and actor evidence. Tracker replacement
therefore changes the active binding, not the Asset identity or its history.
The following names are discussion handles only. Final identifiers, scopes and
relation direction must be published by the official ontology owner before
Device Core persists them.
| Candidate concept | Scope | Required meaning |
| --- | --- | --- |
| `infrastructure.host` | owner + project visibility | A physical or virtual compute host independent of provider and workload |
| `infrastructure.endpoint` | host | A bounded management or service endpoint without credentials |
| `infrastructure.deployment` | owner/project | An immutable desired deployment of an artifact or workload |
| `infrastructure.service_instance` | host + deployment | A runtime instance produced by a deployment |
| `infrastructure.health_observation` | observed entity | A time-bounded observation with source, timestamp and evidence class |
| `infrastructure.management_session` | actor + host | An expiring, audited brokered management session |
Proposed relations:
## VPS and Edge topology
```text
device.project --uses--> infrastructure.host
infrastructure.host --exposes--> infrastructure.endpoint
infrastructure.deployment --targets--> infrastructure.host
infrastructure.service_instance --runs-on--> infrastructure.host
infrastructure.service_instance --realizes--> infrastructure.deployment
device.edge-registration --runs-on--> infrastructure.host
device.route --terminates-at--> device.edge-registration
infrastructure.health-observation --observes--> host|endpoint|service-instance
infrastructure.management-session --targets--> infrastructure.host
device.project -> infrastructure.host -> infrastructure.endpoint
|
v
infrastructure.deployment
|
v
infrastructure.service_instance
|
+---- optional link ----> device.edge-registration
```
## State is faceted, not flattened
Provider names and provider resource IDs are annotations. They never define
the host identity. A management credential is an opaque `secret-ref:*`; the
secret value is not accepted by the API, stored in these tables or projected
to the Manager.
A single `online` flag is insufficient and would hardcode the current VPS
case. Each host projection needs independent facets:
An Edge link is allowed only when the Edge is already used by a route in the
same Device project. This prevents a generic host screen from silently taking
ownership of a platform-wide Edge registration.
- lifecycle: `provisioning | active | suspended | retired`;
- reachability: `reachable | degraded | unreachable | unobserved`;
- management access: `available | denied | expired | unconfigured`;
- workload health: per service instance, never inferred from host ping alone;
- observation freshness: `observedAt`, TTL and source;
- desired/actual configuration: immutable revision refs and reconciliation
state, with secrets represented only by opaque secret refs.
## Health and monitoring
This allows one host to be reachable while a service is unhealthy, or a
service to be healthy while interactive management access is intentionally
disabled.
Health is append-only evidence, not a mutable `online` property. Each
observation has:
## Registration workflow after ontology publication
- subject: Host or Service Instance;
- state: `reachable`, `degraded` or `unreachable`;
- evidence class and schema reference;
- source reference;
- `observed_at` and mandatory `expires_at`;
- bounded, secret-free evidence projection.
1. Create or select an owner-scoped host identity.
2. Grant project visibility through a canonical project-host relation.
3. Attach a provider-neutral endpoint projection and an opaque credential ref.
4. Run a bounded reachability probe through a server-side worker.
5. Register deployments and discovered service instances as separate entities.
6. Link an Edge registration to the host when that role is actually deployed.
7. Emit health observations with TTL instead of mutating a permanent `online`
property.
The read projection returns `unobserved` when evidence is missing or stale.
Missing evidence is never converted into `unreachable`.
Provider fields such as Beget, Hetzner or AWS remain annotations or provider
relations. They never change the host identity or the UI information model.
## API surface
Idempotent management commands:
- `assets:ensure`;
- `asset-bindings:ensure` and `asset-bindings:close`;
- `infrastructure-hosts:ensure`;
- `infrastructure-endpoints:ensure`;
- `infrastructure-deployments:ensure`;
- `infrastructure-service-instances:ensure`;
- `health-observations:record`.
The safe read projection is:
```text
GET /internal/v1/query/projects/{project-id}/ontology
```
It returns the catalog hash, assets and temporal bindings, provider-neutral
infrastructure inventory, and freshness-aware health projections. It never
returns credential references themselves.
## Console boundary
An unrestricted WebSSH terminal is not part of the first slice. If introduced,
it must be a server-side session broker with all of the following properties:
An unrestricted WebSSH terminal remains disabled. A future console requires a
separate `infrastructure.management_session` broker with short-lived sessions,
server-side credentials, command authorization, immutable audit, output limits,
redaction and explicit break-glass controls. A direct browser-to-SSH connection
is outside the Device Core security boundary.
- short-lived session and explicit target selection;
- authorization checked at session creation and command execution;
- no private key or password delivered to the browser;
- bounded command catalog by default;
- immutable actor/target/timing/exit-code audit;
- output size limits and secret redaction;
- explicit break-glass mode for arbitrary commands;
- automatic expiry, revocation and concurrent-session limits.
## Next projection
The broker must reference `infrastructure.management_session` after that
concept becomes canonical. A browser shell iframe or direct browser-to-SSH
connection is outside the Device Core security boundary.
## Delivery sequence
1. **Shipped candidate UI projection:** VPS/Edge hosts, route relationships and
live pinned-channel state from existing Device Core records.
2. **Ontology gate:** publish host/deployment/service/health entities and
relations in the official ontology package.
3. **Inventory:** add provider-neutral host registration and project grants.
4. **Monitoring:** persist bounded health observations from a dedicated worker.
5. **Configuration:** immutable desired revisions and reconciliation receipts.
6. **Management sessions:** bounded command runner first; audited break-glass
console only after a separate threat-model review.
Foundry consumes canonical Asset, Device, temporal binding and Observation
relations. Spatial layers are produced only from current
`observation.position_observation` evidence; Hosts without spatial evidence do
not appear on a map merely because they exist in inventory.
@@ -0,0 +1,284 @@
begin;
alter table device_management_command_receipts
drop constraint if exists device_management_command_receipts_command_kind_check;
alter table device_management_command_receipts
add constraint device_management_command_receipts_command_kind_check
check (command_kind in (
'owner_scope.ensure',
'project.ensure',
'collection.ensure',
'project_grant.upsert',
'adapter_package.ensure',
'adapter_version.register',
'model_profile.register',
'edge.ensure',
'route.ensure',
'enrollment_intent.ensure',
'device.claim',
'device.update',
'device.transfer',
'discovery.reject',
'discovery.expire',
'device_credential_binding.upsert',
'device_credential_binding.revoke',
'device_binding.ensure',
'device_binding.revoke',
'device_configuration_revision.create',
'device_configuration_desired.set',
'asset.ensure',
'asset_binding.ensure',
'asset_binding.close',
'infrastructure_host.ensure',
'infrastructure_endpoint.ensure',
'infrastructure_deployment.ensure',
'infrastructure_service_instance.ensure',
'health_observation.record'
));
create table if not exists device_assets (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
asset_key text not null
check (asset_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
asset_type_ref text not null
check (length(btrim(asset_type_ref)) between 3 and 256),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'retired')),
ontology_entity_id text not null default 'asset.asset'
check (ontology_entity_id = 'asset.asset'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, asset_key),
unique (id, project_id, owner_scope_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
);
create index if not exists device_assets_project_state_idx
on device_assets (project_id, lifecycle_state, updated_at desc);
create table if not exists device_asset_bindings (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
binding_key text not null
check (binding_key ~ '^[a-z][a-z0-9-]{1,62}$'),
device_id uuid not null,
asset_id uuid not null,
binding_kind text not null default 'tracking'
check (binding_kind in ('tracking', 'installed', 'assigned')),
valid_from timestamptz not null,
valid_to timestamptz,
provenance_ref text not null
check (length(btrim(provenance_ref)) between 3 and 256),
ontology_entity_id text not null default 'device.asset_binding'
check (ontology_entity_id = 'device.asset_binding'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
closed_by_ref text
check (closed_by_ref is null or length(btrim(closed_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, binding_key),
foreign key (device_id, project_id, owner_scope_id)
references device_instances(id, project_id, owner_scope_id),
foreign key (asset_id, project_id, owner_scope_id)
references device_assets(id, project_id, owner_scope_id),
check (valid_to is null or valid_to > valid_from),
check ((valid_to is null and closed_by_ref is null) or (valid_to is not null and closed_by_ref is not null))
);
create unique index if not exists device_asset_bindings_active_device_idx
on device_asset_bindings (device_id)
where valid_to is null;
create index if not exists device_asset_bindings_asset_time_idx
on device_asset_bindings (asset_id, valid_from desc, valid_to);
create table if not exists device_infrastructure_hosts (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_key text not null
check (host_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
provider_ref text
check (provider_ref is null or length(btrim(provider_ref)) between 3 and 256),
external_ref text
check (external_ref is null or length(btrim(external_ref)) between 3 and 256),
management_credential_ref text
check (
management_credential_ref is null
or management_credential_ref ~ '^secret-ref:[A-Za-z0-9][A-Za-z0-9._:/+-]{2,244}$'
),
lifecycle_state text not null default 'provisioning'
check (lifecycle_state in ('provisioning', 'active', 'suspended', 'retired')),
ontology_entity_id text not null default 'infrastructure.host'
check (ontology_entity_id = 'infrastructure.host'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, host_key),
unique (id, project_id, owner_scope_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
);
create index if not exists device_infrastructure_hosts_project_state_idx
on device_infrastructure_hosts (project_id, lifecycle_state, updated_at desc);
create table if not exists device_infrastructure_endpoints (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
endpoint_key text not null
check (endpoint_key ~ '^[a-z][a-z0-9-]{1,62}$'),
purpose text not null
check (purpose in ('management', 'service', 'monitoring')),
endpoint_uri text not null
check (
length(btrim(endpoint_uri)) between 8 and 512
and endpoint_uri !~ '@'
),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'disabled', 'retired')),
ontology_entity_id text not null default 'infrastructure.endpoint'
check (ontology_entity_id = 'infrastructure.endpoint'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (host_id, endpoint_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id)
);
create table if not exists device_infrastructure_deployments (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
deployment_key text not null
check (deployment_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
artifact_ref text not null
check (length(btrim(artifact_ref)) between 3 and 256),
artifact_digest text not null
check (artifact_digest ~ '^sha256:[a-f0-9]{64}$'),
lifecycle_state text not null default 'desired'
check (lifecycle_state in ('desired', 'applying', 'active', 'failed', 'retired')),
ontology_entity_id text not null default 'infrastructure.deployment'
check (ontology_entity_id = 'infrastructure.deployment'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, deployment_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id)
);
create table if not exists device_infrastructure_service_instances (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
deployment_id uuid not null,
edge_id uuid references device_edges(id),
service_key text not null
check (service_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
service_role text not null
check (service_role ~ '^[a-z][a-z0-9._-]{1,63}$'),
lifecycle_state text not null default 'provisioning'
check (lifecycle_state in ('provisioning', 'active', 'degraded', 'stopped', 'retired')),
ontology_entity_id text not null default 'infrastructure.service_instance'
check (ontology_entity_id = 'infrastructure.service_instance'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (host_id, service_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id),
foreign key (deployment_id, project_id, owner_scope_id)
references device_infrastructure_deployments(id, project_id, owner_scope_id)
);
create table if not exists device_health_observations (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid,
service_instance_id uuid,
observed_state text not null
check (observed_state in ('reachable', 'degraded', 'unreachable')),
evidence_class text not null
check (evidence_class in ('agent_probe', 'channel', 'management_probe', 'manual')),
source_ref text not null
check (length(btrim(source_ref)) between 3 and 256),
schema_ref text not null
check (length(btrim(schema_ref)) between 3 and 256),
evidence_projection jsonb not null default '{}'::jsonb
check (
jsonb_typeof(evidence_projection) = 'object'
and octet_length(evidence_projection::text) <= 16384
),
observed_at timestamptz not null,
expires_at timestamptz not null,
ontology_entity_id text not null default 'observation.health_observation'
check (ontology_entity_id = 'observation.health_observation'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
recorded_by_ref text not null
check (length(btrim(recorded_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id),
foreign key (service_instance_id, project_id, owner_scope_id)
references device_infrastructure_service_instances(id, project_id, owner_scope_id),
check (
(host_id is not null and service_instance_id is null)
or (host_id is null and service_instance_id is not null)
),
check (expires_at > observed_at)
);
create index if not exists device_health_observations_host_time_idx
on device_health_observations (host_id, observed_at desc)
where host_id is not null;
create index if not exists device_health_observations_service_time_idx
on device_health_observations (service_instance_id, observed_at desc)
where service_instance_id is not null;
commit;
+58
View File
@@ -46,6 +46,29 @@ const managementRoutes = new Map([
"/internal/v1/management/device-configurations:set-desired",
"device_configuration_desired.set",
],
["/internal/v1/management/assets:ensure", "asset.ensure"],
["/internal/v1/management/asset-bindings:ensure", "asset_binding.ensure"],
["/internal/v1/management/asset-bindings:close", "asset_binding.close"],
[
"/internal/v1/management/infrastructure-hosts:ensure",
"infrastructure_host.ensure",
],
[
"/internal/v1/management/infrastructure-endpoints:ensure",
"infrastructure_endpoint.ensure",
],
[
"/internal/v1/management/infrastructure-deployments:ensure",
"infrastructure_deployment.ensure",
],
[
"/internal/v1/management/infrastructure-service-instances:ensure",
"infrastructure_service_instance.ensure",
],
[
"/internal/v1/management/health-observations:record",
"health_observation.record",
],
]);
export function createControlCoreApp({
@@ -295,6 +318,34 @@ export function createControlCoreApp({
return writeJson(response, 200, { ok: true, workspace });
}
const ontologyProjectId = projectOntologyId(requestUrl.pathname);
if (request.method === "GET" && ontologyProjectId) {
if (!managementApiEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_management_api_disabled",
});
}
if (!matchesBearer(request.headers.authorization, managementToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_management_auth_required",
});
}
if (typeof repository.getProjectOntologyProjection !== "function") {
return writeJson(response, 503, {
ok: false,
error: "device_query_repository_unavailable",
});
}
const actor = managementActorFromHeaders(request.headers);
const projection = await repository.getProjectOntologyProjection(
actor,
ontologyProjectId,
);
return writeJson(response, 200, { ok: true, projection });
}
if (
request.method === "POST"
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
@@ -408,6 +459,13 @@ function projectWorkspaceId(pathname) {
return match?.[1]?.toLowerCase() ?? null;
}
function projectOntologyId(pathname) {
const match = pathname.match(
/^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/ontology$/i,
);
return match?.[1]?.toLowerCase() ?? null;
}
function managementActorFromHeaders(headers) {
return normalizeManagementActor({
userRef: singleHeader(headers["x-nodedc-user-ref"]),
@@ -17,6 +17,11 @@ import {
DEVICE_MANAGEMENT_COMMAND_KINDS,
normalizeManagementCommand,
} from "./project-management.mjs";
import {
DEVICE_ONTOLOGY_COMMAND_KINDS,
isOntologyManagementCommand,
normalizeOntologyManagementCommand,
} from "./ontology-management.mjs";
import {
DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
isSensitiveReferenceManagementCommand,
@@ -29,9 +34,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
...DEVICE_LIFECYCLE_COMMAND_KINDS,
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
...DEVICE_ONTOLOGY_COMMAND_KINDS,
]);
export function normalizeDeviceManagementCommand(kind, input) {
if (isOntologyManagementCommand(kind)) {
return normalizeOntologyManagementCommand(kind, input);
}
if (isControlResourceManagementCommand(kind)) {
return normalizeControlResourceManagementCommand(kind, input);
}
@@ -0,0 +1,319 @@
import {
assertSafeProjection,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export const DEVICE_ONTOLOGY_COMMAND_KINDS = Object.freeze([
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]);
export const DEVICE_ONTOLOGY_CATALOG_HASH = "229c61c02a790906";
const commandKindSet = new Set(DEVICE_ONTOLOGY_COMMAND_KINDS);
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
const secretRefPattern = /^secret-ref:[A-Za-z0-9][A-Za-z0-9._:/+-]{2,244}$/;
const digestPattern = /^sha256:[a-f0-9]{64}$/;
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
export function isOntologyManagementCommand(kind) {
return commandKindSet.has(kind);
}
export function normalizeOntologyManagementCommand(kind, input) {
if (!commandKindSet.has(kind)) {
throw new TypeError("device_ontology_command_kind_invalid");
}
assertPlainObject(input, "device_ontology_command_invalid");
if (kind === "asset.ensure") {
assertAllowedKeys(input, [
"projectRef", "assetKey", "displayName", "assetTypeRef", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
assetKey: normalizeKey(input.assetKey, "device_asset_key_invalid"),
displayName: normalizeText(input.displayName, 160, "device_asset_name_invalid"),
assetTypeRef: normalizeOpaqueRef(input.assetTypeRef, "device_asset_type_ref_invalid"),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "active",
new Set(["active", "retired"]),
"device_asset_state_invalid",
),
});
}
if (kind === "asset_binding.ensure") {
assertAllowedKeys(input, [
"projectRef", "bindingKey", "deviceRef", "assetRef", "bindingKind",
"validFrom", "provenanceRef",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
bindingKey: normalizeKey(input.bindingKey, "device_asset_binding_key_invalid"),
deviceId: normalizeEntityRef(input.deviceRef, "device"),
assetId: normalizeEntityRef(input.assetRef, "asset"),
bindingKind: normalizeEnum(
input.bindingKind ?? "tracking",
new Set(["tracking", "installed", "assigned"]),
"device_asset_binding_kind_invalid",
),
validFrom: normalizeTimestamp(input.validFrom, "device_asset_binding_valid_from_invalid"),
provenanceRef: normalizeOpaqueRef(
input.provenanceRef,
"device_asset_binding_provenance_ref_invalid",
),
});
}
if (kind === "asset_binding.close") {
assertAllowedKeys(input, ["projectRef", "assetBindingRef", "validTo"]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
assetBindingId: normalizeEntityRef(input.assetBindingRef, "asset-binding"),
validTo: normalizeTimestamp(input.validTo, "device_asset_binding_valid_to_invalid"),
});
}
if (kind === "infrastructure_host.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostKey", "displayName", "providerRef", "externalRef",
"managementCredentialRef", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostKey: normalizeKey(input.hostKey, "device_host_key_invalid"),
displayName: normalizeText(input.displayName, 160, "device_host_name_invalid"),
providerRef: normalizeOptionalOpaqueRef(input.providerRef, "device_host_provider_ref_invalid"),
externalRef: normalizeOptionalOpaqueRef(input.externalRef, "device_host_external_ref_invalid"),
managementCredentialRef: normalizeOptionalPattern(
input.managementCredentialRef,
secretRefPattern,
"device_host_management_credential_ref_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "provisioning",
new Set(["provisioning", "active", "suspended", "retired"]),
"device_host_state_invalid",
),
});
}
if (kind === "infrastructure_endpoint.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "endpointKey", "purpose", "endpointUri",
"lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
endpointKey: normalizeKey(input.endpointKey, "device_endpoint_key_invalid"),
purpose: normalizeEnum(
input.purpose,
new Set(["management", "service", "monitoring"]),
"device_endpoint_purpose_invalid",
),
endpointUri: normalizeEndpointUri(input.endpointUri),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "active",
new Set(["active", "disabled", "retired"]),
"device_endpoint_state_invalid",
),
});
}
if (kind === "infrastructure_deployment.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "deploymentKey", "displayName", "artifactRef",
"artifactDigest", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
deploymentKey: normalizeKey(input.deploymentKey, "device_deployment_key_invalid"),
displayName: normalizeText(
input.displayName,
160,
"device_deployment_name_invalid",
),
artifactRef: normalizeOpaqueRef(input.artifactRef, "device_deployment_artifact_ref_invalid"),
artifactDigest: normalizePattern(
input.artifactDigest,
digestPattern,
"device_deployment_artifact_digest_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "desired",
new Set(["desired", "applying", "active", "failed", "retired"]),
"device_deployment_state_invalid",
),
});
}
if (kind === "infrastructure_service_instance.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "deploymentRef", "edgeRef", "serviceKey",
"displayName", "serviceRole", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
deploymentId: normalizeEntityRef(input.deploymentRef, "deployment"),
edgeId: input.edgeRef == null ? null : normalizeEntityRef(input.edgeRef, "edge"),
serviceKey: normalizeKey(input.serviceKey, "device_service_instance_key_invalid"),
displayName: normalizeText(
input.displayName,
160,
"device_service_instance_name_invalid",
),
serviceRole: normalizePattern(
input.serviceRole,
/^[a-z][a-z0-9._-]{1,63}$/,
"device_service_instance_role_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "provisioning",
new Set(["provisioning", "active", "degraded", "stopped", "retired"]),
"device_service_instance_state_invalid",
),
});
}
assertAllowedKeys(input, [
"projectRef", "subjectKind", "subjectRef", "observedState", "evidenceClass",
"sourceRef", "schemaRef", "evidence", "observedAt", "expiresAt",
]);
const subjectKind = normalizeEnum(
input.subjectKind,
new Set(["host", "service-instance"]),
"device_health_subject_kind_invalid",
);
const evidence = structuredClone(assertSafeProjection(input.evidence ?? {}));
if (Buffer.byteLength(JSON.stringify(evidence), "utf8") > 16 * 1024) {
throw new TypeError("device_health_evidence_too_large");
}
const observedAt = normalizeTimestamp(
input.observedAt,
"device_health_observed_at_invalid",
);
const expiresAt = normalizeTimestamp(
input.expiresAt,
"device_health_expires_at_invalid",
);
if (Date.parse(expiresAt) <= Date.parse(observedAt)) {
throw new TypeError("device_health_freshness_window_invalid");
}
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
subjectKind,
subjectId: normalizeEntityRef(input.subjectRef, subjectKind),
observedState: normalizeEnum(
input.observedState,
new Set(["reachable", "degraded", "unreachable"]),
"device_health_observed_state_invalid",
),
evidenceClass: normalizeEnum(
input.evidenceClass,
new Set(["agent_probe", "channel", "management_probe", "manual"]),
"device_health_evidence_class_invalid",
),
sourceRef: normalizeOpaqueRef(input.sourceRef, "device_health_source_ref_invalid"),
schemaRef: normalizeOpaqueRef(input.schemaRef, "device_health_schema_ref_invalid"),
evidence: Object.freeze(evidence),
observedAt,
expiresAt,
});
}
function normalizeEndpointUri(value) {
if (typeof value !== "string" || value.length > 512) {
throw new TypeError("device_endpoint_uri_invalid");
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw new TypeError("device_endpoint_uri_invalid");
}
if (
!new Set(["https:", "ssh:", "tcp:"]).has(parsed.protocol)
|| !parsed.hostname
|| parsed.username
|| parsed.password
|| parsed.search
|| parsed.hash
) {
throw new TypeError("device_endpoint_uri_invalid");
}
return parsed.toString();
}
function normalizeEntityRef(value, kind) {
if (typeof value !== "string") throw new TypeError(`device_${kind}_ref_invalid`);
const match = value.match(new RegExp(`^${kind}:(${uuidPattern.source})$`, "i"));
if (!match) throw new TypeError(`device_${kind}_ref_invalid`);
return match[1].toLowerCase();
}
function normalizeKey(value, errorCode) {
return normalizePattern(value, keyPattern, errorCode);
}
function normalizeOpaqueRef(value, errorCode) {
return normalizePattern(value, opaqueRefPattern, errorCode);
}
function normalizeOptionalOpaqueRef(value, errorCode) {
return value == null || value === "" ? null : normalizeOpaqueRef(value, errorCode);
}
function normalizeOptionalPattern(value, pattern, errorCode) {
return value == null || value === "" ? null : normalizePattern(value, pattern, errorCode);
}
function normalizePattern(value, pattern, errorCode) {
if (typeof value !== "string" || !pattern.test(value)) throw new TypeError(errorCode);
return value;
}
function normalizeText(value, maximum, errorCode) {
if (typeof value !== "string") throw new TypeError(errorCode);
const normalized = value.trim();
if (normalized.length < 1 || normalized.length > maximum) throw new TypeError(errorCode);
return normalized;
}
function normalizeEnum(value, allowed, errorCode) {
if (typeof value !== "string" || !allowed.has(value)) throw new TypeError(errorCode);
return value;
}
function normalizeTimestamp(value, errorCode) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(value)) {
throw new TypeError(errorCode);
}
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.valueOf())) throw new TypeError(errorCode);
return timestamp.toISOString();
}
function assertPlainObject(value, errorCode) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(errorCode);
}
}
function assertAllowedKeys(value, allowedKeys) {
const allowed = new Set(allowedKeys);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
throw new TypeError(`device_ontology_command_field_unexpected:${key}`);
}
}
}
@@ -0,0 +1,213 @@
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
export async function getDeviceProjectOntologyProjection(client, actor, projectId) {
await findProjectWithCapability(
client,
actor,
projectId,
"infrastructure.read",
{ lock: false },
);
const [assets, assetBindings, hosts, endpoints, deployments, services] =
await Promise.all([
client.query(
`select * from device_assets
where project_id = $1
order by display_name, id`,
[projectId],
),
client.query(
`select dab.*, di.display_name as device_name,
da.display_name as asset_name
from device_asset_bindings dab
join device_instances di on di.id = dab.device_id
join device_assets da on da.id = dab.asset_id
where dab.project_id = $1
order by dab.valid_from desc, dab.id`,
[projectId],
),
client.query(
`select dih.id, dih.project_id, dih.host_key, dih.display_name,
dih.provider_ref, dih.external_ref, dih.lifecycle_state,
dih.ontology_entity_id, dih.ontology_catalog_hash,
(dih.management_credential_ref is not null) as management_credential_configured,
ho.id as health_observation_id,
ho.observed_state as health_observed_state,
ho.evidence_class as health_evidence_class,
ho.observed_at as health_observed_at,
ho.expires_at as health_expires_at
from device_infrastructure_hosts dih
left join lateral (
select dho.id, dho.observed_state, dho.evidence_class,
dho.observed_at, dho.expires_at
from device_health_observations dho
where dho.host_id = dih.id
order by dho.observed_at desc, dho.id desc
limit 1
) ho on true
where dih.project_id = $1
order by dih.display_name, dih.id`,
[projectId],
),
client.query(
`select * from device_infrastructure_endpoints
where project_id = $1
order by host_id, endpoint_key, id`,
[projectId],
),
client.query(
`select * from device_infrastructure_deployments
where project_id = $1
order by updated_at desc, id`,
[projectId],
),
client.query(
`select disi.*,
ho.id as health_observation_id,
ho.observed_state as health_observed_state,
ho.evidence_class as health_evidence_class,
ho.observed_at as health_observed_at,
ho.expires_at as health_expires_at
from device_infrastructure_service_instances disi
left join lateral (
select dho.id, dho.observed_state, dho.evidence_class,
dho.observed_at, dho.expires_at
from device_health_observations dho
where dho.service_instance_id = disi.id
order by dho.observed_at desc, dho.id desc
limit 1
) ho on true
where disi.project_id = $1
order by disi.display_name, disi.id`,
[projectId],
),
]);
return {
ontology: {
catalogHash: "229c61c02a790906",
packages: ["asset", "device", "infrastructure", "observation"],
},
assets: assets.rows.map(assetView),
assetBindings: assetBindings.rows.map(assetBindingView),
hosts: hosts.rows.map(hostView),
endpoints: endpoints.rows.map(endpointView),
deployments: deployments.rows.map(deploymentView),
serviceInstances: services.rows.map(serviceInstanceView),
policies: {
restrictedIdentifiers: "masked-only",
managementCredentials: "opaque-reference-only",
missingHealthEvidence: "unobserved-not-unhealthy",
arbitraryConsole: "disabled",
},
};
}
function assetView(row) {
return {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
displayName: row.display_name,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function assetBindingView(row) {
return {
assetBindingRef: `asset-binding:${row.id}`,
bindingKey: row.binding_key,
deviceRef: `device:${row.device_id}`,
deviceName: row.device_name,
assetRef: `asset:${row.asset_id}`,
assetName: row.asset_name,
bindingKind: row.binding_kind,
validFrom: toIso(row.valid_from),
validTo: toIso(row.valid_to),
provenanceRef: row.provenance_ref,
ontology: ontologyView(row),
};
}
function hostView(row) {
return {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
displayName: row.display_name,
providerRef: row.provider_ref ?? null,
externalRef: row.external_ref ?? null,
managementCredentialConfigured: row.management_credential_configured === true,
lifecycleState: row.lifecycle_state,
health: healthView(row),
ontology: ontologyView(row),
};
}
function endpointView(row) {
return {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
endpointKey: row.endpoint_key,
purpose: row.purpose,
endpointUri: row.endpoint_uri,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function deploymentView(row) {
return {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentKey: row.deployment_key,
displayName: row.display_name,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function serviceInstanceView(row) {
return {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceKey: row.service_key,
displayName: row.display_name,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
health: healthView(row),
ontology: ontologyView(row),
};
}
function healthView(row) {
if (!row.health_observation_id) {
return { state: "unobserved", freshness: "missing", observationRef: null };
}
const fresh = new Date(row.health_expires_at).valueOf() > Date.now();
return {
state: fresh ? row.health_observed_state : "unobserved",
freshness: fresh ? "fresh" : "stale",
lastObservedState: row.health_observed_state,
evidenceClass: row.health_evidence_class,
observedAt: toIso(row.health_observed_at),
expiresAt: toIso(row.health_expires_at),
observationRef: `health-observation:${row.health_observation_id}`,
};
}
function ontologyView(row) {
return {
entityId: row.ontology_entity_id,
catalogHash: row.ontology_catalog_hash,
};
}
function toIso(value) {
return value == null ? null : new Date(value).toISOString();
}
@@ -0,0 +1,611 @@
import { randomUUID } from "node:crypto";
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
import { isOntologyManagementCommand } from "./ontology-management.mjs";
import { toProjectRef } from "./project-management.mjs";
const commandCapabilities = Object.freeze({
"asset.ensure": "asset.manage",
"asset_binding.ensure": "binding.manage",
"asset_binding.close": "binding.manage",
"infrastructure_host.ensure": "infrastructure.manage",
"infrastructure_endpoint.ensure": "infrastructure.manage",
"infrastructure_deployment.ensure": "infrastructure.manage",
"infrastructure_service_instance.ensure": "infrastructure.manage",
"health_observation.record": "observation.write",
});
export async function applyOntologyManagementCommand(
client,
{ commandKind, actor, command },
) {
assertOntologyCommand(commandKind);
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
commandCapabilities[commandKind],
);
if (commandKind === "asset.ensure") {
return ensureAsset(client, actor, project, command);
}
if (commandKind === "asset_binding.ensure") {
return ensureAssetBinding(client, actor, project, command);
}
if (commandKind === "asset_binding.close") {
return closeAssetBinding(client, actor, project, command);
}
if (commandKind === "infrastructure_host.ensure") {
return ensureHost(client, actor, project, command);
}
if (commandKind === "infrastructure_endpoint.ensure") {
return ensureEndpoint(client, actor, project, command);
}
if (commandKind === "infrastructure_deployment.ensure") {
return ensureDeployment(client, actor, project, command);
}
if (commandKind === "infrastructure_service_instance.ensure") {
return ensureServiceInstance(client, actor, project, command);
}
return recordHealthObservation(client, actor, project, command);
}
export async function authorizeOntologyManagementReplay(
client,
{ commandKind, actor, command },
) {
assertOntologyCommand(commandKind);
await findProjectWithCapability(
client,
actor,
command.projectId,
commandCapabilities[commandKind],
);
}
async function ensureAsset(client, actor, project, command) {
const result = await client.query(
`insert into device_assets (
id, owner_scope_id, project_id, asset_key, display_name,
asset_type_ref, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8)
on conflict (project_id, asset_key) do update set
display_name = excluded.display_name,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_assets.asset_type_ref = excluded.asset_type_ref
and (
device_assets.lifecycle_state = excluded.lifecycle_state
or (
device_assets.lifecycle_state = 'active'
and excluded.lifecycle_state = 'retired'
)
)
returning *, (xmax = 0) as created`,
[
randomUUID(),
project.owner_scope_id,
project.id,
command.assetKey,
command.displayName,
command.assetTypeRef,
command.lifecycleState,
actor.userRef,
],
);
const row = requireRow(result, "device_asset_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "asset.created" : "asset.updated",
payload: {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontologyEntityId: row.ontology_entity_id,
},
});
return { created: row.created === true, asset: assetView(row) };
}
async function ensureAssetBinding(client, actor, project, command) {
const [deviceResult, assetResult] = await Promise.all([
client.query(
`select di.id, di.project_id, di.owner_scope_id, di.display_name,
di.lifecycle_state, dmp.device_type
from device_instances di
join device_model_profiles dmp on dmp.profile_ref = di.model_profile_ref
where di.id = $1 and di.project_id = $2
for share of di, dmp`,
[command.deviceId, project.id],
),
client.query(
`select id, project_id, owner_scope_id, display_name, lifecycle_state
from device_assets
where id = $1 and project_id = $2
for share`,
[command.assetId, project.id],
),
]);
const device = requireRow(deviceResult, "device_not_found", 404);
const asset = requireRow(assetResult, "device_asset_not_found", 404);
if (!["claimed", "online", "offline"].includes(device.lifecycle_state)) {
throw domainError("device_asset_binding_device_inactive", 409);
}
if (command.bindingKind === "tracking" && device.device_type !== "tracker") {
throw domainError("device_asset_binding_tracker_required", 409);
}
if (asset.lifecycle_state !== "active") {
throw domainError("device_asset_binding_asset_inactive", 409);
}
const result = await client.query(
`insert into device_asset_bindings (
id, owner_scope_id, project_id, binding_key, device_id, asset_id,
binding_kind, valid_from, provenance_ref, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, binding_key) do update set
updated_at = now()
where device_asset_bindings.device_id = excluded.device_id
and device_asset_bindings.asset_id = excluded.asset_id
and device_asset_bindings.binding_kind = excluded.binding_kind
and device_asset_bindings.valid_from = excluded.valid_from
and device_asset_bindings.valid_to is null
and device_asset_bindings.provenance_ref = excluded.provenance_ref
returning *, (xmax = 0) as created`,
[
randomUUID(),
project.owner_scope_id,
project.id,
command.bindingKey,
command.deviceId,
command.assetId,
command.bindingKind,
command.validFrom,
command.provenanceRef,
actor.userRef,
],
);
const row = requireRow(result, "device_asset_binding_identity_conflict");
await addAudit(client, {
actor,
project,
deviceId: row.device_id,
eventType: row.created ? "asset_binding.created" : "asset_binding.confirmed",
payload: assetBindingAudit(row),
});
return { created: row.created === true, assetBinding: assetBindingView(row) };
}
async function closeAssetBinding(client, actor, project, command) {
const result = await client.query(
`update device_asset_bindings
set valid_to = $3,
closed_by_ref = $4,
updated_at = now()
where id = $1
and project_id = $2
and valid_to is null
and valid_from < $3
returning *`,
[command.assetBindingId, project.id, command.validTo, actor.userRef],
);
const row = requireRow(result, "device_asset_binding_not_closable", 409);
await addAudit(client, {
actor,
project,
deviceId: row.device_id,
eventType: "asset_binding.closed",
payload: assetBindingAudit(row),
});
return { closed: true, assetBinding: assetBindingView(row) };
}
async function ensureHost(client, actor, project, command) {
const result = await client.query(
`insert into device_infrastructure_hosts (
id, owner_scope_id, project_id, host_key, display_name, provider_ref,
external_ref, management_credential_ref, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, host_key) do update set
display_name = excluded.display_name,
provider_ref = excluded.provider_ref,
external_ref = excluded.external_ref,
management_credential_ref = excluded.management_credential_ref,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_hosts.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostKey,
command.displayName, command.providerRef, command.externalRef,
command.managementCredentialRef, command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_host_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_host.created" : "infrastructure_host.updated",
payload: {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
providerRef: row.provider_ref,
lifecycleState: row.lifecycle_state,
managementCredentialConfigured: row.management_credential_ref != null,
ontologyEntityId: row.ontology_entity_id,
},
});
return { created: row.created === true, host: hostView(row) };
}
async function ensureEndpoint(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const result = await client.query(
`insert into device_infrastructure_endpoints (
id, owner_scope_id, project_id, host_id, endpoint_key, purpose,
endpoint_uri, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
on conflict (host_id, endpoint_key) do update set
purpose = excluded.purpose,
endpoint_uri = excluded.endpoint_uri,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_endpoints.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.endpointKey, command.purpose, command.endpointUri,
command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_endpoint_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_endpoint.created" : "infrastructure_endpoint.updated",
payload: {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
purpose: row.purpose,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, endpoint: endpointView(row) };
}
async function ensureDeployment(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const result = await client.query(
`insert into device_infrastructure_deployments (
id, owner_scope_id, project_id, host_id, deployment_key, display_name,
artifact_ref, artifact_digest, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, deployment_key) do update set
display_name = excluded.display_name,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_deployments.host_id = excluded.host_id
and device_infrastructure_deployments.artifact_ref = excluded.artifact_ref
and device_infrastructure_deployments.artifact_digest = excluded.artifact_digest
and (
device_infrastructure_deployments.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
)
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.deploymentKey, command.displayName, command.artifactRef,
command.artifactDigest, command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_deployment_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_deployment.created" : "infrastructure_deployment.updated",
payload: {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, deployment: deploymentView(row) };
}
async function ensureServiceInstance(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const deployment = await client.query(
`select id, host_id, lifecycle_state
from device_infrastructure_deployments
where id = $1 and project_id = $2
for share`,
[command.deploymentId, project.id],
);
const deploymentRow = requireRow(deployment, "device_deployment_not_found", 404);
if (deploymentRow.host_id !== command.hostId) {
throw domainError("device_service_instance_host_mismatch", 409);
}
if (deploymentRow.lifecycle_state === "retired") {
throw domainError("device_service_instance_deployment_inactive", 409);
}
if (command.edgeId) {
const edge = await client.query(
`select de.id
from device_edges de
where de.id = $1
and exists (
select 1 from device_routes dr
where dr.edge_id = de.id and dr.project_id = $2
)
for share`,
[command.edgeId, project.id],
);
requireRow(edge, "device_service_instance_edge_not_in_project", 409);
}
const result = await client.query(
`insert into device_infrastructure_service_instances (
id, owner_scope_id, project_id, host_id, deployment_id, edge_id,
service_key, display_name, service_role, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (host_id, service_key) do update set
display_name = excluded.display_name,
edge_id = excluded.edge_id,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_service_instances.deployment_id = excluded.deployment_id
and device_infrastructure_service_instances.service_role = excluded.service_role
and (
device_infrastructure_service_instances.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
)
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.deploymentId, command.edgeId, command.serviceKey,
command.displayName, command.serviceRole, command.lifecycleState,
actor.userRef,
],
);
const row = requireRow(result, "device_service_instance_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_service_instance.created" : "infrastructure_service_instance.updated",
payload: {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, serviceInstance: serviceInstanceView(row) };
}
async function recordHealthObservation(client, actor, project, command) {
const subjectColumn = command.subjectKind === "host"
? "host_id"
: "service_instance_id";
const subjectTable = command.subjectKind === "host"
? "device_infrastructure_hosts"
: "device_infrastructure_service_instances";
const subject = await client.query(
`select id from ${subjectTable}
where id = $1 and project_id = $2 and lifecycle_state <> 'retired'
for share`,
[command.subjectId, project.id],
);
requireRow(subject, "device_health_subject_not_found", 404);
const result = await client.query(
`insert into device_health_observations (
id, owner_scope_id, project_id, ${subjectColumn}, observed_state,
evidence_class, source_ref, schema_ref, evidence_projection,
observed_at, expires_at, recorded_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)
returning *`,
[
randomUUID(), project.owner_scope_id, project.id, command.subjectId,
command.observedState, command.evidenceClass, command.sourceRef,
command.schemaRef, JSON.stringify(command.evidence), command.observedAt,
command.expiresAt, actor.userRef,
],
);
const row = result.rows[0];
await addAudit(client, {
actor,
project,
eventType: "health_observation.recorded",
payload: {
healthObservationRef: `health-observation:${row.id}`,
subjectKind: command.subjectKind,
subjectRef: `${command.subjectKind}:${command.subjectId}`,
observedState: row.observed_state,
evidenceClass: row.evidence_class,
observedAt: toIso(row.observed_at),
expiresAt: toIso(row.expires_at),
ontologyEntityId: row.ontology_entity_id,
},
});
return { recorded: true, healthObservation: healthObservationView(row) };
}
async function requireActiveHost(client, projectId, hostId) {
const result = await client.query(
`select id, lifecycle_state
from device_infrastructure_hosts
where id = $1 and project_id = $2
for share`,
[hostId, projectId],
);
const row = requireRow(result, "device_host_not_found", 404);
if (!new Set(["provisioning", "active"]).has(row.lifecycle_state)) {
throw domainError("device_host_inactive", 409);
}
return row;
}
async function addAudit(client, {
actor,
project,
eventType,
deviceId = null,
payload,
}) {
await client.query(
`insert into device_audit_events (
id, event_type, actor_ref, project_id, device_id, payload
) values ($1, $2, $3, $4, $5, $6::jsonb)`,
[
randomUUID(), eventType, actor.userRef, project.id, deviceId,
JSON.stringify({ projectRef: toProjectRef(project.id), ...payload }),
],
);
}
function assetView(row) {
return {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
displayName: row.display_name,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function assetBindingAudit(row) {
return {
assetBindingRef: `asset-binding:${row.id}`,
deviceRef: `device:${row.device_id}`,
assetRef: `asset:${row.asset_id}`,
bindingKind: row.binding_kind,
validFrom: toIso(row.valid_from),
validTo: toIso(row.valid_to),
provenanceRef: row.provenance_ref,
ontologyEntityId: row.ontology_entity_id,
};
}
function assetBindingView(row) {
return {
...assetBindingAudit(row),
bindingKey: row.binding_key,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function hostView(row) {
return {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
displayName: row.display_name,
providerRef: row.provider_ref ?? null,
externalRef: row.external_ref ?? null,
managementCredentialConfigured: row.management_credential_ref != null,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function endpointView(row) {
return {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
endpointKey: row.endpoint_key,
purpose: row.purpose,
endpointUri: row.endpoint_uri,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function deploymentView(row) {
return {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentKey: row.deployment_key,
displayName: row.display_name,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function serviceInstanceView(row) {
return {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceKey: row.service_key,
displayName: row.display_name,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function healthObservationView(row) {
const subjectKind = row.host_id ? "host" : "service-instance";
return {
healthObservationRef: `health-observation:${row.id}`,
subjectKind,
subjectRef: `${subjectKind}:${row.host_id ?? row.service_instance_id}`,
observedState: row.observed_state,
evidenceClass: row.evidence_class,
sourceRef: row.source_ref,
schemaRef: row.schema_ref,
evidence: row.evidence_projection,
observedAt: toIso(row.observed_at),
expiresAt: toIso(row.expires_at),
ontology: ontologyView(row),
};
}
function ontologyView(row) {
return {
entityId: row.ontology_entity_id,
catalogHash: row.ontology_catalog_hash,
};
}
function requireRow(result, code, statusCode = 409) {
const row = result?.rows?.[0];
if (!row) throw domainError(code, statusCode);
return row;
}
function assertOntologyCommand(commandKind) {
if (!isOntologyManagementCommand(commandKind)) {
throw new TypeError("device_ontology_command_kind_invalid");
}
}
function toIso(value) {
return value == null ? null : new Date(value).toISOString();
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -19,6 +19,9 @@ import {
getDeviceProjectWorkspace,
listAccessibleDeviceProjects,
} from "./project-query-repository.mjs";
import {
getDeviceProjectOntologyProjection,
} from "./ontology-query-repository.mjs";
import {
applyInfrastructureManagementCommand,
authorizeInfrastructureManagementReplay,
@@ -36,6 +39,11 @@ import {
import {
isSensitiveReferenceManagementCommand,
} from "./sensitive-reference-management.mjs";
import {
applyOntologyManagementCommand,
authorizeOntologyManagementReplay,
} from "./ontology-repository.mjs";
import { isOntologyManagementCommand } from "./ontology-management.mjs";
import {
assertActorCanManageOwnerScope,
assertGrantMutationAllowed,
@@ -66,6 +74,7 @@ const migrationFiles = [
"013_device_edge_channels.sql",
"014_device_registry_profile_commands.sql",
"015_device_integration_identity.sql",
"016_device_asset_infrastructure_ontology.sql",
];
export class PostgresDeviceRepository {
@@ -181,6 +190,12 @@ export class PostgresDeviceRepository {
);
}
async getProjectOntologyProjection(actor, projectId) {
return this.#executeRead((client) =>
getDeviceProjectOntologyProjection(client, actor, projectId)
);
}
async listActiveEdgeChannelRegistrations(limit = 64) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
throw new TypeError("device_edge_channel_registration_limit_invalid");
@@ -320,6 +335,13 @@ async function completeManagementReceipt(client, receiptId, result) {
}
async function applyManagementCommand(client, { commandKind, actor, command }) {
if (isOntologyManagementCommand(commandKind)) {
return applyOntologyManagementCommand(client, {
commandKind,
actor,
command,
});
}
if (isControlResourceManagementCommand(commandKind)) {
return applyControlResourceManagementCommand(client, {
commandKind,
@@ -364,6 +386,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
}
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
if (isOntologyManagementCommand(commandKind)) {
return authorizeOntologyManagementReplay(client, {
commandKind,
actor,
command,
});
}
if (isControlResourceManagementCommand(commandKind)) {
return authorizeControlResourceManagementReplay(client, {
commandKind,
@@ -3,6 +3,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
"project.manage",
"access.manage",
"inventory.read",
"asset.manage",
"device.enroll",
"device.claim",
"device.transfer",
@@ -10,6 +11,9 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -53,6 +57,7 @@ const roleCapabilities = Object.freeze({
viewer: Object.freeze([
"project.read",
"inventory.read",
"infrastructure.read",
"telemetry.observe",
"configuration.read",
"audit.read",
@@ -61,6 +66,8 @@ const roleCapabilities = Object.freeze({
"project.read",
"inventory.read",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"configuration.read",
"command.plan",
"command.confirm",
@@ -72,10 +79,14 @@ const roleCapabilities = Object.freeze({
"inventory.read",
"device.enroll",
"device.claim",
"asset.manage",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -88,10 +99,14 @@ const roleCapabilities = Object.freeze({
"inventory.read",
"device.enroll",
"device.claim",
"asset.manage",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -8,7 +8,7 @@ const replayedIntermediateConstraintMigrations = Object.freeze([
"009_device_sensitive_reference_commands.sql",
"011_device_control_resource_commands.sql",
]);
const finalCommandKindMigration = "014_device_registry_profile_commands.sql";
const finalCommandKindMigration = "016_device_asset_infrastructure_ontology.sql";
const finalCommandKinds = Object.freeze([
"owner_scope.ensure",
"project.ensure",
@@ -31,6 +31,14 @@ const finalCommandKinds = Object.freeze([
"device_binding.revoke",
"device_configuration_revision.create",
"device_configuration_desired.set",
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]);
const migrationUrl = new URL(
"../migrations/003_device_management_commands.sql",
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createControlCoreApp } from "../src/app.mjs";
const managementToken = "test-only-management-token-with-32-bytes";
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
const projectId = "11111111-1111-4111-8111-111111111111";
test("management API forwards a canonical asset command", async () => {
let executed;
const runtime = await startServer({
executeManagementCommand: async (input) => {
executed = input;
return { replayed: false, result: { created: true } };
},
});
try {
const response = await fetch(`${runtime.baseUrl}/internal/v1/management/assets:ensure`, {
method: "POST",
headers: managementHeaders("ontology-asset-0001"),
body: JSON.stringify({
projectRef: `project:${projectId}`,
assetKey: "trike-001",
displayName: "Trike 001",
assetTypeRef: "asset-type:delivery-trike",
}),
});
assert.equal(response.status, 200);
assert.equal(executed.commandKind, "asset.ensure");
assert.equal(executed.command.assetKey, "trike-001");
} finally {
await runtime.close();
}
});
test("query API exposes the ontology projection through Core", async () => {
let actor;
const runtime = await startServer({
getProjectOntologyProjection: async (value, requestedProjectId) => {
actor = value;
assert.equal(requestedProjectId, projectId);
return {
ontology: { catalogHash: "229c61c02a790906" },
assets: [],
hosts: [],
};
},
});
try {
const response = await fetch(
`${runtime.baseUrl}/internal/v1/query/projects/${projectId}/ontology`,
{ headers: managementHeaders("ontology-query-0001") },
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.projection.ontology.catalogHash, "229c61c02a790906");
assert.equal(actor.userRef, "user:test-owner");
} finally {
await runtime.close();
}
});
async function startServer(repositoryOverrides) {
const server = createControlCoreApp({
managementApiEnabled: true,
managementToken,
identifierPepper,
repository: {
health: async () => "ready",
executeManagementCommand: async () => ({ replayed: false, result: {} }),
...repositoryOverrides,
},
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve, reject) =>
server.close((error) => error ? reject(error) : resolve())),
};
}
function managementHeaders(idempotencyKey) {
return {
Authorization: `Bearer ${managementToken}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
"X-NODEDC-User-Ref": "user:test-owner",
"X-NODEDC-Hub-Role": "owner",
"X-NODEDC-Group-Refs": "",
"X-NODEDC-Owner-Scopes": "company=organization:test",
};
}
@@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_ONTOLOGY_CATALOG_HASH,
normalizeOntologyManagementCommand,
} from "../src/ontology-management.mjs";
import {
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
normalizeDeviceManagementCommand,
} from "../src/management-command.mjs";
const projectRef = "project:11111111-1111-4111-8111-111111111111";
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
const assetRef = "asset:33333333-3333-4333-8333-333333333333";
const hostRef = "host:44444444-4444-4444-8444-444444444444";
const deploymentRef = "deployment:55555555-5555-4555-8555-555555555555";
test("publishes the production ontology catalog contract", () => {
assert.equal(DEVICE_ONTOLOGY_CATALOG_HASH, "229c61c02a790906");
for (const kind of [
"asset.ensure",
"asset_binding.ensure",
"infrastructure_host.ensure",
"health_observation.record",
]) {
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
}
});
test("normalizes an asset and a temporal tracker binding", () => {
const asset = normalizeDeviceManagementCommand("asset.ensure", {
projectRef,
assetKey: "trike-001",
displayName: "Trike 001",
assetTypeRef: "asset-type:delivery-trike",
});
const binding = normalizeDeviceManagementCommand("asset_binding.ensure", {
projectRef,
bindingKey: "trike-001-primary-tracker",
deviceRef,
assetRef,
bindingKind: "tracking",
validFrom: "2026-08-22T10:00:00.000Z",
provenanceRef: "onboarding:direct-b2",
});
assert.equal(asset.assetKey, "trike-001");
assert.equal(binding.deviceId, deviceRef.slice("device:".length));
assert.equal(binding.assetId, assetRef.slice("asset:".length));
assert.equal(binding.bindingKind, "tracking");
});
test("normalizes provider-neutral host topology without browser credentials", () => {
const host = normalizeOntologyManagementCommand("infrastructure_host.ensure", {
projectRef,
hostKey: "b2-edge-moscow",
displayName: "B2 Edge Moscow",
providerRef: "provider:beget",
externalRef: "provider-resource:vps-123",
managementCredentialRef: "secret-ref:device-core/b2-edge-moscow",
lifecycleState: "active",
});
const deployment = normalizeOntologyManagementCommand(
"infrastructure_deployment.ensure",
{
projectRef,
hostRef,
deploymentKey: "device-edge-001",
displayName: "Device Edge 001",
artifactRef: "artifact:device-edge/1.0.0",
artifactDigest: `sha256:${"a".repeat(64)}`,
},
);
const service = normalizeOntologyManagementCommand(
"infrastructure_service_instance.ensure",
{
projectRef,
hostRef,
deploymentRef,
serviceKey: "device-edge",
displayName: "Device Edge",
serviceRole: "device.edge",
},
);
assert.equal(host.managementCredentialRef.startsWith("secret-ref:"), true);
assert.equal(deployment.hostId, hostRef.slice("host:".length));
assert.equal(service.serviceRole, "device.edge");
assert.equal("password" in host, false);
});
test("rejects credential-bearing endpoints and secret-shaped health evidence", () => {
assert.throws(
() => normalizeOntologyManagementCommand("infrastructure_endpoint.ensure", {
projectRef,
hostRef,
endpointKey: "ssh",
purpose: "management",
endpointUri: "ssh://root:password@example.test:22/",
}),
/device_endpoint_uri_invalid/,
);
assert.throws(
() => normalizeOntologyManagementCommand("health_observation.record", {
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: { token: "forbidden" },
observedAt: "2026-08-22T10:00:00.000Z",
expiresAt: "2026-08-22T10:01:00.000Z",
}),
/forbidden_device_field/,
);
});
test("health is a bounded observation and not a permanent online flag", () => {
const command = normalizeOntologyManagementCommand(
"health_observation.record",
{
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: { latencyMs: 42 },
observedAt: "2026-08-22T10:00:00.000Z",
expiresAt: "2026-08-22T10:01:00.000Z",
},
);
assert.equal(command.observedState, "reachable");
assert.equal(command.expiresAt, "2026-08-22T10:01:00.000Z");
assert.throws(
() => normalizeOntologyManagementCommand("health_observation.record", {
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: {},
observedAt: command.observedAt,
expiresAt: command.observedAt,
}),
/device_health_freshness_window_invalid/,
);
});
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const migrationUrl = new URL(
"../migrations/016_device_asset_infrastructure_ontology.sql",
import.meta.url,
);
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
test("ontology migration is additive and encodes the official entity identifiers", async () => {
const sql = await readFile(migrationUrl, "utf8");
for (const table of [
"device_assets",
"device_asset_bindings",
"device_infrastructure_hosts",
"device_infrastructure_deployments",
"device_infrastructure_service_instances",
"device_health_observations",
]) {
assert.match(sql, new RegExp(`create table if not exists ${table}`, "i"));
}
for (const entityId of [
"asset.asset",
"device.asset_binding",
"infrastructure.host",
"infrastructure.deployment",
"infrastructure.service_instance",
"observation.health_observation",
]) {
assert.equal(sql.includes(`'${entityId}'`), true);
}
assert.equal(sql.includes("229c61c02a790906"), true);
for (const commandKind of [
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]) {
assert.equal(sql.includes(`'${commandKind}'`), true);
}
assert.doesNotMatch(sql, /insert\s+into\s+device_(?:assets|infrastructure_hosts)/i);
assert.doesNotMatch(sql, /gelios|arusnavi|beget|hetzner|aws/i);
});
test("ontology migration follows integration identity", async () => {
const source = await readFile(repositoryUrl, "utf8");
assert.ok(
source.indexOf("015_device_integration_identity.sql")
< source.indexOf("016_device_asset_infrastructure_ontology.sql"),
);
});