diff --git a/device-plane/services/device-control-core/src/infrastructure-repository.mjs b/device-plane/services/device-control-core/src/infrastructure-repository.mjs index bad8cc7..ebad411 100644 --- a/device-plane/services/device-control-core/src/infrastructure-repository.mjs +++ b/device-plane/services/device-control-core/src/infrastructure-repository.mjs @@ -221,6 +221,11 @@ async function registerModelProfile(client, actor, command) { schemaArtifactRef: command.schemaArtifactRef, capabilities: command.capabilities, }; + const existingProfile = await findOptionalModelProfileRegistration( + client, + command.profileRef, + ); + const adoptsLegacyProfile = isLegacyMetadataOnlyProfile(existingProfile); const result = await client.query( `insert into device_model_profiles ( profile_ref, @@ -237,6 +242,26 @@ async function registerModelProfile(client, actor, command) { lifecycle_state ) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12) on conflict (profile_ref) do update set + adapter_version_id = case + when device_model_profiles.adapter_version_id is null + then excluded.adapter_version_id + else device_model_profiles.adapter_version_id + end, + schema_artifact_ref = case + when device_model_profiles.schema_artifact_ref is null + then excluded.schema_artifact_ref + else device_model_profiles.schema_artifact_ref + end, + profile_digest = case + when device_model_profiles.profile_digest is null + then excluded.profile_digest + else device_model_profiles.profile_digest + end, + capabilities = case + when cardinality(device_model_profiles.capabilities) = 0 + then excluded.capabilities + else device_model_profiles.capabilities + end, lifecycle_state = excluded.lifecycle_state, updated_at = now() where device_model_profiles.schema_version = excluded.schema_version @@ -244,20 +269,39 @@ async function registerModelProfile(client, actor, command) { and device_model_profiles.model = excluded.model and device_model_profiles.device_type = excluded.device_type and device_model_profiles.protocol = excluded.protocol - and device_model_profiles.profile = excluded.profile - and device_model_profiles.adapter_version_id = excluded.adapter_version_id - and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref - and device_model_profiles.profile_digest = excluded.profile_digest - and device_model_profiles.capabilities = excluded.capabilities and ( - device_model_profiles.lifecycle_state = excluded.lifecycle_state - or ( - device_model_profiles.lifecycle_state = 'draft' - and excluded.lifecycle_state in ('active', 'retired') + ( + device_model_profiles.profile = excluded.profile + and device_model_profiles.adapter_version_id = excluded.adapter_version_id + and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref + and device_model_profiles.profile_digest = excluded.profile_digest + and device_model_profiles.capabilities = excluded.capabilities + and ( + device_model_profiles.lifecycle_state = excluded.lifecycle_state + or ( + device_model_profiles.lifecycle_state = 'draft' + and excluded.lifecycle_state in ('active', 'retired') + ) + or ( + device_model_profiles.lifecycle_state = 'active' + and excluded.lifecycle_state = 'retired' + ) + ) ) or ( - device_model_profiles.lifecycle_state = 'active' - and excluded.lifecycle_state = 'retired' + jsonb_typeof(device_model_profiles.profile) = 'object' + and device_model_profiles.profile ->> 'schemaVersion' = excluded.schema_version + and device_model_profiles.profile ->> 'profileRef' = excluded.profile_ref + and device_model_profiles.profile ->> 'vendor' = excluded.vendor + and device_model_profiles.profile ->> 'model' = excluded.model + and device_model_profiles.profile ->> 'deviceType' = excluded.device_type + and device_model_profiles.profile ->> 'protocol' = excluded.protocol + and device_model_profiles.adapter_version_id is null + and device_model_profiles.schema_artifact_ref is null + and device_model_profiles.profile_digest is null + and cardinality(device_model_profiles.capabilities) = 0 + and device_model_profiles.lifecycle_state = 'active' + and excluded.lifecycle_state = 'draft' ) ) returning profile_ref, schema_version, vendor, model, device_type, @@ -286,7 +330,9 @@ async function registerModelProfile(client, actor, command) { await addAudit(client, { eventType: row.created ? "model_profile.registered" - : "model_profile.lifecycle_updated", + : adoptsLegacyProfile + ? "model_profile.registry_adopted" + : "model_profile.lifecycle_updated", actorRef: actor.userRef, payload: { adapterVersionRef: `adapter-version:${row.adapter_version_id}`, @@ -302,6 +348,28 @@ async function registerModelProfile(client, actor, command) { }; } +async function findOptionalModelProfileRegistration(client, profileRef) { + const result = await client.query( + `select profile_ref, adapter_version_id, schema_artifact_ref, + profile_digest, capabilities, lifecycle_state + from device_model_profiles + where profile_ref = $1 + for update`, + [profileRef], + ); + return result.rows[0] ?? null; +} + +function isLegacyMetadataOnlyProfile(profile) { + return profile != null + && profile.adapter_version_id == null + && profile.schema_artifact_ref == null + && profile.profile_digest == null + && Array.isArray(profile.capabilities) + && profile.capabilities.length === 0 + && profile.lifecycle_state === "active"; +} + async function ensureEdge(client, actor, command) { assertPlatformCatalogAuthority(actor); const channelProvided = command.channel !== undefined; diff --git a/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs b/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs index 3afa4a1..a0561c8 100644 --- a/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs +++ b/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs @@ -17,3 +17,17 @@ test("catalog, Edge and route upserts enforce irreversible lifecycle transitions assert.doesNotMatch(source, /device_(?:adapter_versions|model_profiles|edges|routes)\.lifecycle_state = 'retired'[\s\S]{0,160}excluded\.lifecycle_state = 'active'/); }); +test("legacy model adoption is a one-way exact-identity registry transition", async () => { + const source = await readFile( + new URL("../src/infrastructure-repository.mjs", import.meta.url), + "utf8", + ); + + assert.match(source, /profile ->> 'schemaVersion' = excluded\.schema_version/); + assert.match(source, /profile ->> 'profileRef' = excluded\.profile_ref/); + assert.match(source, /profile ->> 'vendor' = excluded\.vendor/); + assert.match(source, /profile ->> 'model' = excluded\.model/); + assert.match(source, /profile ->> 'deviceType' = excluded\.device_type/); + assert.match(source, /profile ->> 'protocol' = excluded\.protocol/); + assert.match(source, /adapter_version_id is null[\s\S]*schema_artifact_ref is null[\s\S]*profile_digest is null[\s\S]*cardinality\(device_model_profiles\.capabilities\) = 0[\s\S]*lifecycle_state = 'active'[\s\S]*excluded\.lifecycle_state = 'draft'/); +}); diff --git a/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs b/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs index ba28534..79e7e47 100644 --- a/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs +++ b/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs @@ -10,6 +10,8 @@ const now = new Date("2026-08-10T00:00:00.000Z"); const projectId = "11111111-1111-4111-8111-111111111111"; const edgeId = "22222222-2222-4222-8222-222222222222"; const routeId = "33333333-3333-4333-8333-333333333333"; +const adapterPackageId = "44444444-4444-4444-8444-444444444444"; +const adapterVersionId = "55555555-5555-4555-8555-555555555555"; test("commits an owner-authorized generic Edge registration", async () => { const actor = managementActor("owner"); @@ -55,6 +57,147 @@ test("commits an owner-authorized generic Edge registration", async () => { assert.equal(client.released, true); }); +test("adopts a legacy metadata-only model profile into the versioned registry", async () => { + const actor = managementActor("owner"); + const command = normalizeDeviceManagementCommand("model_profile.register", { + adapterVersionRef: `adapter-version:${adapterVersionId}`, + profileRef: "vendor.model.protocol.v1", + schemaVersion: "nodedc.device-model-profile.v1", + vendor: "Example Vendor", + model: "Model One", + deviceType: "tracker", + protocol: "GENERIC_TCP", + schemaArtifactRef: "artifact:model-profiles/vendor-model-v1", + profileDigest: `sha256:${"e".repeat(64)}`, + capabilities: ["telemetry.observe"], + }); + const client = scriptedClient([ + step("begin"), + step("insert into device_management_command_receipts", { + rows: [{ id: "receipt-profile" }], + }), + step("from device_adapter_versions av", { + rows: [{ + id: adapterVersionId, + adapter_package_id: adapterPackageId, + version: "1.0.0", + runtime_package_ref: "artifact:device-adapters/vendor-model-1.0.0", + content_digest: `sha256:${"f".repeat(64)}`, + contract_version: "nodedc.device-adapter.v1", + capabilities: ["telemetry.observe"], + lifecycle_state: "active", + package_lifecycle_state: "active", + created_at: now, + updated_at: now, + }], + }), + step("from device_model_profiles", { + rows: [{ + profile_ref: command.profileRef, + adapter_version_id: null, + schema_artifact_ref: null, + profile_digest: null, + capabilities: [], + lifecycle_state: "active", + }], + }), + step("insert into device_model_profiles", { + rows: [{ + profile_ref: command.profileRef, + schema_version: command.schemaVersion, + vendor: command.vendor, + model: command.model, + device_type: command.deviceType, + protocol: command.protocol, + adapter_version_id: adapterVersionId, + schema_artifact_ref: command.schemaArtifactRef, + profile_digest: command.profileDigest, + capabilities: command.capabilities, + lifecycle_state: "draft", + created_at: now, + updated_at: now, + created: false, + }], + }), + step("insert into device_audit_events"), + step("update device_management_command_receipts"), + step("commit"), + ]); + const repository = repositoryWithClient(client); + + const result = await repository.executeManagementCommand(commandInput({ + actor, + commandKind: "model_profile.register", + command, + digestCharacter: "e", + })); + + assert.equal(result.replayed, false); + assert.equal(result.result.created, false); + assert.equal( + result.result.modelProfile.adapterVersionRef, + `adapter-version:${adapterVersionId}`, + ); + assert.equal(result.result.modelProfile.lifecycleState, "draft"); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("keeps non-legacy model profile identity conflicts fail-closed", async () => { + const actor = managementActor("owner"); + const command = normalizeDeviceManagementCommand("model_profile.register", { + adapterVersionRef: `adapter-version:${adapterVersionId}`, + profileRef: "vendor.model.protocol.v1", + schemaVersion: "nodedc.device-model-profile.v1", + vendor: "Example Vendor", + model: "Model One", + deviceType: "tracker", + protocol: "GENERIC_TCP", + schemaArtifactRef: "artifact:model-profiles/vendor-model-v1", + profileDigest: `sha256:${"e".repeat(64)}`, + capabilities: ["telemetry.observe"], + }); + const client = scriptedClient([ + step("begin"), + step("insert into device_management_command_receipts", { + rows: [{ id: "receipt-profile-conflict" }], + }), + step("from device_adapter_versions av", { + rows: [{ + id: adapterVersionId, + adapter_package_id: adapterPackageId, + lifecycle_state: "active", + package_lifecycle_state: "active", + }], + }), + step("from device_model_profiles", { + rows: [{ + profile_ref: command.profileRef, + adapter_version_id: null, + schema_artifact_ref: "artifact:legacy-but-partial", + profile_digest: null, + capabilities: [], + lifecycle_state: "active", + }], + }), + step("insert into device_model_profiles"), + step("rollback"), + ]); + const repository = repositoryWithClient(client); + + await assert.rejects( + repository.executeManagementCommand(commandInput({ + actor, + commandKind: "model_profile.register", + command, + digestCharacter: "f", + })), + /device_model_profile_identity_conflict/, + ); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + test("denies project route mutation without an explicit project grant", async () => { const actor = managementActor("owner"); const command = normalizeDeviceManagementCommand("route.ensure", {