From 40fbfcf351d4f6aad5ec45f0cbfaf1551168ca60 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 10:51:02 +0300 Subject: [PATCH] fix(device-core): activate adopted model profiles safely --- .../src/infrastructure-repository.mjs | 13 ++- ...infrastructure-lifecycle-contract.test.mjs | 10 +++ .../test/infrastructure-repository.test.mjs | 82 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) 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 ebad411..5828883 100644 --- a/device-plane/services/device-control-core/src/infrastructure-repository.mjs +++ b/device-plane/services/device-control-core/src/infrastructure-repository.mjs @@ -271,7 +271,18 @@ async function registerModelProfile(client, actor, command) { and device_model_profiles.protocol = excluded.protocol and ( ( - device_model_profiles.profile = excluded.profile + ( + device_model_profiles.profile = excluded.profile + or ( + 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 = excluded.adapter_version_id and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref and device_model_profiles.profile_digest = excluded.profile_digest 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 a0561c8..38f97eb 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 @@ -31,3 +31,13 @@ test("legacy model adoption is a one-way exact-identity registry transition", as 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'/); }); + +test("adopted rich profile lifecycle changes preserve exact registry identity", async () => { + const source = await readFile( + new URL("../src/infrastructure-repository.mjs", import.meta.url), + "utf8", + ); + + assert.match(source, /profile = excluded\.profile[\s\S]*or \([\s\S]*profile ->> 'schemaVersion' = excluded\.schema_version[\s\S]*profile ->> 'profileRef' = excluded\.profile_ref[\s\S]*profile ->> 'vendor' = excluded\.vendor[\s\S]*profile ->> 'model' = excluded\.model[\s\S]*profile ->> 'deviceType' = excluded\.device_type[\s\S]*profile ->> 'protocol' = excluded\.protocol/); + assert.match(source, /adapter_version_id = excluded\.adapter_version_id[\s\S]*schema_artifact_ref = excluded\.schema_artifact_ref[\s\S]*profile_digest = excluded\.profile_digest[\s\S]*capabilities = excluded\.capabilities/); +}); 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 79e7e47..2fd60c9 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 @@ -198,6 +198,88 @@ test("keeps non-legacy model profile identity conflicts fail-closed", async () = assert.equal(client.released, true); }); +test("activates an adopted rich legacy profile without replacing its immutable JSON", 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"], + lifecycleState: "active", + }); + const client = scriptedClient([ + step("begin"), + step("insert into device_management_command_receipts", { + rows: [{ id: "receipt-profile-activation" }], + }), + 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: adapterVersionId, + schema_artifact_ref: command.schemaArtifactRef, + profile_digest: command.profileDigest, + capabilities: command.capabilities, + lifecycle_state: "draft", + }], + }), + 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: "active", + 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: "d", + })); + + assert.equal(result.result.created, false); + assert.equal(result.result.modelProfile.lifecycleState, "active"); + 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", {